├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── assets └── css │ └── semantic.min.css ├── main.go ├── main_test.go ├── reporting ├── README.md └── lambda.py ├── templates ├── error.html ├── form.html ├── landing.html ├── layout.html └── thankyou.html ├── testdata └── config.yml └── unifi-portal.yml.example /.gitignore: -------------------------------------------------------------------------------- 1 | # Config file 2 | unifi-portal.yml 3 | 4 | # Binaries for programs and plugins 5 | unifi-captive-portal* 6 | *.exe 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 18 | .glide/ 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jarod Watkins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY = unifi-captive-portal 2 | GOARCH = amd64 3 | 4 | VERSION?=? 5 | COMMIT=$(shell git rev-parse HEAD) 6 | BRANCH=$(shell git rev-parse --abbrev-ref HEAD) 7 | 8 | # Setup the -ldflags option for go build here, interpolate the variable values 9 | LDFLAGS = -ldflags "-X main.Version=${VERSION} -X main.Commit=${COMMIT} -X main.Branch=${BRANCH}" 10 | 11 | # Build the project 12 | all: test fmt linux darwin windows 13 | 14 | linux: 15 | GOOS=linux GOARCH=${GOARCH} go build ${LDFLAGS} -o ${BINARY}-linux-${GOARCH} 16 | 17 | darwin: 18 | GOOS=darwin GOARCH=${GOARCH} go build ${LDFLAGS} -o ${BINARY}-darwin-${GOARCH} 19 | 20 | windows: 21 | GOOS=windows GOARCH=${GOARCH} go build ${LDFLAGS} -o ${BINARY}-windows-${GOARCH}.exe 22 | 23 | test: 24 | go test -v 25 | 26 | fmt: 27 | go fmt $$(go list ./... | grep -v /vendor/) 28 | 29 | .PHONY: linux darwin windows test fmt 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UniFi Captive Portal 2 | 3 | A [UniFi](https://www.ubnt.com) external captive portal which captures email 4 | addresses and saves them to a [DynamoDB](https://aws.amazon.com/dynamodb/) table. 5 | 6 | ## Running 7 | 8 | There are two important directories that need to be on disk in order for this 9 | program to run: `assets` and `templates`. Assets holds CSS/JS/IMG assets. 10 | Templates holds the various HTML templates. 11 | 12 | If you would like to add custom elements (such as a header image) feel free. 13 | The CSS library used is [Semantic UI](https://semantic-ui.com) so refer to their 14 | documentation if you would like to modify the look. 15 | 16 | You must have the DynamoDB table already created and have setup a shared 17 | credentials file per the [SDK docs](https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/configuring-sdk.html#specifying-credentials). 18 | Please use an IAM role with the least privileges required (read/write access to 19 | the DynamoDB table you created). **DO NOT USE YOUR ROOT ACCOUNT**. 20 | 21 | See the configuration section below for more information regarding the config 22 | file. You will need to specify its location along with the assets and 23 | templates directories. 24 | 25 | Also, due to a limitation in the UniFi controller software, external portals 26 | must run on port 80. I recommend running a reverse proxy (such as NGINX) in front 27 | of this application instead of running it with elevated privileges. 28 | 29 | ``` 30 | unifi-captive-portal: 31 | -asset.dir string 32 | Directory which contains css/js/img assets. (default "assets") 33 | -config.file string 34 | Unifi captive portal configuration file. (default "unifi-portal.yml") 35 | -template.dir string 36 | Directory which contains HTML templates. (default "templates") 37 | -verbose 38 | Enable verbose/debug logging. 39 | -version 40 | Print version/build information. 41 | -web.listen-address string 42 | Address to listen on for requests. (default ":4646") 43 | ``` 44 | 45 | ## Configuration 46 | 47 | Config Key | Value 48 | ---------- | ----- 49 | unifi_url | Full URL of your UniFi Controller. Be sure to include the port it is running on (8443 is the default) 50 | unifi_username | Username of the user to make API calls with. It is recommended to use a dedicated user 51 | unifi_password | Password for user defined above 52 | unifi_site | The name of the site the APs/Users reside in. Usually this is default 53 | title | Title used in HTML pages as well as headings. Usually you will put your company name here 54 | intro | Paragraph of text below the page title and above the form requesting a user for their email. You may wish to offer a brief explanation of why you are collecting their email address. 55 | tos | Terms of Service. I am not a lawyer, the sample TOS provided is in no way legally binding nor implied valid. Please consult legal advice for what to put here. 56 | minutes | Amount of time to register user for 57 | redirect_url | URL to redirect users to if they do not provide one to the controller 58 | dynamo_table_name | Name of your DynamoDB table to store the collected email addresses 59 | 60 | 61 | ## Building 62 | 63 | Pre built binaries are provided on Github, but if you prefer to manually compile, 64 | there is a Make file provided. The version variable is not required but highly 65 | recommended. 66 | 67 | $ VERSION="version" make 68 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "errors" 8 | "flag" 9 | "fmt" 10 | "gopkg.in/yaml.v2" 11 | "html/template" 12 | "io/ioutil" 13 | "net/http" 14 | "net/http/cookiejar" 15 | "os" 16 | "time" 17 | 18 | "github.com/aws/aws-sdk-go/aws" 19 | "github.com/aws/aws-sdk-go/aws/session" 20 | "github.com/aws/aws-sdk-go/service/dynamodb" 21 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 22 | 23 | log "github.com/sirupsen/logrus" 24 | ) 25 | 26 | var ( 27 | configFile = flag.String( 28 | "config.file", "unifi-portal.yml", 29 | "UniFi captive portal configuration file.", 30 | ) 31 | listenAddress = flag.String( 32 | "web.listen-address", ":4646", 33 | "Address to listen on for requests.", 34 | ) 35 | templateDir = flag.String( 36 | "template.dir", "templates", 37 | "Directory which contains HTML templates.", 38 | ) 39 | assetDir = flag.String( 40 | "asset.dir", "assets", 41 | "Directory which contains css/js/img assets.", 42 | ) 43 | verbose = flag.Bool( 44 | "verbose", false, 45 | "Enable verbose/debug logging.", 46 | ) 47 | version = flag.Bool( 48 | "version", false, 49 | "Print version/build information.", 50 | ) 51 | 52 | templates = template.Must(template.ParseGlob(fmt.Sprintf("%s/*", *templateDir))) 53 | config *Config 54 | 55 | Version string 56 | Commit string 57 | Branch string 58 | ) 59 | 60 | // Config represents the YAML structure of the configuration file. 61 | type Config struct { 62 | // UnifiURL URL of the UniFi instance you want to register users against 63 | UnifiURL string `yaml:"unifi_url"` 64 | // UnifiUsername username for UniFi API 65 | UnifiUsername string `yaml:"unifi_username"` 66 | // UnifiPassword password for UniFi API 67 | UnifiPassword string `yaml:"unifi_password"` 68 | // UnifiSite site for UniFi Controller 69 | UnifiSite string `yaml:"unifi_site"` 70 | // Title of HTML pages 71 | Title string `yaml:"title"` 72 | // TOS Terms of Service 73 | TOS string `yaml:"tos"` 74 | // Intro text on form page 75 | Intro string `yaml:"intro"` 76 | // Minutes user is authenticate for in one session 77 | Minutes string `yaml:"minutes"` 78 | // RedirectURL url to redirect user to if they did not have one supplied 79 | RedirectURL string `yaml:"redirect_url"` 80 | // DynamoTableName 81 | DynamoTableName string `yaml:"dynamo_table_name"` 82 | } 83 | 84 | type Db struct { 85 | Email string `json:"email"` 86 | ID string `json:"id"` 87 | AP string `json:"ap"` 88 | SSID string `json:"ssid"` 89 | Date time.Time `json:"date"` 90 | } 91 | 92 | type Page struct { 93 | Title string 94 | ID string 95 | AP string 96 | SSID string 97 | URL string 98 | TOS string 99 | Intro string 100 | Errors map[string]string 101 | FormEmail string 102 | FormTOS string 103 | } 104 | 105 | func authUser(id string, ap string) error { 106 | jar, err := cookiejar.New(nil) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | tr := &http.Transport{ 112 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 113 | } 114 | 115 | client := &http.Client{Jar: jar, Transport: tr} 116 | 117 | data := map[string]string{ 118 | "username": config.UnifiUsername, 119 | "password": config.UnifiPassword, 120 | } 121 | 122 | b := new(bytes.Buffer) 123 | json.NewEncoder(b).Encode(data) 124 | 125 | req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/login", config.UnifiURL), b) 126 | req.Header.Set("Content-Type", "application/json") 127 | 128 | resp, err := client.Do(req) 129 | if err != nil { 130 | return err 131 | } 132 | 133 | log.WithFields(log.Fields{ 134 | "code": resp.StatusCode, 135 | }).Debug("login response") 136 | 137 | if resp.StatusCode != 200 { 138 | return errors.New("Controller returned non 200 status code") 139 | } 140 | 141 | data = map[string]string{ 142 | "cmd": "authorize-guest", 143 | "mac": id, 144 | "minutes": config.Minutes, 145 | } 146 | 147 | b = new(bytes.Buffer) 148 | json.NewEncoder(b).Encode(data) 149 | 150 | req, err = http.NewRequest("POST", fmt.Sprintf("%s/api/s/%s/cmd/stamgr", config.UnifiURL, config.UnifiSite), b) 151 | req.Header.Set("Content-Type", "application/json") 152 | 153 | resp, err = client.Do(req) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | log.WithFields(log.Fields{ 159 | "code": resp.StatusCode, 160 | }).Debug("authorize command response") 161 | 162 | if resp.StatusCode != 200 { 163 | return errors.New("Controller returned non 200 status code") 164 | } 165 | 166 | data = map[string]string{ 167 | "cmd": "authorize-guest", 168 | "mac": id, 169 | "minutes": config.Minutes, 170 | } 171 | 172 | req, err = http.NewRequest("GET", fmt.Sprintf("%s/logout", config.UnifiURL), nil) 173 | _, err = client.Do(req) 174 | if err != nil { 175 | return err 176 | } 177 | 178 | return nil 179 | } 180 | 181 | func dbSave(email string, id string, ap string, ssid string) error { 182 | sess := session.Must(session.NewSessionWithOptions(session.Options{ 183 | SharedConfigState: session.SharedConfigEnable, 184 | })) 185 | 186 | svc := dynamodb.New(sess) 187 | 188 | item := Db{ 189 | Email: email, 190 | ID: id, 191 | AP: ap, 192 | SSID: ssid, 193 | Date: time.Now(), 194 | } 195 | 196 | av, err := dynamodbattribute.MarshalMap(item) 197 | if err != nil { 198 | return err 199 | } 200 | 201 | input := &dynamodb.PutItemInput{ 202 | Item: av, 203 | TableName: aws.String(config.DynamoTableName), 204 | } 205 | 206 | _, err = svc.PutItem(input) 207 | if err != nil { 208 | return err 209 | } 210 | 211 | return nil 212 | } 213 | 214 | func landingHandler(w http.ResponseWriter, r *http.Request) { 215 | id := r.URL.Query().Get("id") 216 | if id == "" { 217 | log.Error("Request missing id query parameter") 218 | errorHandler(w, r, http.StatusBadRequest) 219 | return 220 | } 221 | 222 | ap := r.URL.Query().Get("ap") 223 | if ap == "" { 224 | log.Error("Request missing ap query parameter") 225 | errorHandler(w, r, http.StatusBadRequest) 226 | return 227 | } 228 | 229 | ssid := r.URL.Query().Get("ssid") 230 | if ssid == "" { 231 | log.Error("Request missing ssid query parameter") 232 | errorHandler(w, r, http.StatusBadRequest) 233 | return 234 | } 235 | 236 | url := r.URL.Query().Get("url") 237 | 238 | vars := Page{ 239 | Title: config.Title, 240 | ID: id, 241 | AP: ap, 242 | SSID: ssid, 243 | URL: url, 244 | TOS: config.TOS, 245 | Intro: config.Intro, 246 | } 247 | 248 | err := templates.ExecuteTemplate(w, "landingPage", vars) 249 | if err != nil { 250 | log.Error(err.Error()) 251 | errorHandler(w, r, http.StatusInternalServerError) 252 | return 253 | } 254 | } 255 | 256 | func formHandler(w http.ResponseWriter, r *http.Request) { 257 | r.ParseForm() 258 | errors := make(map[string]string) 259 | 260 | if len(r.Form["email"][0]) == 0 { 261 | errors["email"] = "Please enter your email address" 262 | } 263 | 264 | var tos string 265 | _, ok := r.Form["tos"] 266 | if ok { 267 | tos = "on" 268 | } else { 269 | errors["tos"] = "You must agree to the Terms of Service" 270 | } 271 | 272 | if len(errors) != 0 { 273 | log.WithFields(log.Fields{ 274 | "errors": errors, 275 | }).Debug("Form Errors") 276 | 277 | vars := Page{ 278 | Title: config.Title, 279 | ID: r.Form["id"][0], 280 | AP: r.Form["ap"][0], 281 | SSID: r.Form["ssid"][0], 282 | URL: r.Form["url"][0], 283 | TOS: config.TOS, 284 | Intro: config.Intro, 285 | Errors: errors, 286 | FormEmail: r.Form["email"][0], 287 | FormTOS: tos, 288 | } 289 | 290 | err := templates.ExecuteTemplate(w, "landingPage", vars) 291 | if err != nil { 292 | log.Error(err.Error()) 293 | errorHandler(w, r, http.StatusInternalServerError) 294 | return 295 | } 296 | return 297 | } 298 | 299 | err := authUser(r.Form["id"][0], r.Form["ap"][0]) 300 | if err != nil { 301 | log.Error(err.Error()) 302 | errorHandler(w, r, http.StatusInternalServerError) 303 | return 304 | } 305 | 306 | redirect_url := config.RedirectURL 307 | 308 | if len(r.Form["url"][0]) != 0 { 309 | redirect_url = r.Form["url"][0] 310 | } 311 | 312 | log.WithFields(log.Fields{ 313 | "url": redirect_url, 314 | }).Debug("Redirecting user") 315 | 316 | vars := Page{Title: config.Title, URL: redirect_url} 317 | 318 | err = templates.ExecuteTemplate(w, "thankYouPage", vars) 319 | if err != nil { 320 | log.Error(err.Error()) 321 | errorHandler(w, r, http.StatusInternalServerError) 322 | return 323 | } 324 | 325 | err = dbSave(r.Form["email"][0], r.Form["id"][0], r.Form["ap"][0], r.Form["ssid"][0]) 326 | if err != nil { 327 | log.Error(err.Error()) 328 | } 329 | } 330 | 331 | func errorHandler(w http.ResponseWriter, r *http.Request, code int) { 332 | w.WriteHeader(code) 333 | vars := Page{Title: config.Title} 334 | err := templates.ExecuteTemplate(w, "errorPage", vars) 335 | if err != nil { 336 | log.Error(err.Error()) 337 | } 338 | } 339 | 340 | func loadConfiguration(file string) (*Config, error) { 341 | yamlFile, err := ioutil.ReadFile(*configFile) 342 | if err != nil { 343 | return nil, err 344 | } 345 | 346 | config := new(Config) 347 | err = yaml.Unmarshal(yamlFile, &config) 348 | if err != nil { 349 | return nil, err 350 | } 351 | 352 | return config, nil 353 | } 354 | 355 | func logRequest(handler http.Handler) http.Handler { 356 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 357 | log.WithFields(log.Fields{ 358 | "remote_addr": r.RemoteAddr, 359 | "method": r.Method, 360 | "url": r.URL, 361 | }).Debug("Request") 362 | handler.ServeHTTP(w, r) 363 | }) 364 | } 365 | 366 | func info() string { 367 | return fmt.Sprintf("version=%s, commit=%s, branch=%s", Version, Commit, Branch) 368 | } 369 | 370 | func main() { 371 | flag.Parse() 372 | 373 | if *version { 374 | fmt.Println(info()) 375 | os.Exit(0) 376 | } 377 | 378 | if *verbose { 379 | log.SetLevel(log.DebugLevel) 380 | } 381 | 382 | log.Info("Starting up...") 383 | log.Debug(info()) 384 | 385 | var err error 386 | config, err = loadConfiguration(*configFile) 387 | if err != nil { 388 | log.Fatalf("Configuration error, aborting: %s", err) 389 | } 390 | 391 | fs := http.FileServer(http.Dir(*assetDir)) 392 | http.Handle("/assets/", http.StripPrefix("/assets/", fs)) 393 | http.HandleFunc("/guest/s/default/", landingHandler) 394 | http.HandleFunc("/form", formHandler) 395 | log.Fatal(http.ListenAndServe(*listenAddress, logRequest(http.DefaultServeMux))) 396 | 397 | } 398 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func init() { 13 | c := "testdata/config.yml" 14 | configFile = &c 15 | 16 | var err error 17 | config, err = loadConfiguration("testdata/config.yml") 18 | if err != nil { 19 | panic("Could not load test configuration: " + err.Error()) 20 | } 21 | } 22 | 23 | func TestLandingHandler(t *testing.T) { 24 | req, err := http.NewRequest("GET", "/?id=1234&ap=ap01&ssid=ssid", nil) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | rr := httptest.NewRecorder() 30 | handler := http.HandlerFunc(landingHandler) 31 | 32 | handler.ServeHTTP(rr, req) 33 | 34 | if status := rr.Code; status != http.StatusOK { 35 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) 36 | } 37 | 38 | expected := "Test Captive Portal" 39 | if !strings.Contains(rr.Body.String(), expected) { 40 | t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) 41 | } 42 | 43 | } 44 | 45 | func TestLandingHandlerValidation(t *testing.T) { 46 | req, err := http.NewRequest("GET", "/", nil) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | rr := httptest.NewRecorder() 52 | handler := http.HandlerFunc(landingHandler) 53 | 54 | handler.ServeHTTP(rr, req) 55 | 56 | if status := rr.Code; status != http.StatusBadRequest { 57 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusBadRequest) 58 | } 59 | 60 | expected := "An error has occured" 61 | if !strings.Contains(rr.Body.String(), expected) { 62 | t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) 63 | } 64 | 65 | } 66 | 67 | func TestFormHandlerValidation(t *testing.T) { 68 | data := make(url.Values) 69 | data.Set("email", "user@example.com") 70 | data.Set("id", "1234") 71 | data.Set("ap", "ap01") 72 | data.Set("ssid", "ssid") 73 | data.Set("url", "") 74 | 75 | req, err := http.NewRequest("POST", "/form", strings.NewReader(data.Encode())) 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | 80 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 81 | 82 | rr := httptest.NewRecorder() 83 | handler := http.HandlerFunc(formHandler) 84 | 85 | handler.ServeHTTP(rr, req) 86 | 87 | if status := rr.Code; status != http.StatusOK { 88 | t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) 89 | } 90 | 91 | expected := "You must agree to the Terms of Service" 92 | if !strings.Contains(rr.Body.String(), expected) { 93 | t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) 94 | } 95 | 96 | } 97 | 98 | func TestAuthUser(t *testing.T) { 99 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 100 | w.Header().Set("Content-Type", "application/json") 101 | fmt.Fprintln(w, `{"response":"OK"}`) 102 | })) 103 | defer ts.Close() 104 | 105 | config.UnifiURL = ts.URL 106 | err := authUser("02:00:00:00:00:01", "ap01") 107 | if err != nil { 108 | t.Errorf("Mock request to UniFi controller returned: %v", err) 109 | } 110 | 111 | ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 112 | w.Header().Set("Content-Type", "application/json") 113 | w.WriteHeader(500) 114 | fmt.Fprintln(w, `{"response":"OK"}`) 115 | })) 116 | defer ts.Close() 117 | 118 | config.UnifiURL = ts.URL 119 | err = authUser("02:00:00:00:00:01", "ap01") 120 | expected := "Controller returned non 200 status code" 121 | 122 | if !strings.Contains(err.Error(), expected) { 123 | t.Errorf("Mock request to UniFi controller returned: %v, wanted Controller returned non 200 status code", err) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /reporting/README.md: -------------------------------------------------------------------------------- 1 | # UniFi Captive Portal Reporting Function 2 | 3 | This lambda function allows you to send an email with the collected data as a CSV 4 | attachment. 5 | -------------------------------------------------------------------------------- /reporting/lambda.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import csv 3 | import datetime 4 | import io 5 | import json 6 | import os 7 | 8 | from boto3.dynamodb.conditions import Key 9 | from email.mime.text import MIMEText 10 | from email.mime.application import MIMEApplication 11 | from email.mime.multipart import MIMEMultipart 12 | 13 | dynamodb = boto3.resource('dynamodb') 14 | ses = boto3.client('ses') 15 | 16 | print('Loading function') 17 | 18 | def lambda_handler(event, context): 19 | end = datetime.datetime.now() 20 | start = end - datetime.timedelta(days=int(event['days'])) 21 | 22 | table = dynamodb.Table(os.environ['DYNAMODB_TABLE']) 23 | 24 | fe = Key('date').between(start.isoformat(), end.isoformat()) 25 | 26 | response = table.scan( 27 | FilterExpression=fe, 28 | ) 29 | 30 | with open('/tmp/emails.csv', 'w', newline='') as file: 31 | writer = csv.writer(file, 32 | quoting=csv.QUOTE_NONNUMERIC, 33 | ) 34 | 35 | for i in response['Items']: 36 | writer.writerow([i['email']]) 37 | 38 | msg = MIMEMultipart() 39 | msg['Subject'] = event['email']['subject'] 40 | msg['From'] = event['email']['from'] 41 | msg['To'] = ', '.join(event['email']['to']) 42 | 43 | part = MIMEText('Attached is your captive portal email report from {} to {}.'.format(start.isoformat(), end.isoformat())) 44 | msg.attach(part) 45 | 46 | part = MIMEApplication(open('/tmp/emails.csv', 'rb').read()) 47 | part.add_header('Content-Disposition', 'attachment', filename='emails.csv') 48 | msg.attach(part) 49 | 50 | ses.send_raw_email( 51 | RawMessage={'Data': msg.as_string()}, 52 | Source=msg['From'], 53 | Destinations=event['email']['to'] 54 | ) 55 | 56 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | {{ define "errorPage" }} 2 | 3 | {{ template "head" . }} 4 |

We are sorry

5 |

An error has occured. Please contact the site administrator for further assistance.

6 | {{ template "tail" . }} 7 | 8 | {{ end }} 9 | 10 | -------------------------------------------------------------------------------- /templates/form.html: -------------------------------------------------------------------------------- 1 | {{ define "form" }} 2 |

{{ .Intro }}

3 |
4 |
5 | 6 | 7 |
8 |
9 |
10 | 11 | 12 |
13 |
14 |
15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {{if .Errors}} 26 |
27 |
33 | {{end}} 34 |
35 | {{ end }} 36 | -------------------------------------------------------------------------------- /templates/landing.html: -------------------------------------------------------------------------------- 1 | {{ define "landingPage" }} 2 | 3 | {{ template "head" . }} 4 |

{{ .Title }}

5 | {{ template "form" . }} 6 | {{ template "tail" . }} 7 | 8 | {{ end }} 9 | 10 | -------------------------------------------------------------------------------- /templates/layout.html: -------------------------------------------------------------------------------- 1 | {{ define "head" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | {{ .Title }} 17 | 18 | 19 |
20 | {{ end }} 21 | 22 | {{ define "tail" }} 23 |
24 | 25 | 26 | {{end}} 27 | -------------------------------------------------------------------------------- /templates/thankyou.html: -------------------------------------------------------------------------------- 1 | {{ define "thankYouPage" }} 2 | 3 | {{ template "head" . }} 4 |

{{ .Title }}

5 |
6 |
7 |
Almost there. Connecting...
8 |
9 |

10 |
11 | 16 | {{ template "tail" . }} 17 | 18 | {{ end }} 19 | 20 | -------------------------------------------------------------------------------- /testdata/config.yml: -------------------------------------------------------------------------------- 1 | unifi_url: 'https://localhost' 2 | title: 'Test Captive Portal' 3 | -------------------------------------------------------------------------------- /unifi-portal.yml.example: -------------------------------------------------------------------------------- 1 | unifi_url: 'https://127.0.0.1:8443' 2 | unifi_username: 'user' 3 | unifi_password: 'password' 4 | unifi_site: 'default' 5 | title: 'UniFi Captive Portal' 6 | intro: > 7 | To join our guest network, please agree to the Terms of Service below. 8 | tos: | 9 | By accepting this agreement and accessing the wireless network, you acknowledge that you are of legal age, you have read and understood, and agree to be bound by this agreement. 10 | 11 | (*) The wireless network service is provided by the property owners and is completely at their discretion. Your access to the network may be blocked, suspended, or terminated at any time for any reason. 12 | (*) You agree not to use the wireless network for any purpose that is unlawful or otherwise prohibited and you are fully responsible for your use. 13 | (*) The wireless network is provided "as is" without warranties of any kind, either expressed or implied. 14 | minutes: 600 15 | redirect_url: 'https://google.com' 16 | dynamo_table_name: 'unifi-captive-portal' 17 | --------------------------------------------------------------------------------