├── .circleci └── config.yml ├── .gitattributes ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── admin.go ├── admin_test.go ├── audit.go ├── audit_test.go ├── auth.go ├── go.mod ├── go.sum ├── internal ├── migrations │ ├── 00001_init.go │ ├── 00002_users.go │ ├── 00003_time_int.go │ ├── 00004_jobs.go │ ├── 00005_job_requestor.go │ ├── 00006_scan_datetime.go │ ├── 00007_job_count.go │ ├── 00008_traceroute.go │ ├── 00009_job_traceroute_constraints.go │ ├── 00010_groups.go │ ├── 00011_submission.go │ ├── 00013_audit_table.go │ └── new-migrate └── sqlite │ ├── audit.go │ ├── auth.go │ ├── job.go │ └── sqlite.go ├── job.go ├── job_test.go ├── jobs.png ├── metrics.go ├── new_data.png ├── pkg └── scan │ └── scan.go ├── scan.go ├── scan_test.go ├── static ├── css │ ├── bootstrap-theme.css │ ├── bootstrap-theme.css.map │ ├── bootstrap-theme.min.css │ ├── bootstrap-theme.min.css.map │ ├── bootstrap.css │ ├── bootstrap.css.map │ ├── bootstrap.min.css │ └── bootstrap.min.css.map ├── fonts │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.svg │ ├── glyphicons-halflings-regular.ttf │ ├── glyphicons-halflings-regular.woff │ └── glyphicons-halflings-regular.woff2 ├── images │ └── search.png └── js │ ├── bootstrap.min.js │ ├── jquery-3.2.1.min.js │ └── npm.js ├── updated_data.png └── views ├── _footer.html ├── _header.html ├── admin.html ├── index.html └── job.html /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Use the latest 2.1 version of CircleCI pipeline process engine. See: https://circleci.com/docs/2.0/configuration-reference 2 | version: 2.1 3 | 4 | orbs: 5 | go: circleci/go@1.6.0 6 | 7 | jobs: 8 | bindata: 9 | executor: 10 | name: go/default 11 | tag: '1.16' 12 | 13 | steps: 14 | - checkout 15 | - run: 16 | name: Install go-bindata 17 | command: | 18 | go get github.com/go-bindata/go-bindata/... 19 | - run: 20 | name: Generate bindata.go 21 | command: go generate -x 22 | - persist_to_workspace: 23 | root: . 24 | paths: 25 | - "bindata.go" 26 | 27 | build: 28 | parameters: 29 | go_version: 30 | description: Go version 31 | type: string 32 | default: "1.16" 33 | 34 | environment: 35 | TEST_RESULTS: &test_results /tmp/test-results 36 | 37 | executor: 38 | name: go/default 39 | tag: << parameters.go_version >> 40 | 41 | steps: 42 | - checkout 43 | - go/mod-download-cached 44 | - attach_workspace: 45 | at: . 46 | - run: 47 | name: Install SQLite 48 | command: | 49 | sudo apt-get update 50 | sudo apt-get install -y libsqlite3-0 51 | - run: 52 | name: Configure environment 53 | command: | 54 | echo 'VERSION=${CIRCLECI_TAG:-$(git describe)}' >> $BASH_ENV 55 | echo 'VERSION=${VERSION#v*}' >> $BASH_ENV 56 | echo 'DIST_NAME=scan-${VERSION}-$(go env GOOS)-$(go env GOARCH)' >> $BASH_ENV 57 | echo 'SHA256_FILE=${DIST_NAME}.sha256' >> $BASH_ENV 58 | - run: 59 | name: Run unit tests 60 | command: | 61 | mkdir -p $TEST_RESULTS 62 | gotestsum --junitfile $TEST_RESULTS/scan-tests.xml -- -coverprofile $TEST_RESULTS/cover.out 63 | - store_artifacts: 64 | path: *test_results 65 | destination: test-results 66 | - store_test_results: 67 | path: *test_results 68 | - run: 69 | name: Build binary 70 | command: | 71 | mkdir .build 72 | go build -o .build/$DIST_NAME 73 | cd .build 74 | shasum -a 256 $DIST_NAME > $SHA256_FILE 75 | - persist_to_workspace: 76 | root: . 77 | paths: 78 | - .build 79 | - store_artifacts: 80 | path: .build 81 | destination: . 82 | 83 | release: 84 | docker: 85 | - image: cibuilds/github:0.13 86 | 87 | steps: 88 | - checkout # Needed so we can get git metadata 89 | - attach_workspace: 90 | at: . 91 | - run: 92 | name: Publish release to GitHub 93 | command: | 94 | VERSION=${CIRCLECI_TAG:-$(git describe --always)} 95 | ghr -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -delete ${VERSION} ./.build/ 96 | 97 | workflows: 98 | Branch builds: 99 | jobs: 100 | - bindata: 101 | filters: &filters-branch 102 | branches: 103 | ignore: master 104 | - build: 105 | requires: 106 | - bindata 107 | matrix: 108 | parameters: 109 | go_version: ["1.15", "1.16"] 110 | filters: 111 | <<: *filters-branch 112 | 113 | Release: 114 | jobs: 115 | - bindata: 116 | filters: &filters-release 117 | branches: 118 | ignore: /.*/ 119 | tags: 120 | only: /^v.*/ 121 | - build: 122 | requires: 123 | - bindata 124 | filters: 125 | <<: *filters-release 126 | - hold: 127 | type: approval 128 | requires: 129 | - build 130 | filters: 131 | <<: *filters-release 132 | - release: 133 | requires: 134 | - hold 135 | filters: 136 | <<: *filters-release 137 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | static/* linguist-vendored 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore the binary 2 | /scan 3 | # Ignore the database 4 | scan.db 5 | 6 | # Don't ignore any Go source files 7 | !*.go 8 | 9 | # Vim swapfiles 10 | .*.swp 11 | 12 | # Secrets 13 | .cookie_key 14 | client_secret.json 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 James O'Gorman 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOBINDATA = $(shell go env GOPATH)/bin/go-bindata 2 | 3 | .PHONY: all 4 | all: build 5 | 6 | .PHONY: build 7 | build: assets scan 8 | 9 | dirs := $(shell go list -f '{{.Dir}}' ./...) 10 | gofiles := $(foreach dir,$(dirs),$(wildcard $(dir)/*.go)) 11 | 12 | scan: $(gofiles) 13 | go build 14 | 15 | .PHONY: assets 16 | assets: bindata.go 17 | 18 | bindata.go: $(GOBINDATA) views/*.html 19 | go generate 20 | 21 | $(GOBINDATA): 22 | go get github.com/go-bindata/go-bindata/... 23 | 24 | 25 | .PHONY: sample-data 26 | sample-data: 27 | curl -s -H "Content-Type: application/json" \ 28 | -d '[{"ip":"192.0.2.1","ports":[{"port":80,"proto":"tcp","status":"open","reason":"syn-ack","ttl":57}]}]' \ 29 | localhost:8080/results 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scan 2 | 3 | Scan is a small web service for recording and displaying [Masscan](https://github.com/robertdavidgraham/masscan) results. 4 | 5 | ![New data](/new_data.png) 6 | ![Updated data](/updated_data.png) 7 | 8 | ## Building and Installation 9 | 10 | As of v0.8.1 Scan uses Go modules. Go 1.13 or newer is required to build. 11 | 12 | Precompiled binaries for Linux on x86-64 are available on the GitHub releases page. 13 | 14 | Both build and runtime require the SQLite libraries installed. 15 | 16 | For a Debian system: 17 | 18 | ``` 19 | sudo apt-get install sqlite3 20 | ``` 21 | 22 | Or macOS: 23 | 24 | ``` 25 | brew install sqlite 26 | ``` 27 | 28 | To generate the `scan` binary run: 29 | 30 | ``` 31 | make 32 | ``` 33 | 34 | This will generate the `bindata.go` containing static assets and build the binary. 35 | 36 | ## Database 37 | 38 | Scan stores results in a SQLite database. The database is automatically created and maintained at startup. 39 | 40 | The `-data.dir` flag (defaults to current directory) tells Scan where to store the database file. 41 | 42 | ## TLS 43 | 44 | Scan can automatically obtain a TLS certificate for HTTPS using Let's Encrypt. 45 | 46 | When the `-tls` flag is set and a client connects to the HTTPS port, Scan will attempt to automatically obtain a certificate for the hostname being connected to. A DNS hostname must be set up for this to work as Let's Encrypt uses Domain Validation - it needs to connect to the hostname on the HTTP port. 47 | 48 | Optionally, you can restrict certificates to a single hostname using the `-tls.hostname` flag. 49 | 50 | ## Authentication & Authorization 51 | 52 | By default the data will not be displayed unless a user has been authenticated and authorized. 53 | 54 | Authentication is with Google OAuth2. You should create credentials for the application at https://console.cloud.google.com/apis/credentials. 55 | 56 | * Click the down arrow next to Create credentials 57 | * Select Web application 58 | * Enter a name for the application (e.g. Scan) 59 | * Add the `/auth` URI to Authorized redirect URIs 60 | (e.g. https://scan.example.com/auth) 61 | * Download the JSON file containing the credentials 62 | 63 | Scan will look for the credentials file called `client_secret.json` in the data directory (`-data.dir` flag) by default. The data directory defaults to the current directory. The credentials file path can be changed with the `-credentials` flag. If a relative path is specified it's assumed the file is in the data directory. 64 | 65 | Users can be managed at the `/admin` URI. 66 | 67 | If you want to authorise users by a G Suite group you must enable the 68 | [Admin SDK](https://console.cloud.google.com/apis/api/admin.googleapis.com/overview) on the project 69 | and add the group address to the `groups` table: 70 | 71 | ``` 72 | INSERT INTO groups (group_name) VALUES ('scan-users@example.com'); 73 | ``` 74 | 75 | If you want to disable authentication use the `-no-auth` flag. 76 | 77 | ## Importing data 78 | 79 | Results are sent to `/results` using the `POST` method. The data is expected to be 80 | a JSON array of Masscan results. 81 | 82 | Note that Masscan generates incorrect JSON data. It looks like: 83 | 84 | ```json 85 | { "ip": "192.168.0.1", "ports": [ {"port": 80, "proto": "tcp", "status": "open"} ] }, 86 | { "ip": "192.168.0.1", "ports": [ {"port": 443, "proto": "tcp", "status": "open"} ] }, 87 | {finished: 1} 88 | ``` 89 | 90 | That is, it is missing the surrounding `[ ]` and the last line is not valid JSON. 91 | This must be fixed before POSTing the data. 92 | 93 | ```json 94 | [ 95 | { "ip": "192.168.0.1", "ports": [ {"port": 80, "proto": "tcp", "status": "open"} ] }, 96 | { "ip": "192.168.0.1", "ports": [ {"port": 443, "proto": "tcp", "status": "open"} ] } 97 | ] 98 | ``` 99 | 100 | You can fix it by using `sed`: 101 | 102 | ``` 103 | sed -e '/,$/h;g;$s/,$//' -e '1i [' -e '$a ]' 104 | ``` 105 | 106 | And then send it to the server: 107 | 108 | ``` 109 | curl -H "Content-Type: application/json" -d @data.json https://scan.example.com/results 110 | ``` 111 | 112 | When automating this you should ensure you don't send empty data to the server. 113 | If the output file is empty you should send an empty JSON array (`[]`). 114 | 115 | ## Jobs 116 | 117 | Jobs allow you to request nodes to perform specific scans, possibly in addition 118 | to the scans they usually do. 119 | 120 | Once job data has been submitted, the job list will show a count of how many 121 | ports were found. 122 | 123 | ![Job list](/jobs.png) 124 | 125 | Nodes fetch the job list from `/jobs`. This is a JSON document of the form: 126 | 127 | ```json 128 | [ 129 | { 130 | "id": 1, 131 | "cidr": "192.0.2.0/24", 132 | "ports": "1-1024", 133 | "proto": "tcp" 134 | } 135 | ] 136 | ``` 137 | 138 | Job data is submitted similar to normal results, but using the `PUT` method 139 | and appending the job ID to the URI, e.g. 140 | 141 | ``` 142 | curl -H "Content-Type: application/json" -X PUT -d @data.json https://scan.example.com/results/1 143 | ``` 144 | 145 | ## Traceroutes 146 | 147 | To aid with network debugging after finding open ports, you can submit a 148 | traceroute for the IP. This should be `POST`ed to `/traceroute` as multipart 149 | form data, e.g. 150 | 151 | ``` 152 | curl -F dest=192.0.2.1 -F traceroute=@traceroute.txt https://scan.example.com/traceroute 153 | ``` 154 | 155 | ## Metrics 156 | 157 | Prometheus metrics are available to allow you to monitor and alert on Scan results. By default it listens on `localhost:3000`. 158 | 159 | Listening on a separate port from the main web server is deliberate - if you have authentication enabled the metrics data could leak information. If you configure metrics to listen on a public interface you should use IP ACLs to control access. 160 | 161 | TLS can be enabled on the metrics server (`-metrics.tls`) if TLS is also enabled for the main server. 162 | -------------------------------------------------------------------------------- /admin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | type userData struct { 11 | indexData 12 | Users *[]string 13 | } 14 | 15 | func (u *userData) AddError(err string) { 16 | u.Errors = append(u.Errors, err) 17 | } 18 | 19 | // Handler for GET and POST /admin 20 | func (app *App) adminHandler(w http.ResponseWriter, r *http.Request) { 21 | if authDisabled { 22 | http.Error(w, "Admin interface not available when authentication is disabled.", http.StatusNotImplemented) 23 | return 24 | } 25 | 26 | var user User 27 | session, err := store.Get(r, "user") 28 | if err != nil { 29 | http.Error(w, err.Error(), http.StatusInternalServerError) 30 | return 31 | } 32 | if _, ok := session.Values["user"]; !ok { 33 | data := indexData{URI: r.RequestURI} 34 | if flash := session.Flashes("unauth_flash"); len(flash) > 0 { 35 | data.NotAuth = flash[0].(string) 36 | w.WriteHeader(http.StatusUnauthorized) 37 | session.Save(r, w) 38 | } 39 | tmpl.ExecuteTemplate(w, "index", data) 40 | return 41 | } 42 | v := session.Values["user"] 43 | switch v := v.(type) { 44 | case string: 45 | user.Email = v 46 | case User: 47 | user = v 48 | } 49 | 50 | users, err := app.db.LoadUsers() 51 | if err != nil { 52 | http.Error(w, err.Error(), http.StatusInternalServerError) 53 | return 54 | } 55 | 56 | data := userData{ 57 | indexData: indexData{Authenticated: true, User: user}, 58 | Users: &users, 59 | } 60 | 61 | // Handle deleting and adding users 62 | if r.Method == "POST" { 63 | err := r.ParseForm() 64 | if err != nil { 65 | w.WriteHeader(http.StatusBadRequest) 66 | return 67 | } 68 | 69 | f := r.Form 70 | err = app.adminFormProcess(f, user, users) 71 | switch { 72 | case err == errUserExists: 73 | data.AddError(userExists) 74 | w.WriteHeader(http.StatusBadRequest) 75 | case err == errSelfDeletion: 76 | data.AddError(selfDeletion) 77 | w.WriteHeader(http.StatusBadRequest) 78 | case err != nil: 79 | http.Error(w, err.Error(), http.StatusInternalServerError) 80 | return 81 | case err == nil: 82 | // Reload the list of users 83 | users, err = app.db.LoadUsers() 84 | if err != nil { 85 | http.Error(w, err.Error(), http.StatusInternalServerError) 86 | return 87 | } 88 | } 89 | } 90 | 91 | tmpl.ExecuteTemplate(w, "admin", data) 92 | } 93 | 94 | var ( 95 | userExists = "User already exists" 96 | selfDeletion = "You can't delete yourself" 97 | errUserExists = errors.New(strings.ToLower(userExists)) 98 | errSelfDeletion = errors.New(strings.ToLower(selfDeletion)) 99 | ) 100 | 101 | func (app *App) adminFormProcess(f url.Values, user User, users []string) error { 102 | if add := f.Get("add_email"); add != "" { 103 | // Check if the address already exists as a user 104 | for _, u := range users { 105 | if u == add { 106 | return errUserExists 107 | } 108 | } 109 | if err := app.db.SaveUser(add); err != nil { 110 | return err 111 | } 112 | app.audit(user.Email, "add_user", add) 113 | } 114 | 115 | if delete := f.Get("delete_email"); delete != "" { 116 | // Ensure the user isn't trying to delete themselves 117 | if user.Email == delete { 118 | return errSelfDeletion 119 | } 120 | if err := app.db.DeleteUser(delete); err != nil { 121 | return err 122 | } 123 | app.audit(user.Email, "delete_user", delete) 124 | } 125 | 126 | return nil 127 | } 128 | -------------------------------------------------------------------------------- /admin_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | ) 7 | 8 | func TestAdminFormProcess(t *testing.T) { 9 | db := createDB("TestAdminFormProcess") 10 | defer db.Close() 11 | app := &App{db: db} 12 | 13 | f := url.Values{} 14 | user := User{Email: "admin@example.com"} 15 | users, err := db.LoadUsers() 16 | if err != nil { 17 | t.Fatalf("couldn't fetch from users table: %v", err) 18 | } 19 | 20 | t.Run("AddNewUser", func(t *testing.T) { 21 | f.Set("add_email", "user1@example.com") 22 | err := app.adminFormProcess(f, user, users) 23 | if err != nil { 24 | t.Errorf("expected no error; got %v", err) 25 | } 26 | }) 27 | 28 | t.Run("AddExistingUser", func(t *testing.T) { 29 | users, err = db.LoadUsers() 30 | if err != nil { 31 | t.Fatalf("couldn't fetch from users table: %v", err) 32 | } 33 | err := app.adminFormProcess(f, user, users) 34 | if err != errUserExists { 35 | t.Errorf("expected UserExistsError; got %v", err) 36 | } 37 | }) 38 | 39 | f.Del("add_email") 40 | 41 | t.Run("DeleteExistingUser", func(t *testing.T) { 42 | f.Set("delete_email", "user1@example.com") 43 | err := app.adminFormProcess(f, user, users) 44 | if err != nil { 45 | t.Errorf("expected no error; got %v", err) 46 | } 47 | }) 48 | 49 | t.Run("DeleteSelf", func(t *testing.T) { 50 | f.Set("delete_email", "user1@example.com") 51 | user.Email = "user1@example.com" 52 | err := app.adminFormProcess(f, user, users) 53 | if err != errSelfDeletion { 54 | t.Fatalf("expected SelfDeletionError; got %v", err) 55 | } 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /audit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | // audit logs events to the audit table 6 | func (app *App) audit(user, event, info string) error { 7 | return app.db.SaveAudit(time.Now(), user, event, info) 8 | } 9 | -------------------------------------------------------------------------------- /audit_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestAudit(t *testing.T) { 6 | db := createDB("TestAudit") 7 | defer db.Close() 8 | app := &App{db: db} 9 | if err := app.audit("admin@example.com", "add_user", "user1@example.com"); err != nil { 10 | t.Errorf("couldn't write audit log: %v", err) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/base64" 7 | "encoding/gob" 8 | "encoding/json" 9 | "fmt" 10 | "io/ioutil" 11 | "log" 12 | "net/http" 13 | "path/filepath" 14 | 15 | "github.com/gorilla/securecookie" 16 | "github.com/gorilla/sessions" 17 | 18 | "golang.org/x/oauth2" 19 | "golang.org/x/oauth2/google" 20 | ) 21 | 22 | var conf *oauth2.Config 23 | 24 | var store *sessions.CookieStore 25 | 26 | // User is a Google user 27 | type User struct { 28 | Name string `json:"name"` 29 | GivenName string `json:"given_name"` 30 | FamilyName string `json:"family_name"` 31 | Email string `json:"email"` 32 | Picture string `json:"picture"` 33 | } 34 | 35 | // GroupMember defines whether the user is a member of a group 36 | // It is set by the groups `hasMember` API endpoint 37 | type GroupMember struct { 38 | IsMember bool `json:"isMember"` 39 | } 40 | 41 | func init() { 42 | gob.Register(User{}) 43 | } 44 | 45 | func oauthConfig() { 46 | keyFile := filepath.Join(dataDir, ".cookie_key") 47 | if key, err := ioutil.ReadFile(keyFile); err == nil { 48 | store = sessions.NewCookieStore(key) 49 | } else { 50 | // TODO(jamesog): Add a second parameter for encryption 51 | // This makes it more complicated to write to the cache file 52 | // It should probably be saved in the database instead 53 | key := securecookie.GenerateRandomKey(64) 54 | err := ioutil.WriteFile(keyFile, key, 0600) 55 | if err != nil { 56 | log.Fatal(err) 57 | } 58 | store = sessions.NewCookieStore(key) 59 | } 60 | 61 | f, err := ioutil.ReadFile(credsFile) 62 | if err != nil { 63 | log.Fatalf("couldn't read credentials file: %s", err) 64 | } 65 | 66 | scopes := []string{ 67 | "https://www.googleapis.com/auth/userinfo.email", 68 | "https://www.googleapis.com/auth/userinfo.profile", 69 | "https://www.googleapis.com/auth/admin.directory.group.member.readonly", 70 | } 71 | conf, err = google.ConfigFromJSON(f, scopes...) 72 | if err != nil { 73 | log.Fatalf("couldn't parse OAuth2 config: %s", err) 74 | } 75 | } 76 | 77 | func getLoginURL(state string) string { 78 | return conf.AuthCodeURL(state) 79 | } 80 | 81 | func randToken() string { 82 | b := make([]byte, 32) 83 | rand.Read(b) 84 | return base64.StdEncoding.EncodeToString(b) 85 | } 86 | 87 | // loginHandler is just a redirect to the Google login page 88 | func (app *App) loginHandler(w http.ResponseWriter, r *http.Request) { 89 | tok := randToken() 90 | state, err := store.Get(r, "state") 91 | if err != nil { 92 | http.Error(w, err.Error(), http.StatusInternalServerError) 93 | return 94 | } 95 | 96 | // State only needs to be valid for 5 mins 97 | state.Options.MaxAge = 300 98 | state.Values["state"] = tok 99 | 100 | // Store a redirect URL to send the user back to the page they were on 101 | redir, _ := store.Get(r, "redir") 102 | redir.Options.MaxAge = 300 103 | redir.Values["redir"] = r.URL.Query().Get("redir") 104 | 105 | // Save both sessions 106 | sessions.Save(r, w) 107 | 108 | http.Redirect(w, r, getLoginURL(tok), http.StatusFound) 109 | } 110 | 111 | func (app *App) logoutHandler(w http.ResponseWriter, r *http.Request) { 112 | session, err := store.Get(r, "user") 113 | if err != nil { 114 | http.Error(w, err.Error(), http.StatusInternalServerError) 115 | return 116 | } 117 | 118 | v := session.Values["user"] 119 | if user, ok := v.(User); ok { 120 | app.audit(user.Email, "logout", "") 121 | } 122 | 123 | session.Options.MaxAge = -1 124 | session.Save(r, w) 125 | 126 | // User is logged out. Redirect back to the index page 127 | http.Redirect(w, r, "/", http.StatusFound) 128 | } 129 | 130 | // AuthSession stores the session and OAuth2 client 131 | type AuthSession struct { 132 | state *sessions.Session 133 | user *sessions.Session 134 | token *oauth2.Token 135 | client *http.Client 136 | } 137 | 138 | type googleAPIError struct { 139 | Error struct { 140 | Message string `json:"message"` 141 | Code int `json:"code"` 142 | } `json:"error"` 143 | } 144 | 145 | // userInfo fetches the user profile info from the Google API 146 | func (s AuthSession) userInfo() (*User, error) { 147 | // Retrieve the logged in user's information 148 | res, err := s.client.Get("https://www.googleapis.com/oauth2/v3/userinfo") 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | defer res.Body.Close() 154 | 155 | data, _ := ioutil.ReadAll(res.Body) 156 | 157 | // Unmarshal the user data 158 | var user User 159 | err = json.Unmarshal(data, &user) 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | return &user, nil 165 | } 166 | 167 | // validateUser looks up the user's email address in the database and returns 168 | // true if they exist 169 | func (app *App) validateUser(user *User) (bool, error) { 170 | return app.db.UserExists(user.Email) 171 | } 172 | 173 | // validateGroupMember looks up all group names in the database and returns 174 | // true if the user is a member of any of the groups 175 | func (app *App) validateGroupMember(s AuthSession, email string) (bool, error) { 176 | url := "https://www.googleapis.com/admin/directory/v1/groups/%s/hasMember/%s" 177 | 178 | groups, err := app.db.LoadGroups() 179 | if err != nil { 180 | log.Printf("error retrieving groups from database: %v", err) 181 | return false, err 182 | } 183 | 184 | for _, group := range groups { 185 | res, err := s.client.Get(fmt.Sprintf(url, group, email)) 186 | if err != nil { 187 | log.Printf("error retrieving user %s for group %s: %v", email, group, err) 188 | continue 189 | } 190 | defer res.Body.Close() 191 | 192 | data, _ := ioutil.ReadAll(res.Body) 193 | 194 | if res.StatusCode != http.StatusOK { 195 | var e googleAPIError 196 | err := json.Unmarshal(data, &e) 197 | if err != nil { 198 | log.Printf("[group %s] error unmarshaling Google API error: %v", group, err) 199 | continue 200 | } 201 | log.Printf("[group %s] error code %d from groups API: %v", group, e.Error.Code, e.Error.Message) 202 | continue 203 | } 204 | 205 | var gm GroupMember 206 | err = json.Unmarshal(data, &gm) 207 | if err != nil { 208 | return false, err 209 | } 210 | 211 | if gm.IsMember { 212 | return true, nil 213 | } 214 | } 215 | 216 | return false, nil 217 | } 218 | 219 | // authHandler receives the login information from Google and checks if the 220 | // email address is authorized 221 | func (app *App) authHandler(w http.ResponseWriter, r *http.Request) { 222 | var s AuthSession 223 | var err error 224 | s.state, err = store.Get(r, "state") 225 | if err != nil { 226 | http.Error(w, err.Error(), http.StatusInternalServerError) 227 | return 228 | } 229 | 230 | // Check if the user has a valid session 231 | q := r.URL.Query() 232 | if s.state.Values["state"] != q.Get("state") { 233 | http.Error(w, "Invalid session", http.StatusUnauthorized) 234 | return 235 | } 236 | 237 | // Attempt to fetch the redirect URI from the store 238 | uri := "/" 239 | redir, _ := store.Get(r, "redir") 240 | if u := redir.Values["redir"]; u != "" { 241 | uri = u.(string) 242 | } 243 | // Destroy the redirect session, it isn't needed any more 244 | redir.Options.MaxAge = -1 245 | redir.Save(r, w) 246 | 247 | s.user, err = store.Get(r, "user") 248 | if err != nil { 249 | http.Error(w, err.Error(), http.StatusInternalServerError) 250 | return 251 | } 252 | 253 | s.token, err = conf.Exchange(context.Background(), q.Get("code")) 254 | if err != nil { 255 | http.Error(w, err.Error(), http.StatusBadRequest) 256 | return 257 | } 258 | 259 | s.client = conf.Client(context.Background(), s.token) 260 | 261 | var authorised bool 262 | 263 | // Check if the user email is in the individual users list 264 | // If the individual user is not authorised, check group membership 265 | 266 | user, err := s.userInfo() 267 | if err != nil { 268 | http.Error(w, err.Error(), http.StatusInternalServerError) 269 | return 270 | } 271 | authorised, err = app.validateUser(user) 272 | if err != nil { 273 | http.Error(w, err.Error(), http.StatusInternalServerError) 274 | return 275 | } 276 | 277 | // The user doesn't have an individual entry, check group membership 278 | if !authorised { 279 | authorised, err = app.validateGroupMember(s, user.Email) 280 | if err != nil { 281 | http.Error(w, err.Error(), http.StatusInternalServerError) 282 | return 283 | } 284 | } 285 | 286 | if authorised { 287 | // Store the information in the session 288 | s.user.Values["user"] = user 289 | } else { 290 | s.user.AddFlash(fmt.Sprintf("%s is not authorised", user.Email), "unauth_flash") 291 | } 292 | 293 | s.user.Save(r, w) 294 | app.audit(user.Email, "login", "") 295 | 296 | // User is logged in. Redirect back to the index page 297 | http.Redirect(w, r, uri, http.StatusFound) 298 | } 299 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jamesog/scan 2 | 3 | go 1.14 4 | 5 | require ( 6 | cloud.google.com/go v0.57.0 // indirect 7 | github.com/go-chi/chi v3.3.2+incompatible 8 | github.com/go-chi/render v1.0.0 9 | github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f // indirect 10 | github.com/gorilla/securecookie v0.0.0-20160422134519-667fe4e3466a 11 | github.com/gorilla/sessions v0.0.0-20160922145804-ca9ada445741 12 | github.com/mattn/go-sqlite3 v2.0.3+incompatible 13 | github.com/pressly/goose v2.2.0+incompatible 14 | github.com/prometheus/client_golang v1.11.1 15 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 16 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d 17 | ) 18 | -------------------------------------------------------------------------------- /internal/migrations/00001_init.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigration(up00001, down00001) 11 | } 12 | 13 | func up00001(tx *sql.Tx) error { 14 | _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS scan (ip text, port integer, proto text, firstseen text, lastseen text)`) 15 | return err 16 | } 17 | 18 | func down00001(tx *sql.Tx) error { 19 | // Can't go down from here! 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /internal/migrations/00002_users.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigration(up00002, down00002) 11 | } 12 | 13 | func up00002(tx *sql.Tx) error { 14 | _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS users (email text)`) 15 | return err 16 | } 17 | 18 | func down00002(tx *sql.Tx) error { 19 | _, err := tx.Exec(`DROP TABLE users`) 20 | return err 21 | } 22 | -------------------------------------------------------------------------------- /internal/migrations/00003_time_int.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/pressly/goose" 9 | ) 10 | 11 | func init() { 12 | goose.AddMigration(up00003, down00003) 13 | } 14 | 15 | // Alter the firstseen and lastseen columns from text to int 16 | func up00003(tx *sql.Tx) error { 17 | stmts := []string{ 18 | `CREATE TABLE scan_migrate (ip text, port integer, proto text, firstseen int, lastseen int)`, 19 | `INSERT INTO scan_migrate SELECT ip, port, proto, strftime('%s', firstseen), strftime('%s', lastseen) FROM scan`, 20 | // Preserve the old table just in case 21 | // If an existing database is being migrated there is a potential for 22 | // data loss because columns have been changed later 23 | fmt.Sprintf(`ALTER TABLE scan RENAME TO scan_00003_%d`, time.Now().Unix()), 24 | `ALTER TABLE scan_migrate RENAME TO scan`, 25 | } 26 | for _, stmt := range stmts { 27 | _, err := tx.Exec(stmt) 28 | if err != nil { 29 | return err 30 | } 31 | } 32 | 33 | return nil 34 | } 35 | 36 | func down00003(tx *sql.Tx) error { 37 | stmts := []string{ 38 | `CREATE TABLE scan_migrate (ip text, port integer, proto text, firstseen text, lastseen text)`, 39 | `INSERT INTO scan_migrate SELECT ip, port, proto strftime('%Y-%m-%d %H:%M', firstseen), strftime('%Y-%m-%d %H:%M', lastseen) FROM scan`, 40 | `DROP TABLE scan`, 41 | `ALTER TABLE scan_migrate RENAME TO scan`, 42 | } 43 | for _, stmt := range stmts { 44 | _, err := tx.Exec(stmt) 45 | if err != nil { 46 | return err 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/migrations/00004_jobs.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigration(up00004, down00004) 11 | } 12 | 13 | func up00004(tx *sql.Tx) error { 14 | _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS job (id int, cidr text, ports text, proto text, submitted datetime, received datetime)`) 15 | return err 16 | } 17 | 18 | func down00004(tx *sql.Tx) error { 19 | _, err := tx.Exec(`DROP TABLE job`) 20 | return err 21 | } 22 | -------------------------------------------------------------------------------- /internal/migrations/00005_job_requestor.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/pressly/goose" 9 | ) 10 | 11 | func init() { 12 | goose.AddMigration(up00005, down00005) 13 | } 14 | 15 | // Add the requested_by column 16 | func up00005(tx *sql.Tx) error { 17 | stmts := []string{ 18 | `CREATE TABLE job_migrate (id int, cidr text, ports text, proto text, requested_by text, submitted datetime, received datetime)`, 19 | `INSERT INTO job_migrate (id, cidr, ports, proto, submitted, received) SELECT id, cidr, ports, proto, submitted, received FROM job`, 20 | // Preserve the old table just in case 21 | // If an existing database is being migrated there is a potential for 22 | // data loss because other columns have been added later 23 | fmt.Sprintf(`ALTER TABLE job RENAME TO job_00005_%d`, time.Now().Unix()), 24 | `ALTER TABLE job_migrate RENAME TO job`, 25 | } 26 | for _, stmt := range stmts { 27 | _, err := tx.Exec(stmt) 28 | if err != nil { 29 | return err 30 | } 31 | } 32 | 33 | return nil 34 | } 35 | 36 | func down00005(tx *sql.Tx) error { 37 | stmts := []string{ 38 | `CREATE TABLE job_migrate (id int, cidr text, ports text, proto text, submitted datetime, received datetime)`, 39 | `INSERT INTO job_migrate (id, cidr, ports, proto, submitted, received) SELECT * FROM job`, 40 | `DROP TABLE job`, 41 | `ALTER TABLE job_migrate RENAME TO job`, 42 | } 43 | for _, stmt := range stmts { 44 | _, err := tx.Exec(stmt) 45 | if err != nil { 46 | return err 47 | } 48 | } 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /internal/migrations/00006_scan_datetime.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/pressly/goose" 9 | ) 10 | 11 | func init() { 12 | goose.AddMigration(up00006, down00006) 13 | } 14 | 15 | // Change firstseen and lastseen from int to datetime 16 | func up00006(tx *sql.Tx) error { 17 | stmts := []string{ 18 | `CREATE TABLE scan_migrate (ip text, port integer, proto text, firstseen datetime, lastseen datetime)`, 19 | `INSERT INTO scan_migrate SELECT ip, port, proto, datetime(firstseen), datetime(lastseen) FROM scan`, 20 | // Preserve the old table just in case 21 | // If an existing database is being migrated there is a potential for 22 | // data loss because columns have been changed later 23 | fmt.Sprintf(`ALTER TABLE scan RENAME TO scan_00006_%d`, time.Now().Unix()), 24 | `ALTER TABLE scan_migrate RENAME TO scan`, 25 | } 26 | for _, stmt := range stmts { 27 | _, err := tx.Exec(stmt) 28 | if err != nil { 29 | return err 30 | } 31 | } 32 | 33 | return nil 34 | } 35 | 36 | func down00006(tx *sql.Tx) error { 37 | stmts := []string{ 38 | `CREATE TABLE scan_migrate (ip text, port integer, proto text, firstseen int, lastseen int)`, 39 | `INSERT INTO scan_migrate SELECT ip, port, proto strftime('%s', firstseen), strftime('%s', lastseen) FROM scan`, 40 | `DROP TABLE scan`, 41 | `ALTER TABLE scan_migrate RENAME TO scan`, 42 | } 43 | for _, stmt := range stmts { 44 | _, err := tx.Exec(stmt) 45 | if err != nil { 46 | return err 47 | } 48 | } 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/migrations/00007_job_count.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigration(up00007, down00007) 11 | } 12 | 13 | // Add job count column 14 | func up00007(tx *sql.Tx) error { 15 | _, err := tx.Exec(`ALTER TABLE job ADD COLUMN count int`) 16 | return err 17 | } 18 | 19 | func down00007(tx *sql.Tx) error { 20 | stmts := []string{ 21 | `CREATE TABLE job_migrate AS SELECT id, cidr, ports, proto, requested_by, submitted, received FROM job`, 22 | `DROP TABLE job`, 23 | `ALTER TABLE job_migrate RENAME TO job`, 24 | } 25 | for _, stmt := range stmts { 26 | _, err := tx.Exec(stmt) 27 | if err != nil { 28 | return err 29 | } 30 | } 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/migrations/00008_traceroute.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigration(up00008, down00008) 11 | } 12 | 13 | // Add traceroute table 14 | func up00008(tx *sql.Tx) error { 15 | _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS traceroute (dest text, path text)`) 16 | return err 17 | } 18 | 19 | func down00008(tx *sql.Tx) error { 20 | _, err := tx.Exec(`DROP TABLE job`) 21 | return err 22 | } 23 | -------------------------------------------------------------------------------- /internal/migrations/00009_job_traceroute_constraints.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/pressly/goose" 9 | ) 10 | 11 | func init() { 12 | goose.AddMigration(up00009, down00009) 13 | } 14 | 15 | // Add NOT NULL constraint to job cidr 16 | // Add UNIQUE NOT NULL constraint to traceroute dest 17 | func up00009(tx *sql.Tx) error { 18 | stmts := []string{ 19 | `CREATE TABLE job_migrate (id int, cidr text NOT NULL, ports text, proto text, requested_by text, submitted datetime, received datetime, count int)`, 20 | `INSERT INTO job_migrate SELECT * FROM job`, 21 | // Preserve the old table just in case 22 | // If an existing database is being migrated there is a potential for 23 | // data loss because columns have been changed later 24 | fmt.Sprintf(`ALTER TABLE job RENAME TO job_00009_%d`, time.Now().Unix()), 25 | `ALTER TABLE job_migrate RENAME TO job`, 26 | 27 | `CREATE TABLE traceroute_migrate (dest text UNIQUE NOT NULL, path text)`, 28 | `INSERT INTO traceroute_migrate SELECT DISTINCT dest, path FROM traceroute`, 29 | fmt.Sprintf(`ALTER TABLE traceroute RENAME TO traceroute_%d`, time.Now().Unix()), 30 | `ALTER TABLE traceroute_migrate RENAME TO traceroute`, 31 | } 32 | 33 | for _, stmt := range stmts { 34 | _, err := tx.Exec(stmt) 35 | if err != nil { 36 | return err 37 | } 38 | } 39 | return nil 40 | } 41 | 42 | func down00009(tx *sql.Tx) error { 43 | _, err := tx.Exec(`DROP TABLE job`) 44 | return err 45 | } 46 | -------------------------------------------------------------------------------- /internal/migrations/00010_groups.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/pressly/goose" 9 | ) 10 | 11 | func init() { 12 | goose.AddMigration(up00010, down00010) 13 | } 14 | 15 | // Add UNIQUE NOT NULL constraint to users email 16 | // Create groups table 17 | func up00010(tx *sql.Tx) error { 18 | stmts := []string{ 19 | `CREATE TABLE users_migrate (email text UNIQUE NOT NULL)`, 20 | `INSERT INTO users_migrate SELECT DISTINCT email FROM users`, 21 | // Preserve the old table just in case 22 | fmt.Sprintf(`ALTER TABLE users RENAME TO users_00010_%d`, time.Now().Unix()), 23 | `ALTER TABLE users_migrate RENAME TO users`, 24 | 25 | `CREATE TABLE IF NOT EXISTS groups (group_name text UNIQUE NOT NULL)`, 26 | } 27 | 28 | for _, stmt := range stmts { 29 | _, err := tx.Exec(stmt) 30 | if err != nil { 31 | return err 32 | } 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func down00010(tx *sql.Tx) error { 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/migrations/00011_submission.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigration(up00011, down00011) 11 | } 12 | 13 | func up00011(tx *sql.Tx) error { 14 | _, err := tx.Exec(`CREATE TABLE submission (host text NOT NULL, job_id integer, submission_time datetime DEFAULT CURRENT_TIMESTAMP)`) 15 | return err 16 | } 17 | 18 | func down00011(tx *sql.Tx) error { 19 | _, err := tx.Exec(`DROP TABLE submission`) 20 | return err 21 | } 22 | -------------------------------------------------------------------------------- /internal/migrations/00013_audit_table.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/pressly/goose" 7 | ) 8 | 9 | func init() { 10 | goose.AddMigration(up00013, down00013) 11 | } 12 | 13 | func up00013(tx *sql.Tx) error { 14 | _, err := tx.Exec(`CREATE TABLE IF NOT EXISTS audit (time datetime NOT NULL, user text NOT NULL, action text NOT NULL, info text)`) 15 | return err 16 | } 17 | 18 | func down00013(tx *sql.Tx) error { 19 | _, err := tx.Exec(`DROP TABLE IF EXISTS audit`) 20 | return err 21 | } 22 | -------------------------------------------------------------------------------- /internal/migrations/new-migrate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | use strict; 4 | use File::Basename; 5 | 6 | chdir(dirname($0)); 7 | my $nextmig = 0; 8 | # Loop over all current migrate files, grab the number from the filename and 9 | # find the next number. 10 | for my $f (<*.go>) { 11 | $f =~ /([0-9]+)_/; 12 | my $num = $1; 13 | $num =~ s/^0+//; 14 | $nextmig = $num + 1 if($num >= $nextmig); 15 | } 16 | print "Next migrate: $nextmig\n"; 17 | print "Migration summary: "; 18 | my $summ = ; 19 | chomp($summ); 20 | $summ =~ s/\s/_/g; 21 | $summ =~ tr/A-Z/a-z/; 22 | my $mignum = sprintf("%05d", $nextmig); 23 | my $mfile = sprintf("%s_%s.go", $mignum, $summ); 24 | print "Migrate name: $mfile\n"; 25 | 26 | my $tmpl = <', $mfile) or die "Can't open $mfile: $!"; 52 | print $fh $tmpl; 53 | close($fh); 54 | -------------------------------------------------------------------------------- /internal/sqlite/audit.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import "time" 4 | 5 | func (db *DB) SaveAudit(ts time.Time, user, event, info string) error { 6 | txn, err := db.Begin() 7 | if err != nil { 8 | return err 9 | } 10 | 11 | qry := `INSERT INTO audit (time, user, action, info) VALUES (?, ?, ?, ?)` 12 | _, err = txn.Exec(qry, ts, user, event, info) 13 | if err != nil { 14 | txn.Rollback() 15 | return err 16 | } 17 | 18 | return txn.Commit() 19 | } 20 | -------------------------------------------------------------------------------- /internal/sqlite/auth.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | ) 8 | 9 | // LoadUsers retrieves all users. 10 | func (db *DB) LoadUsers() ([]string, error) { 11 | rows, err := db.Query(`SELECT * FROM users ORDER BY email`) 12 | if err != nil { 13 | log.Printf("error loading users: %v\n", err) 14 | return []string{}, err 15 | } 16 | defer rows.Close() 17 | 18 | var users []string 19 | var email string 20 | 21 | for rows.Next() { 22 | err := rows.Scan(&email) 23 | if err != nil { 24 | log.Println("loadUsers: error scanning table:", err) 25 | return []string{}, err 26 | } 27 | users = append(users, email) 28 | } 29 | 30 | return users, nil 31 | } 32 | 33 | func (db *DB) LoadGroups() ([]string, error) { 34 | rows, err := db.Query(`SELECT group_name FROM groups`) 35 | if err != nil { 36 | log.Printf("error retrieving groups from database: %v", err) 37 | return nil, fmt.Errorf("error querying for groups: %w", err) 38 | } 39 | defer rows.Close() 40 | 41 | var groups []string 42 | 43 | for rows.Next() { 44 | var group string 45 | err := rows.Scan(&group) 46 | if err != nil { 47 | return nil, fmt.Errorf("error scanning group: %w", err) 48 | } 49 | groups = append(groups, group) 50 | } 51 | return groups, nil 52 | } 53 | 54 | func (db *DB) UserExists(email string) (bool, error) { 55 | var x string 56 | err := db.QueryRow(`SELECT email FROM users WHERE email=?`, email).Scan(&x) 57 | switch { 58 | case err != nil && err != sql.ErrNoRows: 59 | return false, nil 60 | case err == nil: 61 | return true, nil 62 | } 63 | 64 | return false, err 65 | } 66 | 67 | // SaveUser stores a new user. 68 | func (db *DB) SaveUser(email string) error { 69 | txn, err := db.Begin() 70 | if err != nil { 71 | return err 72 | } 73 | 74 | qry := `INSERT INTO users (email) VALUES (?)` 75 | _, err = txn.Exec(qry, email) 76 | if err != nil { 77 | txn.Rollback() 78 | return err 79 | } 80 | 81 | err = txn.Commit() 82 | if err != nil { 83 | return err 84 | } 85 | 86 | return nil 87 | } 88 | 89 | // DeleteUser deletes a user. 90 | func (db *DB) DeleteUser(email string) error { 91 | txn, err := db.Begin() 92 | if err != nil { 93 | return err 94 | } 95 | 96 | qry := `DELETE FROM users WHERE email = ?` 97 | _, err = txn.Exec(qry, email) 98 | if err != nil { 99 | txn.Rollback() 100 | return err 101 | } 102 | 103 | err = txn.Commit() 104 | if err != nil { 105 | return err 106 | } 107 | 108 | return nil 109 | } 110 | -------------------------------------------------------------------------------- /internal/sqlite/job.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "time" 9 | 10 | "github.com/jamesog/scan/pkg/scan" 11 | ) 12 | 13 | // LoadJobs retrives the stored jobs. 14 | func (db *DB) LoadJobs(filter SQLFilter) ([]scan.Job, error) { 15 | qry := fmt.Sprintf(`SELECT rowid, cidr, ports, proto, requested_by, submitted, received, count FROM job %s ORDER BY received DESC, submitted, rowid`, filter) 16 | rows, err := db.Query(qry, filter.Values...) 17 | if err != nil { 18 | log.Printf("loadJobs: error scanning table: %v\n", err) 19 | return []scan.Job{}, err 20 | } 21 | 22 | defer rows.Close() 23 | 24 | var id int 25 | var cidr, ports, proto, requestedBy string 26 | var submitted time.Time 27 | var received sql.NullTime 28 | var count sql.NullInt64 29 | 30 | var jobs []scan.Job 31 | 32 | for rows.Next() { 33 | err := rows.Scan(&id, &cidr, &ports, &proto, &requestedBy, &submitted, &received, &count) 34 | if err != nil { 35 | return []scan.Job{}, err 36 | } 37 | 38 | jobs = append(jobs, scan.Job{ 39 | ID: id, CIDR: cidr, Ports: ports, Proto: proto, 40 | RequestedBy: requestedBy, Submitted: scan.Time{Time: submitted}, 41 | Received: scan.Time{Time: received.Time}, Count: count.Int64}) 42 | } 43 | 44 | return jobs, nil 45 | } 46 | 47 | // LoadJobSubmission retrieves the stored submissions associated with a job. 48 | func (db *DB) LoadJobSubmission() (scan.Submission, error) { 49 | f := SQLFilter{ 50 | Where: []string{"job_id IS NOT NULL"}, 51 | } 52 | return db.LoadSubmission(f) 53 | } 54 | 55 | // SaveJob stores a new custom scan job request. 56 | func (db *DB) SaveJob(cidr, ports, proto, user string) (int64, error) { 57 | txn, err := db.DB.Begin() 58 | if err != nil { 59 | return 0, err 60 | } 61 | 62 | qry := `INSERT INTO job (cidr, ports, proto, requested_by, submitted) VALUES (?, ?, ?, ?, ?)` 63 | res, err := txn.Exec(qry, cidr, ports, strings.ToLower(proto), user, time.Now()) 64 | if err != nil { 65 | txn.Rollback() 66 | return 0, err 67 | } 68 | 69 | id, err := res.LastInsertId() 70 | if err != nil { 71 | return 0, err 72 | } 73 | 74 | err = txn.Commit() 75 | if err != nil { 76 | return 0, err 77 | } 78 | 79 | return id, nil 80 | } 81 | 82 | // UpdateJob updates the given job to mark the number of ports found. 83 | func (db *DB) UpdateJob(id string, count int64) error { 84 | txn, err := db.DB.Begin() 85 | if err != nil { 86 | return err 87 | } 88 | 89 | qry := `UPDATE job SET received=?, count=? WHERE rowid=?` 90 | res, err := txn.Exec(qry, time.Now(), count, id) 91 | rows, _ := res.RowsAffected() 92 | if err != nil || rows <= 0 { 93 | txn.Rollback() 94 | return err 95 | } 96 | 97 | err = txn.Commit() 98 | if err != nil { 99 | return err 100 | } 101 | 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /internal/sqlite/sqlite.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | _ "github.com/jamesog/scan/internal/migrations" 15 | "github.com/jamesog/scan/pkg/scan" 16 | _ "github.com/mattn/go-sqlite3" 17 | "github.com/pressly/goose" 18 | ) 19 | 20 | // DefaultDBFile is the default SQLite database file name. 21 | const DefaultDBFile = "scan.db" 22 | 23 | // DB is the database. 24 | type DB struct { 25 | *sql.DB 26 | } 27 | 28 | func toNullInt64(i *int64) sql.NullInt64 { 29 | var ni sql.NullInt64 30 | if i != nil { 31 | ni = sql.NullInt64{Int64: *i, Valid: true} 32 | } 33 | return ni 34 | } 35 | 36 | // Open creates a new SQLite database object. 37 | func Open(dsn string) (*DB, error) { 38 | var err error 39 | db, err := sql.Open("sqlite3", dsn) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | err = db.Ping() 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | // Run migrations 50 | goose.SetDialect("sqlite3") 51 | // Use a temporary directory for goose.Up() - we don't have any .sql files 52 | // to run, it's all embedded in the binary 53 | tmpdir, err := ioutil.TempDir(filepath.Dir(dsn), "") 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | defer os.RemoveAll(tmpdir) 58 | 59 | // FIXME(jamesog): The verbose flag isn't accessible here anymore 60 | // if verbose { 61 | // log.Println("Checking database migration status") 62 | // goose.Status(db, tmpdir) 63 | // } else { 64 | // Discard Goose's log output 65 | goose.SetLogger(log.New(ioutil.Discard, "", 0)) 66 | // } 67 | err = goose.Up(db, tmpdir) 68 | if err != nil { 69 | log.Fatalf("Error running database migrations: %v\n", err) 70 | } 71 | 72 | return &DB{DB: db}, nil 73 | } 74 | 75 | // SQLFilter is for constructing data filters ("WHERE" clauses) in a SQL statement 76 | type SQLFilter struct { 77 | Where []string 78 | Values []interface{} 79 | } 80 | 81 | // String constructs a SQL WHERE clause. 82 | func (f SQLFilter) String() string { 83 | if len(f.Where) > 0 { 84 | return "WHERE " + strings.Join(f.Where, " AND ") 85 | } 86 | return "" 87 | } 88 | 89 | // LoadData loads all data for displaying in the browser. 90 | func (db *DB) LoadData(filter SQLFilter) ([]scan.IPInfo, error) { 91 | qry := fmt.Sprintf(`SELECT ip, port, proto, firstseen, lastseen FROM scan %s ORDER BY port, proto, ip, lastseen`, filter) 92 | rows, err := db.Query(qry, filter.Values...) 93 | if err != nil { 94 | return []scan.IPInfo{}, err 95 | } 96 | 97 | defer rows.Close() 98 | 99 | var data []scan.IPInfo 100 | var ip, proto string 101 | var firstseen, lastseen time.Time 102 | var port int 103 | var latest time.Time 104 | 105 | tracerouteIPs, err := db.LoadTracerouteIPs() 106 | if err != nil { 107 | return []scan.IPInfo{}, err 108 | } 109 | 110 | submission, err := db.LoadSubmission(SQLFilter{Where: []string{"job_id IS NULL"}}) 111 | if err == nil { 112 | latest = submission.Time.Time 113 | } 114 | 115 | for rows.Next() { 116 | err := rows.Scan(&ip, &port, &proto, &firstseen, &lastseen) 117 | if err != nil { 118 | log.Println("loadData: error scanning table:", err) 119 | return []scan.IPInfo{}, err 120 | } 121 | if lastseen.After(latest) { 122 | latest = lastseen 123 | } 124 | var hasTraceroute bool 125 | if _, ok := tracerouteIPs[ip]; ok { 126 | hasTraceroute = true 127 | } 128 | data = append(data, scan.IPInfo{ 129 | IP: ip, 130 | Port: port, 131 | Proto: proto, 132 | FirstSeen: scan.Time{Time: firstseen}, 133 | LastSeen: scan.Time{Time: lastseen}, 134 | New: firstseen.Equal(lastseen) && lastseen == latest, 135 | Gone: lastseen.Before(latest), 136 | HasTraceroute: hasTraceroute}) 137 | } 138 | 139 | return data, nil 140 | } 141 | 142 | // ResultData retrieves stored results. Each argument is optional and allows 143 | // searching by IP address, first seen and last seen. 144 | func (db *DB) ResultData(ip, fs, ls string) (scan.Data, error) { 145 | var filter SQLFilter 146 | if ip != "" { 147 | filter.Where = append(filter.Where, `ip LIKE ?`) 148 | filter.Values = append(filter.Values, fmt.Sprintf("%%%s%%", ip)) 149 | } 150 | if fs != "" { 151 | i, err := strconv.ParseInt(fs, 10, 0) 152 | if err != nil { 153 | log.Printf("couldn't parse firstseen value %q: %v", ls, err) 154 | } else { 155 | t := time.Unix(i, 0).UTC() 156 | filter.Where = append(filter.Where, `firstseen=?`) 157 | filter.Values = append(filter.Values, t) 158 | } 159 | } 160 | if ls != "" { 161 | i, err := strconv.ParseInt(ls, 10, 0) 162 | if err != nil { 163 | log.Printf("couldn't parse lastseen value %q: %v", ls, err) 164 | } else { 165 | t := time.Unix(i, 0).UTC() 166 | filter.Where = append(filter.Where, `lastseen=?`) 167 | filter.Values = append(filter.Values, t) 168 | } 169 | } 170 | 171 | results, err := db.LoadData(filter) 172 | if err != nil { 173 | return scan.Data{}, err 174 | } 175 | 176 | data := scan.Data{ 177 | Results: results, 178 | Total: len(results), 179 | } 180 | 181 | // Find all the latest results and store the number in the struct 182 | // Set latest to Unix(0, 0) rather than the default zero value of the type 183 | // to allow tests to receive an actual 0 value rather than a negative int 184 | latest := time.Unix(0, 0) 185 | for _, r := range results { 186 | last := r.LastSeen.Time 187 | if last.After(latest) { 188 | latest = last 189 | } 190 | } 191 | for _, r := range results { 192 | if !r.Gone { 193 | data.Latest++ 194 | } 195 | if r.New { 196 | data.New++ 197 | } 198 | } 199 | data.LastSeen = latest.Unix() 200 | 201 | return data, nil 202 | } 203 | 204 | // SaveData saves the results posted. 205 | func (db *DB) SaveData(results []scan.Result, now time.Time) (int64, error) { 206 | txn, err := db.Begin() 207 | if err != nil { 208 | return 0, err 209 | } 210 | 211 | insert, err := txn.Prepare(`INSERT INTO scan (ip, port, proto, firstseen, lastseen) VALUES (?, ?, ?, ?, ?)`) 212 | if err != nil { 213 | txn.Rollback() 214 | return 0, err 215 | } 216 | qry, err := txn.Prepare(`SELECT 1 FROM scan WHERE ip=? AND port=? AND proto=?`) 217 | if err != nil { 218 | txn.Rollback() 219 | return 0, err 220 | } 221 | update, err := txn.Prepare(`UPDATE scan SET lastseen=? WHERE ip=? AND port=? AND proto=?`) 222 | if err != nil { 223 | txn.Rollback() 224 | return 0, err 225 | } 226 | 227 | var count int64 228 | 229 | for _, r := range results { 230 | // Although it's an array, only one port is in each 231 | port := r.Ports[0] 232 | 233 | // Skip results which are (usually) banner-only 234 | // While it would be nice to store banners, we need to restructure a 235 | // bit to accommodate this and it just inserts duplicate data for now 236 | if port.Status == "" || port.Service.Name != "" { 237 | continue 238 | } 239 | 240 | // Search for the IP/port/proto combo 241 | // If it exists, update `lastseen`, else insert a new record 242 | 243 | // Because we have to scan into something 244 | var x int 245 | err := qry.QueryRow(r.IP, port.Port, port.Proto).Scan(&x) 246 | switch { 247 | case err == sql.ErrNoRows: 248 | _, err = insert.Exec(r.IP, port.Port, port.Proto, now, now) 249 | if err != nil { 250 | txn.Rollback() 251 | return 0, err 252 | } 253 | count++ 254 | continue 255 | case err != nil: 256 | txn.Rollback() 257 | return 0, err 258 | } 259 | 260 | _, err = update.Exec(now, r.IP, port.Port, port.Proto) 261 | if err != nil { 262 | txn.Rollback() 263 | return 0, err 264 | } 265 | 266 | count++ 267 | } 268 | 269 | txn.Commit() 270 | return count, nil 271 | } 272 | 273 | // LoadSubmission retrieves the stored submissions. 274 | func (db *DB) LoadSubmission(filter SQLFilter) (scan.Submission, error) { 275 | var host string 276 | var job sql.NullInt64 277 | var subTime sql.NullTime 278 | 279 | qry := fmt.Sprintf(`SELECT host, job_id, submission_time FROM submission %s ORDER BY rowid DESC LIMIT 1`, filter) 280 | err := db.QueryRow(qry, filter.Values...).Scan(&host, &job, &subTime) 281 | if err != nil && err != sql.ErrNoRows { 282 | log.Println("loadSubmission: error scanning table:", err) 283 | return scan.Submission{}, err 284 | } 285 | 286 | return scan.Submission{Host: host, Job: job.Int64, Time: scan.Time{Time: subTime.Time.UTC()}}, nil 287 | } 288 | 289 | // SaveSubmission stores when and which host just submitted data. 290 | func (db *DB) SaveSubmission(host string, job *int64, now time.Time) error { 291 | txn, err := db.Begin() 292 | if err != nil { 293 | return err 294 | } 295 | 296 | qry := `INSERT INTO submission (host, job_id, submission_time) VALUES (?, ?, ?)` 297 | _, err = txn.Exec(qry, host, toNullInt64(job), now) 298 | if err != nil { 299 | txn.Rollback() 300 | return err 301 | } 302 | 303 | err = txn.Commit() 304 | if err != nil { 305 | return err 306 | } 307 | 308 | return nil 309 | } 310 | 311 | // LoadTracerouteIPs retrieves the stored traceroutes. 312 | func (db *DB) LoadTracerouteIPs() (map[string]struct{}, error) { 313 | ips := make(map[string]struct{}) 314 | 315 | rows, err := db.Query(`SELECT dest FROM traceroute`) 316 | if err != nil { 317 | return nil, err 318 | } 319 | defer rows.Close() 320 | 321 | var ip string 322 | for rows.Next() { 323 | err := rows.Scan(&ip) 324 | if err != nil { 325 | return nil, err 326 | } 327 | if _, ok := ips[ip]; !ok { 328 | ips[ip] = struct{}{} 329 | } 330 | } 331 | 332 | return ips, nil 333 | } 334 | 335 | // LoadTraceroute retrieves a traceroute. 336 | func (db *DB) LoadTraceroute(dest string) (string, error) { 337 | var path string 338 | err := db.QueryRow(`SELECT path FROM traceroute WHERE dest = ?`, dest).Scan(&path) 339 | return path, err 340 | } 341 | 342 | func (db *DB) SaveTraceroute(dest, trace string) error { 343 | txn, err := db.Begin() 344 | if err != nil { 345 | return err 346 | } 347 | 348 | _, err = txn.Exec(`INSERT OR REPLACE INTO traceroute (dest, path) VALUES (?, ?)`, dest, trace) 349 | if err != nil { 350 | txn.Rollback() 351 | return err 352 | } 353 | 354 | return txn.Commit() 355 | } 356 | -------------------------------------------------------------------------------- /job.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "net/http" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/go-chi/chi" 11 | "github.com/go-chi/render" 12 | "github.com/jamesog/scan/internal/sqlite" 13 | "github.com/jamesog/scan/pkg/scan" 14 | "github.com/prometheus/client_golang/prometheus" 15 | ) 16 | 17 | type jobData struct { 18 | indexData 19 | JobID []string 20 | Jobs []scan.Job 21 | } 22 | 23 | // Handler for GET and POST /job 24 | func (app *App) newJob(w http.ResponseWriter, r *http.Request) { 25 | var user User 26 | if !authDisabled { 27 | session, err := store.Get(r, "user") 28 | if err != nil { 29 | http.Error(w, err.Error(), http.StatusInternalServerError) 30 | return 31 | } 32 | if _, ok := session.Values["user"]; !ok { 33 | data := jobData{indexData: indexData{URI: r.RequestURI}} 34 | tmpl.ExecuteTemplate(w, "job", data) 35 | return 36 | } 37 | v := session.Values["user"] 38 | switch v := v.(type) { 39 | case string: 40 | user.Email = v 41 | case User: 42 | user = v 43 | } 44 | } 45 | 46 | var jobID []string 47 | var errors []string 48 | 49 | if r.Method == "POST" { 50 | err := r.ParseForm() 51 | if err != nil { 52 | w.WriteHeader(http.StatusBadRequest) 53 | return 54 | } 55 | 56 | f := r.Form 57 | cidr := f.Get("cidr") 58 | ports := f.Get("ports") 59 | proto := f["proto"] 60 | 61 | if cidr == "" { 62 | errors = append(errors, "CIDR") 63 | } 64 | if ports == "" { 65 | errors = append(errors, "Ports") 66 | } 67 | if len(proto) == 0 { 68 | errors = append(errors, "Protocol") 69 | } 70 | 71 | // If we have form parameters, save the data as a new job. 72 | // Multiple protocols can be submitted. These are saved as separate jobs. 73 | if len(errors) == 0 { 74 | for i := range proto { 75 | id, err := app.db.SaveJob(cidr, ports, proto[i], user.Email) 76 | if err != nil { 77 | http.Error(w, err.Error(), http.StatusInternalServerError) 78 | return 79 | } 80 | jobID = append(jobID, strconv.FormatInt(id, 10)) 81 | } 82 | } 83 | } 84 | 85 | jobs, err := app.db.LoadJobs(sqlite.SQLFilter{}) 86 | if err != nil { 87 | http.Error(w, err.Error(), http.StatusInternalServerError) 88 | return 89 | } 90 | 91 | sub, err := app.db.LoadJobSubmission() 92 | if err != nil { 93 | log.Println("newJob: couldn't load submissions:", err) 94 | http.Error(w, err.Error(), http.StatusInternalServerError) 95 | return 96 | } 97 | 98 | // Fetch result numbers for display in the navbar 99 | // Errors aren't fatal here, we can just display 0 results if something 100 | // goes wrong 101 | results, _ := app.db.ResultData("", "", "") 102 | 103 | data := jobData{ 104 | indexData: indexData{ 105 | Errors: errors, 106 | Authenticated: true, 107 | User: user, 108 | URI: r.URL.Path, 109 | Submission: sub, 110 | Data: results, 111 | }, 112 | JobID: jobID, 113 | Jobs: jobs, 114 | } 115 | 116 | tmpl.ExecuteTemplate(w, "job", data) 117 | } 118 | 119 | // Handler for GET /jobs 120 | func (app *App) jobs(w http.ResponseWriter, r *http.Request) { 121 | jobs, err := app.db.LoadJobs(sqlite.SQLFilter{ 122 | Where: []string{"received IS NULL"}, 123 | }) 124 | if err != nil { 125 | w.WriteHeader(http.StatusInternalServerError) 126 | render.JSON(w, r, err.Error()) 127 | } 128 | 129 | render.JSON(w, r, jobs) 130 | } 131 | 132 | // Handler for PUT /results/{id} 133 | func (app *App) recvJobResults(w http.ResponseWriter, r *http.Request) { 134 | job := chi.URLParam(r, "id") 135 | 136 | // Check if the job ID is valid 137 | jobs, err := app.db.LoadJobs(sqlite.SQLFilter{ 138 | Where: []string{"rowid=?"}, 139 | Values: []interface{}{job}, 140 | }) 141 | if err != nil { 142 | http.Error(w, err.Error(), http.StatusBadRequest) 143 | return 144 | } 145 | if len(jobs) == 0 { 146 | http.Error(w, "Job does not exist", http.StatusBadRequest) 147 | return 148 | } 149 | if !jobs[0].Received.IsZero() { 150 | http.Error(w, "Job already submitted", http.StatusBadRequest) 151 | return 152 | } 153 | 154 | now := time.Now().UTC() 155 | 156 | // Insert the results as normal 157 | count, err := app.saveResults(w, r, now) 158 | if err != nil { 159 | http.Error(w, err.Error(), http.StatusInternalServerError) 160 | return 161 | } 162 | 163 | // Update the job 164 | err = app.db.UpdateJob(job, count) 165 | if err != nil { 166 | http.Error(w, err.Error(), http.StatusInternalServerError) 167 | return 168 | } 169 | 170 | ip, _, err := net.SplitHostPort(r.RemoteAddr) 171 | if err != nil { 172 | ip = r.RemoteAddr 173 | } 174 | id, _ := strconv.ParseInt(job, 10, 64) 175 | 176 | err = app.db.SaveSubmission(ip, &id, now) 177 | if err != nil { 178 | log.Println("recvJobResults: error saving submission:", err) 179 | http.Error(w, err.Error(), http.StatusInternalServerError) 180 | return 181 | } 182 | 183 | // Finally, update metrics 184 | gaugeJobSubmission.Set(float64(now.Unix())) 185 | gaugeJobs.With(prometheus.Labels{ 186 | "id": strconv.FormatInt(id, 10), 187 | "submitted": strconv.FormatInt(time.Now().Unix(), 10), 188 | "received": strconv.FormatInt(time.Now().Unix(), 10), 189 | }).Set(float64(count)) 190 | } 191 | -------------------------------------------------------------------------------- /job_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/jamesog/scan/internal/sqlite" 14 | ) 15 | 16 | func TestLoadJobsWithNoResults(t *testing.T) { 17 | db := createDB("TestLoadJobsWithNoResults") 18 | defer db.Close() 19 | data, err := db.LoadJobs(sqlite.SQLFilter{}) 20 | if err != nil { 21 | t.Fatalf("error from loadJobs: %v", err) 22 | } 23 | if len(data) != 0 { 24 | t.Errorf("expected len 0, got %v", len(data)) 25 | } 26 | } 27 | 28 | func TestSaveJob(t *testing.T) { 29 | db := createDB("TestSaveJob") 30 | defer db.Close() 31 | id, err := db.SaveJob("192.0.2.0/24", "80,443", "tcp", "sysadmin@example.com") 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | if id != 1 { 36 | t.Errorf("expected ID 1, got %d", id) 37 | } 38 | } 39 | 40 | func TestUpdateJob(t *testing.T) { 41 | db := createDB("TestUpdateJob") 42 | defer db.Close() 43 | id, err := db.SaveJob("192.0.2.0/24", "80,443", "tcp", "sysadmin@example.com") 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | err = db.UpdateJob(strconv.FormatInt(id, 10), 999) 48 | if err != nil { 49 | t.Errorf("error updating job: %v", err) 50 | } 51 | } 52 | 53 | func TestJobHandler(t *testing.T) { 54 | db := createDB("TestJobHandler") 55 | defer db.Close() 56 | app := App{db: db} 57 | 58 | r := httptest.NewRequest("GET", "/job", nil) 59 | w := httptest.NewRecorder() 60 | app.newJob(w, r) 61 | 62 | resp := w.Result() 63 | body, _ := ioutil.ReadAll(resp.Body) 64 | if resp.StatusCode != http.StatusOK { 65 | t.Errorf("expected status 200, got %v: %s", resp.StatusCode, body) 66 | } 67 | 68 | v := url.Values{} 69 | v.Set("cidr", "192.0.2.0/24") 70 | v.Set("ports", "1-1024") 71 | v.Set("proto", "tcp") 72 | 73 | r = httptest.NewRequest("POST", "/job", strings.NewReader(v.Encode())) 74 | w = httptest.NewRecorder() 75 | app.newJob(w, r) 76 | 77 | resp = w.Result() 78 | body, _ = ioutil.ReadAll(resp.Body) 79 | if resp.StatusCode != http.StatusOK { 80 | t.Errorf("expected status 200, got %v: %s", resp.StatusCode, body) 81 | } 82 | } 83 | 84 | func TestJobsHandler(t *testing.T) { 85 | db := createDB("TestJobsHandler") 86 | defer db.Close() 87 | app := App{db: db} 88 | 89 | r := httptest.NewRequest("GET", "/jobs", nil) 90 | w := httptest.NewRecorder() 91 | app.jobs(w, r) 92 | 93 | resp := w.Result() 94 | body, _ := ioutil.ReadAll(resp.Body) 95 | if resp.StatusCode != http.StatusOK { 96 | t.Errorf("expected status 200, got %v: %s", resp.StatusCode, body) 97 | } 98 | ct := resp.Header.Get("Content-Type") 99 | if ct != "application/json" { 100 | t.Errorf("expected Content-Type: %s, got %v", "application/json", ct) 101 | } 102 | } 103 | 104 | func TestJobResultsHandler(t *testing.T) { 105 | db := createDB("TestJobResultsHandler") 106 | defer db.Close() 107 | app := App{db: db} 108 | 109 | data := bytes.NewBuffer([]byte(`[{"ip":"192.0.2.1","ports":[{"port":80,"proto":"tcp","status":"open","reason":"syn-ack","ttl":57}]}]`)) 110 | 111 | mux := app.setupRouter() 112 | ts := httptest.NewServer(mux) 113 | defer ts.Close() 114 | 115 | // We need to save some job data before trying to submit any 116 | app.db.SaveJob("192.0.2.1", "80", "tcp", "testuser@example.com") 117 | 118 | req, err := http.NewRequest("PUT", ts.URL+"/results/1", data) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | req.Header.Set("Content-Type", "application/json") 123 | 124 | resp, err := http.DefaultClient.Do(req) 125 | if err != nil { 126 | t.Fatal(err) 127 | } 128 | if resp.StatusCode != http.StatusOK { 129 | t.Errorf("expected status 200, got %v", resp.StatusCode) 130 | } 131 | 132 | // Do it again - submitting the same job should be an error 133 | resp, err = http.DefaultClient.Do(req) 134 | if err != nil { 135 | t.Fatal(err) 136 | } 137 | if resp.StatusCode != http.StatusBadRequest { 138 | t.Errorf("expected status 400, got %v", resp.StatusCode) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /jobs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesog/scan/050d36cff97af8d07183b32ca77dd962bd1f8c3f/jobs.png -------------------------------------------------------------------------------- /metrics.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/jamesog/scan/internal/sqlite" 8 | "github.com/prometheus/client_golang/prometheus" 9 | "github.com/prometheus/client_golang/prometheus/promhttp" 10 | ) 11 | 12 | var ( 13 | gaugeTotal = prometheus.NewGauge(prometheus.GaugeOpts{ 14 | Namespace: "scan", 15 | Subsystem: "ips", 16 | Name: "total", 17 | Help: "Total IPs found", 18 | }) 19 | 20 | gaugeLatest = prometheus.NewGauge(prometheus.GaugeOpts{ 21 | Namespace: "scan", 22 | Subsystem: "ips", 23 | Name: "latest", 24 | Help: "Latest IPs found", 25 | }) 26 | 27 | gaugeNew = prometheus.NewGauge(prometheus.GaugeOpts{ 28 | Namespace: "scan", 29 | Subsystem: "ips", 30 | Name: "new", 31 | Help: "New IPs found", 32 | }) 33 | 34 | gaugeSubmission = prometheus.NewGauge(prometheus.GaugeOpts{ 35 | Namespace: "scan", 36 | Name: "last_submission_time", 37 | Help: "Last submission time in seconds since the Unix epoch", 38 | }) 39 | 40 | gaugeJobs = prometheus.NewGaugeVec( 41 | prometheus.GaugeOpts{ 42 | Namespace: "scan", 43 | Name: "job", 44 | Help: "Number of IPs found in each each job, with submitted and received times", 45 | }, 46 | []string{"id", "submitted", "received"}) 47 | 48 | gaugeJobSubmission = prometheus.NewGauge(prometheus.GaugeOpts{ 49 | Namespace: "scan", 50 | Subsystem: "job", 51 | Name: "last_submission_time", 52 | Help: "Last job submission time in seconds since the Unix epoch", 53 | }) 54 | ) 55 | 56 | func init() { 57 | prometheus.MustRegister(gaugeTotal) 58 | prometheus.MustRegister(gaugeLatest) 59 | prometheus.MustRegister(gaugeNew) 60 | prometheus.MustRegister(gaugeSubmission) 61 | prometheus.MustRegister(gaugeJobs) 62 | prometheus.MustRegister(gaugeJobSubmission) 63 | } 64 | 65 | func (app *App) metrics() http.Handler { 66 | results, err := app.db.ResultData("", "", "") 67 | if err == nil { 68 | gaugeTotal.Set(float64(results.Total)) 69 | gaugeLatest.Set(float64(results.Latest)) 70 | gaugeNew.Set(float64(results.New)) 71 | } 72 | 73 | jobs, _ := app.db.LoadJobs(sqlite.SQLFilter{ 74 | Where: []string{`received IS NOT NULL`}, 75 | }) 76 | for _, job := range jobs { 77 | gaugeJobs.With(prometheus.Labels{ 78 | "id": strconv.Itoa(job.ID), 79 | "submitted": strconv.FormatInt(job.Submitted.Unix(), 10), 80 | "received": strconv.FormatInt(job.Received.Unix(), 10), 81 | }).Set(float64(job.Count)) 82 | } 83 | 84 | sub, _ := app.db.LoadSubmission(sqlite.SQLFilter{}) 85 | gaugeSubmission.Set(float64(sub.Time.Unix())) 86 | 87 | return promhttp.Handler() 88 | } 89 | -------------------------------------------------------------------------------- /new_data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesog/scan/050d36cff97af8d07183b32ca77dd962bd1f8c3f/new_data.png -------------------------------------------------------------------------------- /pkg/scan/scan.go: -------------------------------------------------------------------------------- 1 | package scan 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Port is a masscan port description. 8 | type Port struct { 9 | Port int `json:"port"` 10 | Proto string `json:"proto"` 11 | Status string `json:"status"` 12 | Service struct { 13 | Name string `json:"name"` 14 | Banner string `json:"banner"` 15 | } `json:"service"` 16 | } 17 | 18 | // Result data posted from masscan. 19 | type Result struct { 20 | IP string `json:"ip"` 21 | Ports []Port `json:"ports"` 22 | } 23 | 24 | // Time wraps time.Time to implement a custom String method. 25 | type Time struct { 26 | time.Time 27 | } 28 | 29 | const dateTime = "2006-01-02 15:04" 30 | 31 | func (t Time) String() string { 32 | if t.IsZero() { 33 | return "" 34 | } 35 | return t.Format(dateTime) 36 | } 37 | 38 | // IPInfo is data retrieved from the database for display. 39 | type IPInfo struct { 40 | IP string 41 | Port int 42 | Proto string 43 | FirstSeen Time 44 | LastSeen Time 45 | New bool 46 | Gone bool 47 | HasTraceroute bool 48 | } 49 | 50 | // Data is used for display in the UI. It contains a summary of the number of 51 | // items stored in the database as well as each result. 52 | type Data struct { 53 | Total int 54 | Latest int 55 | New int 56 | LastSeen int64 57 | Results []IPInfo 58 | } 59 | 60 | // Submission is used for display in the UI to show when and which host last 61 | // submitted results. 62 | type Submission struct { 63 | Host string 64 | Job int64 65 | Time Time 66 | } 67 | 68 | // Job represents a job to be sent to and received from scanning nodes, 69 | type Job struct { 70 | ID int `json:"id"` 71 | CIDR string `json:"cidr"` 72 | Ports string `json:"ports"` 73 | Proto string `json:"proto"` 74 | RequestedBy string `json:"-"` 75 | Submitted Time `json:"-"` 76 | Received Time `json:"-"` 77 | Count int64 `json:"-"` 78 | } 79 | -------------------------------------------------------------------------------- /scan.go: -------------------------------------------------------------------------------- 1 | //go:generate go-bindata views static/... 2 | package main 3 | 4 | import ( 5 | "bytes" 6 | "crypto/tls" 7 | "database/sql" 8 | "encoding/json" 9 | "errors" 10 | "flag" 11 | "fmt" 12 | "html/template" 13 | "io" 14 | "io/ioutil" 15 | "log" 16 | "mime" 17 | "net" 18 | "net/http" 19 | "os" 20 | "path" 21 | "path/filepath" 22 | "strings" 23 | "time" 24 | 25 | "golang.org/x/crypto/acme/autocert" 26 | 27 | "github.com/go-chi/chi" 28 | "github.com/go-chi/chi/middleware" 29 | 30 | "github.com/jamesog/scan/internal/sqlite" 31 | "github.com/jamesog/scan/pkg/scan" 32 | ) 33 | 34 | var ( 35 | // Flag variables 36 | authDisabled bool 37 | credsFile string 38 | dataDir string 39 | httpsAddr string 40 | verbose bool 41 | 42 | // HTML templates 43 | tmpl *template.Template 44 | ) 45 | 46 | type storage interface { 47 | LoadData(filter sqlite.SQLFilter) ([]scan.IPInfo, error) 48 | ResultData(ip, fs, ls string) (scan.Data, error) 49 | SaveData(results []scan.Result, now time.Time) (int64, error) 50 | LoadSubmission(filter sqlite.SQLFilter) (scan.Submission, error) 51 | SaveSubmission(host string, job *int64, now time.Time) error 52 | LoadTracerouteIPs() (map[string]struct{}, error) 53 | LoadTraceroute(dest string) (string, error) 54 | SaveTraceroute(dest, trace string) error 55 | LoadJobs(filter sqlite.SQLFilter) ([]scan.Job, error) 56 | LoadJobSubmission() (scan.Submission, error) 57 | SaveJob(cidr, ports, proto, user string) (int64, error) 58 | UpdateJob(id string, count int64) error 59 | LoadUsers() ([]string, error) 60 | LoadGroups() ([]string, error) 61 | UserExists(email string) (bool, error) 62 | SaveUser(email string) error 63 | DeleteUser(email string) error 64 | SaveAudit(ts time.Time, user, event, info string) error 65 | } 66 | 67 | type indexData struct { 68 | NotAuth string 69 | Errors []string 70 | Authenticated bool 71 | User User 72 | URI string 73 | AllResults bool 74 | Submission scan.Submission 75 | scan.Data 76 | } 77 | 78 | type App struct { 79 | db storage 80 | } 81 | 82 | // Handler for GET / 83 | func (app *App) index(w http.ResponseWriter, r *http.Request) { 84 | var user User 85 | if !authDisabled { 86 | session, err := store.Get(r, "user") 87 | if err != nil { 88 | http.Error(w, err.Error(), http.StatusInternalServerError) 89 | return 90 | } 91 | if _, ok := session.Values["user"]; !ok { 92 | data := indexData{URI: r.RequestURI} 93 | if flash := session.Flashes("unauth_flash"); len(flash) > 0 { 94 | data.NotAuth = flash[0].(string) 95 | w.WriteHeader(http.StatusUnauthorized) 96 | session.Save(r, w) 97 | } 98 | tmpl.ExecuteTemplate(w, "index", data) 99 | return 100 | } 101 | v := session.Values["user"] 102 | switch v := v.(type) { 103 | case string: 104 | user.Email = v 105 | case User: 106 | user = v 107 | } 108 | } 109 | 110 | q := r.URL.Query() 111 | ip := q.Get("ip") 112 | firstSeen := q.Get("firstseen") 113 | lastSeen := q.Get("lastseen") 114 | _, allResults := q["all"] 115 | 116 | results, err := app.db.ResultData(ip, firstSeen, lastSeen) 117 | if err != nil { 118 | http.Error(w, err.Error(), http.StatusInternalServerError) 119 | return 120 | } 121 | 122 | sub, err := app.db.LoadSubmission(sqlite.SQLFilter{}) 123 | if err != nil { 124 | http.Error(w, err.Error(), http.StatusInternalServerError) 125 | return 126 | } 127 | 128 | data := indexData{ 129 | Authenticated: true, 130 | User: user, 131 | URI: r.URL.Path, 132 | AllResults: allResults, 133 | Submission: sub, 134 | Data: results, 135 | } 136 | tmpl.ExecuteTemplate(w, "index", data) 137 | } 138 | 139 | func (app *App) saveResults(w http.ResponseWriter, r *http.Request, now time.Time) (int64, error) { 140 | if r.Header.Get("Content-Type") != "application/json" { 141 | w.WriteHeader(http.StatusUnsupportedMediaType) 142 | return 0, errors.New("invalid Content-Type") 143 | } 144 | 145 | res := new([]scan.Result) 146 | 147 | err := json.NewDecoder(r.Body).Decode(&res) 148 | if err != nil { 149 | return 0, err 150 | } 151 | 152 | count, err := app.db.SaveData(*res, now) 153 | if err != nil { 154 | return 0, err 155 | } 156 | 157 | return count, nil 158 | } 159 | 160 | // Handler for POST /results 161 | func (app *App) recvResults(w http.ResponseWriter, r *http.Request) { 162 | now := time.Now().UTC().Truncate(time.Second) 163 | _, err := app.saveResults(w, r, now) 164 | if err != nil { 165 | log.Println("recvResults: error saving results:", err) 166 | http.Error(w, err.Error(), http.StatusInternalServerError) 167 | return 168 | } 169 | ip, _, err := net.SplitHostPort(r.RemoteAddr) 170 | if err != nil { 171 | ip = r.RemoteAddr 172 | } 173 | err = app.db.SaveSubmission(ip, nil, now) 174 | if err != nil { 175 | log.Println("recvResults: error saving submission:", err) 176 | http.Error(w, err.Error(), http.StatusInternalServerError) 177 | return 178 | } 179 | 180 | // Update metrics with latest data 181 | results, err := app.db.ResultData("", "", "") 182 | if err != nil { 183 | log.Printf("saveResults: error fetching results for metrics update: %v\n", err) 184 | } else { 185 | gaugeSubmission.Set(float64(now.Unix())) 186 | gaugeTotal.Set(float64(results.Total)) 187 | gaugeLatest.Set(float64(results.Latest)) 188 | gaugeNew.Set(float64(results.New)) 189 | } 190 | } 191 | 192 | // Handler for POST /traceroute 193 | func (app *App) recvTraceroute(w http.ResponseWriter, r *http.Request) { 194 | dest := r.FormValue("dest") 195 | f, _, err := r.FormFile("traceroute") 196 | if err != nil { 197 | http.Error(w, err.Error(), http.StatusInternalServerError) 198 | return 199 | } 200 | trace, err := ioutil.ReadAll(f) 201 | if err != nil { 202 | http.Error(w, err.Error(), http.StatusInternalServerError) 203 | return 204 | } 205 | 206 | err = app.db.SaveTraceroute(dest, string(trace)) 207 | if err != nil { 208 | http.Error(w, err.Error(), http.StatusInternalServerError) 209 | return 210 | } 211 | 212 | w.Header().Set("Location", path.Join(r.URL.Path, dest)) 213 | w.WriteHeader(http.StatusCreated) 214 | } 215 | 216 | // Handler for GET /traceroute/{ip} 217 | func (app *App) traceroute(w http.ResponseWriter, r *http.Request) { 218 | ip := chi.URLParam(r, "ip") 219 | 220 | path, err := app.db.LoadTraceroute(ip) 221 | switch { 222 | case errors.Is(err, sql.ErrNoRows): 223 | http.Error(w, "Traceroute not found", http.StatusNotFound) 224 | return 225 | case err != nil: 226 | http.Error(w, err.Error(), http.StatusInternalServerError) 227 | return 228 | } 229 | 230 | io.WriteString(w, path) 231 | } 232 | 233 | // redirectHTTPS is a middleware for redirecting non-HTTPS requests to HTTPS 234 | func redirectHTTPS(next http.Handler) http.Handler { 235 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 236 | _, httpsPort, err := net.SplitHostPort(httpsAddr) 237 | if err != nil { 238 | http.Error(w, err.Error(), http.StatusInternalServerError) 239 | return 240 | } 241 | 242 | if r.TLS == nil { 243 | url := r.URL 244 | url.Scheme = "https" 245 | host, _, err := net.SplitHostPort(r.Host) 246 | if err != nil { 247 | url.Host = r.Host 248 | } else { 249 | url.Host = host 250 | } 251 | if httpsPort != "443" { 252 | url.Host = net.JoinHostPort(url.Host, httpsPort) 253 | } 254 | http.Redirect(w, r, url.String(), http.StatusMovedPermanently) 255 | return 256 | } 257 | next.ServeHTTP(w, r) 258 | }) 259 | } 260 | 261 | type assetMap map[string]asset 262 | 263 | var assets assetMap 264 | 265 | // loadAssetsFromDir gets all assets whose parent directory is "name" and 266 | // returns a map of the asset path to the asset function. 267 | func loadAssetsFromDir(name string) assetMap { 268 | assets = make(assetMap) 269 | for b := range _bindata { 270 | if strings.HasPrefix(b, name+"/") { 271 | a, err := _bindata[b]() 272 | if err != nil { 273 | log.Printf("Failed to load asset %s: %v", b, err) 274 | } 275 | assets[b] = *a 276 | } 277 | } 278 | return assets 279 | } 280 | 281 | // staticHandler returns a static asset from the map generated by 282 | // loadAssetsFromDir. 283 | func staticHandler(w http.ResponseWriter, r *http.Request) { 284 | path := strings.TrimPrefix(r.URL.Path, "/") 285 | if a, ok := assets[path]; ok { 286 | ct := mime.TypeByExtension(filepath.Ext(a.info.Name())) 287 | if ct == "" { 288 | ct = http.DetectContentType(a.bytes) 289 | } 290 | w.Header().Set("Content-Type", ct) 291 | b := bytes.NewReader(a.bytes) 292 | http.ServeContent(w, r, a.info.Name(), a.info.ModTime(), b) 293 | } 294 | 295 | http.NotFound(w, r) 296 | } 297 | 298 | func (app *App) setupRouter(middlewares ...func(http.Handler) http.Handler) *chi.Mux { 299 | r := chi.NewRouter() 300 | r.Use(middleware.RealIP) 301 | r.Use(middleware.Logger) 302 | for _, mw := range middlewares { 303 | r.Use(mw) 304 | } 305 | 306 | assets = loadAssetsFromDir("static") 307 | 308 | r.Get("/", app.index) 309 | r.Route("/admin", func(r chi.Router) { 310 | r.Get("/", app.adminHandler) 311 | r.Post("/", app.adminHandler) 312 | }) 313 | r.Get("/auth", app.authHandler) 314 | r.Route("/job", func(r chi.Router) { 315 | r.Get("/", app.newJob) 316 | r.Post("/", app.newJob) 317 | }) 318 | r.Get("/jobs", app.jobs) 319 | r.Get("/login", app.loginHandler) 320 | r.Get("/logout", app.logoutHandler) 321 | r.Post("/results", app.recvResults) 322 | r.Put("/results/{id}", app.recvJobResults) 323 | r.Get("/static/*", staticHandler) 324 | r.Post("/traceroute", app.recvTraceroute) 325 | r.Get("/traceroute/{ip}", app.traceroute) 326 | 327 | return r 328 | } 329 | 330 | func setupTemplates() { 331 | funcMap := template.FuncMap{ 332 | "join": func(sep string, s []string) string { 333 | return strings.Join(s, sep) 334 | }, 335 | } 336 | 337 | tmpl = template.New("").Funcs(funcMap) 338 | 339 | views, err := AssetDir("views") 340 | if err != nil { 341 | log.Fatal(err) 342 | } 343 | 344 | for _, file := range views { 345 | b, err := Asset("views/" + file) 346 | if err != nil { 347 | log.Println(err) 348 | continue 349 | } 350 | t := tmpl.New(filepath.Base(file)) 351 | template.Must(t.Parse(string(b))) 352 | } 353 | } 354 | 355 | func main() { 356 | flag.BoolVar(&authDisabled, "no-auth", false, "Disable authentication") 357 | flag.StringVar(&credsFile, "credentials", "client_secret.json", 358 | "OAuth 2.0 credentials `file`\n"+ 359 | "Relative paths are taken as relative to -data.dir") 360 | flag.StringVar(&dataDir, "data.dir", ".", "Data directory `path`") 361 | httpAddr := flag.String("http.addr", ":80", "HTTP `address`:port") 362 | flag.StringVar(&httpsAddr, "https.addr", ":443", "HTTPS `address`:port") 363 | metricsAddr := flag.String("metrics.addr", "localhost:3000", "Metrics `address`:port") 364 | metricsTLS := flag.Bool("metrics.tls", false, "Enable AutoTLS for metrics, if -tls enabled\n"+ 365 | "This is useful when exposing metrics on a public interface") 366 | enableTLS := flag.Bool("tls", false, "Enable AutoTLS") 367 | tlsHostname := flag.String("tls.hostname", "", "(Optional) Restrict AutoTLS to `hostname`") 368 | flag.BoolVar(&verbose, "v", false, "Enable verbose logging") 369 | flag.Parse() 370 | 371 | // Disable TLS on metrics if TLS wasn't generally enabled as autocert 372 | // isn't set up. 373 | if !*enableTLS && *metricsTLS { 374 | log.Println("Info: Disabling -metrics.tls as -tls was not enabled") 375 | *metricsTLS = false 376 | } 377 | 378 | if !filepath.IsAbs(credsFile) { 379 | credsFile = filepath.Join(dataDir, credsFile) 380 | } 381 | 382 | if !authDisabled { 383 | oauthConfig() 384 | } 385 | 386 | db, err := sqlite.Open(filepath.Join(dataDir, sqlite.DefaultDBFile)) 387 | if err != nil { 388 | log.Fatalf("failed to open database: %v", err) 389 | } 390 | app := &App{db: db} 391 | 392 | setupTemplates() 393 | 394 | var middlewares []func(http.Handler) http.Handler 395 | 396 | if authDisabled { 397 | fmt.Fprintf(os.Stderr, "%sAuthentication Disabled%s\n", "\033[31m", "\033[0m") 398 | } 399 | 400 | var m *autocert.Manager 401 | if *enableTLS { 402 | m = &autocert.Manager{ 403 | Cache: autocert.DirCache(filepath.Join(dataDir, ".cache")), 404 | Prompt: autocert.AcceptTOS, 405 | } 406 | if *tlsHostname != "" { 407 | m.HostPolicy = autocert.HostWhitelist(*tlsHostname) 408 | } 409 | middlewares = append(middlewares, m.HTTPHandler, redirectHTTPS) 410 | } 411 | 412 | r := app.setupRouter(middlewares...) 413 | 414 | // Common http.Server timeout values 415 | readTimeout := 5 * time.Second 416 | writeTimeout := 5 * time.Second 417 | idleTimeout := 120 * time.Second 418 | 419 | httpSrv := &http.Server{ 420 | Addr: *httpAddr, 421 | Handler: r, 422 | ReadTimeout: readTimeout, 423 | WriteTimeout: writeTimeout, 424 | IdleTimeout: idleTimeout, 425 | } 426 | 427 | metricsMux := chi.NewRouter() 428 | metricsMux.Use(middleware.RealIP) 429 | metricsMux.Use(middleware.Logger) 430 | if *metricsTLS { 431 | metricsMux.Use(redirectHTTPS) 432 | } 433 | metricsMux.Handle("/metrics", app.metrics()) 434 | metricsSrv := &http.Server{ 435 | Addr: *metricsAddr, 436 | Handler: metricsMux, 437 | ReadTimeout: readTimeout, 438 | WriteTimeout: writeTimeout, 439 | IdleTimeout: idleTimeout, 440 | } 441 | 442 | if !*metricsTLS { 443 | log.Println("Metrics HTTP server starting on", metricsSrv.Addr) 444 | go func() { log.Fatal(metricsSrv.ListenAndServe()) }() 445 | } 446 | 447 | if *enableTLS { 448 | tlsConfig := &tls.Config{ 449 | GetCertificate: m.GetCertificate, 450 | PreferServerCipherSuites: true, 451 | CurvePreferences: []tls.CurveID{ 452 | tls.CurveP256, 453 | tls.X25519, 454 | }, 455 | MinVersion: tls.VersionTLS12, 456 | CipherSuites: []uint16{ 457 | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, 458 | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, 459 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 460 | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, 461 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 462 | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, 463 | }, 464 | } 465 | 466 | httpsSrv := &http.Server{ 467 | Addr: httpsAddr, 468 | Handler: r, 469 | ReadTimeout: readTimeout, 470 | WriteTimeout: writeTimeout, 471 | IdleTimeout: idleTimeout, 472 | TLSConfig: tlsConfig, 473 | } 474 | if *metricsTLS { 475 | metricsSrv.Addr = *metricsAddr 476 | metricsSrv.Handler = metricsMux 477 | metricsSrv.TLSConfig = tlsConfig 478 | log.Println("Metrics HTTPS server starting on", metricsSrv.Addr) 479 | go func() { log.Fatal(metricsSrv.ListenAndServeTLS("", "")) }() 480 | } 481 | log.Println("HTTPS server starting on", httpsSrv.Addr) 482 | go func() { log.Fatal(httpsSrv.ListenAndServeTLS("", "")) }() 483 | } 484 | 485 | log.Println("HTTP server starting on", httpSrv.Addr) 486 | log.Fatal(httpSrv.ListenAndServe()) 487 | } 488 | -------------------------------------------------------------------------------- /scan_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "mime/multipart" 9 | "net/http" 10 | "net/http/httptest" 11 | "reflect" 12 | "testing" 13 | "time" 14 | 15 | "github.com/jamesog/scan/internal/sqlite" 16 | "github.com/jamesog/scan/pkg/scan" 17 | ) 18 | 19 | func init() { 20 | // We can't go through the OAuth2 login flow in tests 21 | authDisabled = true 22 | 23 | setupTemplates() 24 | } 25 | 26 | func createDB(test string) *sqlite.DB { 27 | db, err := sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", test)) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | return db 32 | } 33 | 34 | func TestLoadDataWithNoResults(t *testing.T) { 35 | db := createDB("TestLoadDataWithNoResults") 36 | defer db.Close() 37 | data, err := db.LoadData(sqlite.SQLFilter{}) 38 | if err != nil { 39 | t.Fatalf("error from loadData: %v", err) 40 | } 41 | if len(data) != 0 { 42 | t.Errorf("expected len 0, got %v", len(data)) 43 | } 44 | } 45 | 46 | func TestLoadTraceroutesWithNoResults(t *testing.T) { 47 | db := createDB("TestLoadTraceroutesWithNoResults") 48 | defer db.Close() 49 | tr, err := db.LoadTracerouteIPs() 50 | if err != nil { 51 | t.Fatal(err) 52 | } 53 | if len(tr) != 0 { 54 | t.Errorf("expected 0 results, got %v", len(tr)) 55 | } 56 | } 57 | 58 | func TestSaveData(t *testing.T) { 59 | db := createDB("TestSaveData") 60 | defer db.Close() 61 | results := []scan.Result{ 62 | {IP: "192.0.2.1", Ports: []scan.Port{{Port: 80, Proto: "tcp", Status: "open"}}}, 63 | {IP: "192.0.2.2", Ports: []scan.Port{{Port: 80, Proto: "tcp", Status: "open"}}}, 64 | {IP: "192.0.2.3", Ports: []scan.Port{{Port: 80, Proto: "tcp", Status: "open"}}}, 65 | } 66 | count, err := db.SaveData(results, time.Now().UTC()) 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | if count != int64(len(results)) { 71 | t.Errorf("expected count %d, got %d", len(results), count) 72 | } 73 | 74 | } 75 | 76 | func TestResultData(t *testing.T) { 77 | db := createDB("TestResultData") 78 | defer db.Close() 79 | want := scan.Data{Total: 0, Latest: 0, New: 0, LastSeen: time.Unix(0, 0).Unix(), Results: nil} 80 | data, err := db.ResultData("", "", "") 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | if !reflect.DeepEqual(data, want) { 85 | t.Errorf("want %+v, got %+v", want, data) 86 | } 87 | } 88 | 89 | // TestIndexHandlerWithoutAuth tests fetching the index page with 90 | // authentication disabled 91 | func TestIndexHandlerWithoutAuth(t *testing.T) { 92 | db := createDB("TestIndexHandlerWithoutAuth") 93 | defer db.Close() 94 | app := App{db: db} 95 | 96 | r := httptest.NewRequest("GET", "/", nil) 97 | w := httptest.NewRecorder() 98 | app.index(w, r) 99 | 100 | resp := w.Result() 101 | body, _ := ioutil.ReadAll(resp.Body) 102 | if resp.StatusCode != http.StatusOK { 103 | t.Errorf("expected status 200, got %v: %s", resp.StatusCode, body) 104 | } 105 | } 106 | 107 | func TestResultsHandler(t *testing.T) { 108 | db := createDB("TestResultsHandler") 109 | defer db.Close() 110 | app := App{db: db} 111 | 112 | data := bytes.NewBuffer([]byte(`[{"ip":"192.0.2.1","ports":[{"port":80,"proto":"tcp","status":"open","reason":"syn-ack","ttl":57}]}]`)) 113 | 114 | r := httptest.NewRequest("POST", "/results", data) 115 | r.Header.Set("Content-Type", "application/json") 116 | w := httptest.NewRecorder() 117 | app.recvResults(w, r) 118 | 119 | resp := w.Result() 120 | body, _ := ioutil.ReadAll(resp.Body) 121 | if resp.StatusCode != http.StatusOK { 122 | t.Errorf("expected status 200, got %v: %s", resp.StatusCode, body) 123 | } 124 | 125 | filter := sqlite.SQLFilter{Where: []string{"ip = ?"}, Values: []interface{}{"192.0.2.1"}} 126 | results, err := db.LoadData(filter) 127 | if err != nil { 128 | t.Errorf("couldn't retrieve results from database: %v", err) 129 | } 130 | if len(results) > 1 { 131 | t.Errorf("expected 1 result, got %d", len(results)) 132 | } 133 | if results[0].IP != "192.0.2.1" { 134 | t.Errorf("expected IP %s, got %v", "192.0.2.1", results[0].IP) 135 | } 136 | if results[0].Port != 80 { 137 | t.Errorf("expected port %d, got %v", 80, results[0].Port) 138 | } 139 | if results[0].Proto != "tcp" { 140 | t.Errorf("expected proto %s, got %v", "tcp", results[0].Proto) 141 | } 142 | 143 | // TODO(jamesog): We should test sending the same data to test the update 144 | // path in saveData() but this is currently difficult due to the way 145 | // times are stored in the database (as strings instead of raw values) - 146 | // it would require sleeping for some time, which isn't good. 147 | // ls := results[0].LastSeen 148 | // time.Sleep(70 * time.Second) 149 | // recvResults(w, r) 150 | 151 | // resp = w.Result() 152 | // body, _ = ioutil.ReadAll(resp.Body) 153 | // if resp.StatusCode != http.StatusOK { 154 | // t.Errorf("expected status 200, got %v: %s", resp.StatusCode, body) 155 | // } 156 | 157 | // results, _ = loadData(filter) 158 | // if results[0].LastSeen == ls { 159 | // t.Errorf("lastseen did not update, %v, %v", ls, results[0].LastSeen) 160 | // } 161 | } 162 | 163 | // TestTracerouteHandler tests fetching a route, ensuring it fails, uploading 164 | // that route then fetching it. 165 | func TestTracerouteHandler(t *testing.T) { 166 | db := createDB("TestTracerouteHandler") 167 | defer db.Close() 168 | app := App{db: db} 169 | 170 | route := ` 171 | traceroute to 192.0.2.1 (192.0.2.1), 64 hops max, 52 byte packets 172 | 1 router.internal (192.168.0.1) 1.308 ms 1.087 ms 0.929 ms 173 | 2 server.example.com (192.0.2.1) 8.134 ms !N 6.533 ms !N 6.295 ms !N 174 | ` 175 | 176 | mux := app.setupRouter() 177 | ts := httptest.NewServer(mux) 178 | defer ts.Close() 179 | 180 | req, err := http.NewRequest("GET", ts.URL+"/traceroute/192.0.2.1", nil) 181 | if err != nil { 182 | t.Fatal(err) 183 | } 184 | 185 | resp, err := http.DefaultClient.Do(req) 186 | if err != nil { 187 | t.Fatal(err) 188 | } 189 | body, _ := ioutil.ReadAll(resp.Body) 190 | if resp.StatusCode != http.StatusNotFound { 191 | t.Errorf("expected status %d, got %v: %s", http.StatusNotFound, resp.StatusCode, body) 192 | } 193 | 194 | postBody := new(bytes.Buffer) 195 | mp := multipart.NewWriter(postBody) 196 | mp.WriteField("dest", "192.0.2.1") 197 | ff, _ := mp.CreateFormFile("traceroute", "traceroute") 198 | ff.Write([]byte(route)) 199 | mp.Close() 200 | 201 | req = httptest.NewRequest("POST", "/traceroute", postBody) 202 | req.Header.Set("Content-Type", mp.FormDataContentType()) 203 | w := httptest.NewRecorder() 204 | app.recvTraceroute(w, req) 205 | 206 | resp = w.Result() 207 | body, _ = ioutil.ReadAll(resp.Body) 208 | if resp.StatusCode != http.StatusCreated { 209 | t.Errorf("expected status %d, got %v: %s", http.StatusCreated, resp.StatusCode, body) 210 | } 211 | 212 | req, err = http.NewRequest("GET", ts.URL+"/traceroute/192.0.2.1", nil) 213 | if err != nil { 214 | t.Fatal(err) 215 | } 216 | 217 | resp, err = http.DefaultClient.Do(req) 218 | if err != nil { 219 | t.Fatal(err) 220 | } 221 | body, _ = ioutil.ReadAll(resp.Body) 222 | if resp.StatusCode != http.StatusOK { 223 | t.Errorf("expected status %d, got %v: %s", http.StatusOK, resp.StatusCode, body) 224 | } 225 | 226 | if string(body) != route { 227 | t.Errorf("expect %q, got %q", route, string(body)) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /static/css/bootstrap-theme.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.7 (http://getbootstrap.com) 3 | * Copyright 2011-2016 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | .btn-default, 7 | .btn-primary, 8 | .btn-success, 9 | .btn-info, 10 | .btn-warning, 11 | .btn-danger { 12 | text-shadow: 0 -1px 0 rgba(0, 0, 0, .2); 13 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); 14 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 1px rgba(0, 0, 0, .075); 15 | } 16 | .btn-default:active, 17 | .btn-primary:active, 18 | .btn-success:active, 19 | .btn-info:active, 20 | .btn-warning:active, 21 | .btn-danger:active, 22 | .btn-default.active, 23 | .btn-primary.active, 24 | .btn-success.active, 25 | .btn-info.active, 26 | .btn-warning.active, 27 | .btn-danger.active { 28 | -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); 29 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); 30 | } 31 | .btn-default.disabled, 32 | .btn-primary.disabled, 33 | .btn-success.disabled, 34 | .btn-info.disabled, 35 | .btn-warning.disabled, 36 | .btn-danger.disabled, 37 | .btn-default[disabled], 38 | .btn-primary[disabled], 39 | .btn-success[disabled], 40 | .btn-info[disabled], 41 | .btn-warning[disabled], 42 | .btn-danger[disabled], 43 | fieldset[disabled] .btn-default, 44 | fieldset[disabled] .btn-primary, 45 | fieldset[disabled] .btn-success, 46 | fieldset[disabled] .btn-info, 47 | fieldset[disabled] .btn-warning, 48 | fieldset[disabled] .btn-danger { 49 | -webkit-box-shadow: none; 50 | box-shadow: none; 51 | } 52 | .btn-default .badge, 53 | .btn-primary .badge, 54 | .btn-success .badge, 55 | .btn-info .badge, 56 | .btn-warning .badge, 57 | .btn-danger .badge { 58 | text-shadow: none; 59 | } 60 | .btn:active, 61 | .btn.active { 62 | background-image: none; 63 | } 64 | .btn-default { 65 | text-shadow: 0 1px 0 #fff; 66 | background-image: -webkit-linear-gradient(top, #fff 0%, #e0e0e0 100%); 67 | background-image: -o-linear-gradient(top, #fff 0%, #e0e0e0 100%); 68 | background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#e0e0e0)); 69 | background-image: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%); 70 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0); 71 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 72 | background-repeat: repeat-x; 73 | border-color: #dbdbdb; 74 | border-color: #ccc; 75 | } 76 | .btn-default:hover, 77 | .btn-default:focus { 78 | background-color: #e0e0e0; 79 | background-position: 0 -15px; 80 | } 81 | .btn-default:active, 82 | .btn-default.active { 83 | background-color: #e0e0e0; 84 | border-color: #dbdbdb; 85 | } 86 | .btn-default.disabled, 87 | .btn-default[disabled], 88 | fieldset[disabled] .btn-default, 89 | .btn-default.disabled:hover, 90 | .btn-default[disabled]:hover, 91 | fieldset[disabled] .btn-default:hover, 92 | .btn-default.disabled:focus, 93 | .btn-default[disabled]:focus, 94 | fieldset[disabled] .btn-default:focus, 95 | .btn-default.disabled.focus, 96 | .btn-default[disabled].focus, 97 | fieldset[disabled] .btn-default.focus, 98 | .btn-default.disabled:active, 99 | .btn-default[disabled]:active, 100 | fieldset[disabled] .btn-default:active, 101 | .btn-default.disabled.active, 102 | .btn-default[disabled].active, 103 | fieldset[disabled] .btn-default.active { 104 | background-color: #e0e0e0; 105 | background-image: none; 106 | } 107 | .btn-primary { 108 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%); 109 | background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%); 110 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88)); 111 | background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%); 112 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0); 113 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 114 | background-repeat: repeat-x; 115 | border-color: #245580; 116 | } 117 | .btn-primary:hover, 118 | .btn-primary:focus { 119 | background-color: #265a88; 120 | background-position: 0 -15px; 121 | } 122 | .btn-primary:active, 123 | .btn-primary.active { 124 | background-color: #265a88; 125 | border-color: #245580; 126 | } 127 | .btn-primary.disabled, 128 | .btn-primary[disabled], 129 | fieldset[disabled] .btn-primary, 130 | .btn-primary.disabled:hover, 131 | .btn-primary[disabled]:hover, 132 | fieldset[disabled] .btn-primary:hover, 133 | .btn-primary.disabled:focus, 134 | .btn-primary[disabled]:focus, 135 | fieldset[disabled] .btn-primary:focus, 136 | .btn-primary.disabled.focus, 137 | .btn-primary[disabled].focus, 138 | fieldset[disabled] .btn-primary.focus, 139 | .btn-primary.disabled:active, 140 | .btn-primary[disabled]:active, 141 | fieldset[disabled] .btn-primary:active, 142 | .btn-primary.disabled.active, 143 | .btn-primary[disabled].active, 144 | fieldset[disabled] .btn-primary.active { 145 | background-color: #265a88; 146 | background-image: none; 147 | } 148 | .btn-success { 149 | background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%); 150 | background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%); 151 | background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641)); 152 | background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%); 153 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0); 154 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 155 | background-repeat: repeat-x; 156 | border-color: #3e8f3e; 157 | } 158 | .btn-success:hover, 159 | .btn-success:focus { 160 | background-color: #419641; 161 | background-position: 0 -15px; 162 | } 163 | .btn-success:active, 164 | .btn-success.active { 165 | background-color: #419641; 166 | border-color: #3e8f3e; 167 | } 168 | .btn-success.disabled, 169 | .btn-success[disabled], 170 | fieldset[disabled] .btn-success, 171 | .btn-success.disabled:hover, 172 | .btn-success[disabled]:hover, 173 | fieldset[disabled] .btn-success:hover, 174 | .btn-success.disabled:focus, 175 | .btn-success[disabled]:focus, 176 | fieldset[disabled] .btn-success:focus, 177 | .btn-success.disabled.focus, 178 | .btn-success[disabled].focus, 179 | fieldset[disabled] .btn-success.focus, 180 | .btn-success.disabled:active, 181 | .btn-success[disabled]:active, 182 | fieldset[disabled] .btn-success:active, 183 | .btn-success.disabled.active, 184 | .btn-success[disabled].active, 185 | fieldset[disabled] .btn-success.active { 186 | background-color: #419641; 187 | background-image: none; 188 | } 189 | .btn-info { 190 | background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); 191 | background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); 192 | background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2)); 193 | background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%); 194 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0); 195 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 196 | background-repeat: repeat-x; 197 | border-color: #28a4c9; 198 | } 199 | .btn-info:hover, 200 | .btn-info:focus { 201 | background-color: #2aabd2; 202 | background-position: 0 -15px; 203 | } 204 | .btn-info:active, 205 | .btn-info.active { 206 | background-color: #2aabd2; 207 | border-color: #28a4c9; 208 | } 209 | .btn-info.disabled, 210 | .btn-info[disabled], 211 | fieldset[disabled] .btn-info, 212 | .btn-info.disabled:hover, 213 | .btn-info[disabled]:hover, 214 | fieldset[disabled] .btn-info:hover, 215 | .btn-info.disabled:focus, 216 | .btn-info[disabled]:focus, 217 | fieldset[disabled] .btn-info:focus, 218 | .btn-info.disabled.focus, 219 | .btn-info[disabled].focus, 220 | fieldset[disabled] .btn-info.focus, 221 | .btn-info.disabled:active, 222 | .btn-info[disabled]:active, 223 | fieldset[disabled] .btn-info:active, 224 | .btn-info.disabled.active, 225 | .btn-info[disabled].active, 226 | fieldset[disabled] .btn-info.active { 227 | background-color: #2aabd2; 228 | background-image: none; 229 | } 230 | .btn-warning { 231 | background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); 232 | background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); 233 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316)); 234 | background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%); 235 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0); 236 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 237 | background-repeat: repeat-x; 238 | border-color: #e38d13; 239 | } 240 | .btn-warning:hover, 241 | .btn-warning:focus { 242 | background-color: #eb9316; 243 | background-position: 0 -15px; 244 | } 245 | .btn-warning:active, 246 | .btn-warning.active { 247 | background-color: #eb9316; 248 | border-color: #e38d13; 249 | } 250 | .btn-warning.disabled, 251 | .btn-warning[disabled], 252 | fieldset[disabled] .btn-warning, 253 | .btn-warning.disabled:hover, 254 | .btn-warning[disabled]:hover, 255 | fieldset[disabled] .btn-warning:hover, 256 | .btn-warning.disabled:focus, 257 | .btn-warning[disabled]:focus, 258 | fieldset[disabled] .btn-warning:focus, 259 | .btn-warning.disabled.focus, 260 | .btn-warning[disabled].focus, 261 | fieldset[disabled] .btn-warning.focus, 262 | .btn-warning.disabled:active, 263 | .btn-warning[disabled]:active, 264 | fieldset[disabled] .btn-warning:active, 265 | .btn-warning.disabled.active, 266 | .btn-warning[disabled].active, 267 | fieldset[disabled] .btn-warning.active { 268 | background-color: #eb9316; 269 | background-image: none; 270 | } 271 | .btn-danger { 272 | background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%); 273 | background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%); 274 | background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a)); 275 | background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%); 276 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0); 277 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 278 | background-repeat: repeat-x; 279 | border-color: #b92c28; 280 | } 281 | .btn-danger:hover, 282 | .btn-danger:focus { 283 | background-color: #c12e2a; 284 | background-position: 0 -15px; 285 | } 286 | .btn-danger:active, 287 | .btn-danger.active { 288 | background-color: #c12e2a; 289 | border-color: #b92c28; 290 | } 291 | .btn-danger.disabled, 292 | .btn-danger[disabled], 293 | fieldset[disabled] .btn-danger, 294 | .btn-danger.disabled:hover, 295 | .btn-danger[disabled]:hover, 296 | fieldset[disabled] .btn-danger:hover, 297 | .btn-danger.disabled:focus, 298 | .btn-danger[disabled]:focus, 299 | fieldset[disabled] .btn-danger:focus, 300 | .btn-danger.disabled.focus, 301 | .btn-danger[disabled].focus, 302 | fieldset[disabled] .btn-danger.focus, 303 | .btn-danger.disabled:active, 304 | .btn-danger[disabled]:active, 305 | fieldset[disabled] .btn-danger:active, 306 | .btn-danger.disabled.active, 307 | .btn-danger[disabled].active, 308 | fieldset[disabled] .btn-danger.active { 309 | background-color: #c12e2a; 310 | background-image: none; 311 | } 312 | .thumbnail, 313 | .img-thumbnail { 314 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 315 | box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 316 | } 317 | .dropdown-menu > li > a:hover, 318 | .dropdown-menu > li > a:focus { 319 | background-color: #e8e8e8; 320 | background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 321 | background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 322 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); 323 | background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); 324 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); 325 | background-repeat: repeat-x; 326 | } 327 | .dropdown-menu > .active > a, 328 | .dropdown-menu > .active > a:hover, 329 | .dropdown-menu > .active > a:focus { 330 | background-color: #2e6da4; 331 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 332 | background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 333 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); 334 | background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); 335 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); 336 | background-repeat: repeat-x; 337 | } 338 | .navbar-default { 339 | background-image: -webkit-linear-gradient(top, #fff 0%, #f8f8f8 100%); 340 | background-image: -o-linear-gradient(top, #fff 0%, #f8f8f8 100%); 341 | background-image: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#f8f8f8)); 342 | background-image: linear-gradient(to bottom, #fff 0%, #f8f8f8 100%); 343 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0); 344 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 345 | background-repeat: repeat-x; 346 | border-radius: 4px; 347 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); 348 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), 0 1px 5px rgba(0, 0, 0, .075); 349 | } 350 | .navbar-default .navbar-nav > .open > a, 351 | .navbar-default .navbar-nav > .active > a { 352 | background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); 353 | background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); 354 | background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2)); 355 | background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%); 356 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0); 357 | background-repeat: repeat-x; 358 | -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); 359 | box-shadow: inset 0 3px 9px rgba(0, 0, 0, .075); 360 | } 361 | .navbar-brand, 362 | .navbar-nav > li > a { 363 | text-shadow: 0 1px 0 rgba(255, 255, 255, .25); 364 | } 365 | .navbar-inverse { 366 | background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222 100%); 367 | background-image: -o-linear-gradient(top, #3c3c3c 0%, #222 100%); 368 | background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222)); 369 | background-image: linear-gradient(to bottom, #3c3c3c 0%, #222 100%); 370 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0); 371 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 372 | background-repeat: repeat-x; 373 | border-radius: 4px; 374 | } 375 | .navbar-inverse .navbar-nav > .open > a, 376 | .navbar-inverse .navbar-nav > .active > a { 377 | background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%); 378 | background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%); 379 | background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f)); 380 | background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%); 381 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0); 382 | background-repeat: repeat-x; 383 | -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); 384 | box-shadow: inset 0 3px 9px rgba(0, 0, 0, .25); 385 | } 386 | .navbar-inverse .navbar-brand, 387 | .navbar-inverse .navbar-nav > li > a { 388 | text-shadow: 0 -1px 0 rgba(0, 0, 0, .25); 389 | } 390 | .navbar-static-top, 391 | .navbar-fixed-top, 392 | .navbar-fixed-bottom { 393 | border-radius: 0; 394 | } 395 | @media (max-width: 767px) { 396 | .navbar .navbar-nav .open .dropdown-menu > .active > a, 397 | .navbar .navbar-nav .open .dropdown-menu > .active > a:hover, 398 | .navbar .navbar-nav .open .dropdown-menu > .active > a:focus { 399 | color: #fff; 400 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 401 | background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 402 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); 403 | background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); 404 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); 405 | background-repeat: repeat-x; 406 | } 407 | } 408 | .alert { 409 | text-shadow: 0 1px 0 rgba(255, 255, 255, .2); 410 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); 411 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 1px 2px rgba(0, 0, 0, .05); 412 | } 413 | .alert-success { 414 | background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); 415 | background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); 416 | background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc)); 417 | background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%); 418 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0); 419 | background-repeat: repeat-x; 420 | border-color: #b2dba1; 421 | } 422 | .alert-info { 423 | background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%); 424 | background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%); 425 | background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0)); 426 | background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%); 427 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0); 428 | background-repeat: repeat-x; 429 | border-color: #9acfea; 430 | } 431 | .alert-warning { 432 | background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); 433 | background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); 434 | background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0)); 435 | background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%); 436 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0); 437 | background-repeat: repeat-x; 438 | border-color: #f5e79e; 439 | } 440 | .alert-danger { 441 | background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); 442 | background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); 443 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3)); 444 | background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%); 445 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0); 446 | background-repeat: repeat-x; 447 | border-color: #dca7a7; 448 | } 449 | .progress { 450 | background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); 451 | background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); 452 | background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5)); 453 | background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%); 454 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0); 455 | background-repeat: repeat-x; 456 | } 457 | .progress-bar { 458 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%); 459 | background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%); 460 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090)); 461 | background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%); 462 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0); 463 | background-repeat: repeat-x; 464 | } 465 | .progress-bar-success { 466 | background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%); 467 | background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%); 468 | background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44)); 469 | background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%); 470 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0); 471 | background-repeat: repeat-x; 472 | } 473 | .progress-bar-info { 474 | background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); 475 | background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); 476 | background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5)); 477 | background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%); 478 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0); 479 | background-repeat: repeat-x; 480 | } 481 | .progress-bar-warning { 482 | background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); 483 | background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); 484 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f)); 485 | background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%); 486 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0); 487 | background-repeat: repeat-x; 488 | } 489 | .progress-bar-danger { 490 | background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%); 491 | background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%); 492 | background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c)); 493 | background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%); 494 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0); 495 | background-repeat: repeat-x; 496 | } 497 | .progress-bar-striped { 498 | background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); 499 | background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); 500 | background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); 501 | } 502 | .list-group { 503 | border-radius: 4px; 504 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 505 | box-shadow: 0 1px 2px rgba(0, 0, 0, .075); 506 | } 507 | .list-group-item.active, 508 | .list-group-item.active:hover, 509 | .list-group-item.active:focus { 510 | text-shadow: 0 -1px 0 #286090; 511 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%); 512 | background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%); 513 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a)); 514 | background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%); 515 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0); 516 | background-repeat: repeat-x; 517 | border-color: #2b669a; 518 | } 519 | .list-group-item.active .badge, 520 | .list-group-item.active:hover .badge, 521 | .list-group-item.active:focus .badge { 522 | text-shadow: none; 523 | } 524 | .panel { 525 | -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, .05); 526 | box-shadow: 0 1px 2px rgba(0, 0, 0, .05); 527 | } 528 | .panel-default > .panel-heading { 529 | background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 530 | background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); 531 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); 532 | background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); 533 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); 534 | background-repeat: repeat-x; 535 | } 536 | .panel-primary > .panel-heading { 537 | background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 538 | background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); 539 | background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); 540 | background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); 541 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); 542 | background-repeat: repeat-x; 543 | } 544 | .panel-success > .panel-heading { 545 | background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); 546 | background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); 547 | background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6)); 548 | background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%); 549 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0); 550 | background-repeat: repeat-x; 551 | } 552 | .panel-info > .panel-heading { 553 | background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); 554 | background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); 555 | background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3)); 556 | background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%); 557 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0); 558 | background-repeat: repeat-x; 559 | } 560 | .panel-warning > .panel-heading { 561 | background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); 562 | background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); 563 | background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc)); 564 | background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%); 565 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0); 566 | background-repeat: repeat-x; 567 | } 568 | .panel-danger > .panel-heading { 569 | background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%); 570 | background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%); 571 | background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc)); 572 | background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%); 573 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0); 574 | background-repeat: repeat-x; 575 | } 576 | .well { 577 | background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); 578 | background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); 579 | background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5)); 580 | background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%); 581 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0); 582 | background-repeat: repeat-x; 583 | border-color: #dcdcdc; 584 | -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); 585 | box-shadow: inset 0 1px 3px rgba(0, 0, 0, .05), 0 1px 0 rgba(255, 255, 255, .1); 586 | } 587 | /*# sourceMappingURL=bootstrap-theme.css.map */ 588 | -------------------------------------------------------------------------------- /static/css/bootstrap-theme.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.7 (http://getbootstrap.com) 3 | * Copyright 2011-2016 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger.disabled,.btn-danger[disabled],.btn-default.disabled,.btn-default[disabled],.btn-info.disabled,.btn-info[disabled],.btn-primary.disabled,.btn-primary[disabled],.btn-success.disabled,.btn-success[disabled],.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-danger,fieldset[disabled] .btn-default,fieldset[disabled] .btn-info,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-warning{-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} 6 | /*# sourceMappingURL=bootstrap-theme.min.css.map */ -------------------------------------------------------------------------------- /static/css/bootstrap-theme.min.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["less/theme.less","less/mixins/vendor-prefixes.less","less/mixins/gradients.less","less/mixins/reset-filter.less"],"names":[],"mappings":";;;;AAmBA,YAAA,aAAA,UAAA,aAAA,aAAA,aAME,YAAA,EAAA,KAAA,EAAA,eC2CA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBDvCR,mBAAA,mBAAA,oBAAA,oBAAA,iBAAA,iBAAA,oBAAA,oBAAA,oBAAA,oBAAA,oBAAA,oBCsCA,mBAAA,MAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,iBDlCR,qBAAA,sBAAA,sBAAA,uBAAA,mBAAA,oBAAA,sBAAA,uBAAA,sBAAA,uBAAA,sBAAA,uBAAA,+BAAA,gCAAA,6BAAA,gCAAA,gCAAA,gCCiCA,mBAAA,KACQ,WAAA,KDlDV,mBAAA,oBAAA,iBAAA,oBAAA,oBAAA,oBAuBI,YAAA,KAyCF,YAAA,YAEE,iBAAA,KAKJ,aErEI,YAAA,EAAA,IAAA,EAAA,KACA,iBAAA,iDACA,iBAAA,4CAAA,iBAAA,qEAEA,iBAAA,+CCnBF,OAAA,+GH4CA,OAAA,0DACA,kBAAA,SAuC2C,aAAA,QAA2B,aAAA,KArCtE,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAgBN,aEtEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAiBN,aEvEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAkBN,UExEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,gBAAA,gBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,iBAAA,iBAEE,iBAAA,QACA,aAAA,QAMA,mBAAA,0BAAA,yBAAA,0BAAA,yBAAA,yBAAA,oBAAA,2BAAA,0BAAA,2BAAA,0BAAA,0BAAA,6BAAA,oCAAA,mCAAA,oCAAA,mCAAA,mCAME,iBAAA,QACA,iBAAA,KAmBN,aEzEI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,mBAAA,mBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,oBAAA,oBAEE,iBAAA,QACA,aAAA,QAMA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,uBAAA,8BAAA,6BAAA,8BAAA,6BAAA,6BAAA,gCAAA,uCAAA,sCAAA,uCAAA,sCAAA,sCAME,iBAAA,QACA,iBAAA,KAoBN,YE1EI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDAEA,OAAA,+GCnBF,OAAA,0DH4CA,kBAAA,SACA,aAAA,QAEA,kBAAA,kBAEE,iBAAA,QACA,oBAAA,EAAA,MAGF,mBAAA,mBAEE,iBAAA,QACA,aAAA,QAMA,qBAAA,4BAAA,2BAAA,4BAAA,2BAAA,2BAAA,sBAAA,6BAAA,4BAAA,6BAAA,4BAAA,4BAAA,+BAAA,sCAAA,qCAAA,sCAAA,qCAAA,qCAME,iBAAA,QACA,iBAAA,KA2BN,eAAA,WClCE,mBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,EAAA,IAAA,IAAA,iBD2CV,0BAAA,0BE3FI,iBAAA,QACA,iBAAA,oDACA,iBAAA,+CAAA,iBAAA,wEACA,iBAAA,kDACA,OAAA,+GF0FF,kBAAA,SAEF,yBAAA,+BAAA,+BEhGI,iBAAA,QACA,iBAAA,oDACA,iBAAA,+CAAA,iBAAA,wEACA,iBAAA,kDACA,OAAA,+GFgGF,kBAAA,SASF,gBE7GI,iBAAA,iDACA,iBAAA,4CACA,iBAAA,qEAAA,iBAAA,+CACA,OAAA,+GACA,OAAA,0DCnBF,kBAAA,SH+HA,cAAA,ICjEA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,iBD6DV,sCAAA,oCE7GI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SD2CF,mBAAA,MAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,iBD0EV,cAAA,iBAEE,YAAA,EAAA,IAAA,EAAA,sBAIF,gBEhII,iBAAA,iDACA,iBAAA,4CACA,iBAAA,qEAAA,iBAAA,+CACA,OAAA,+GACA,OAAA,0DCnBF,kBAAA,SHkJA,cAAA,IAHF,sCAAA,oCEhII,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SD2CF,mBAAA,MAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,gBDgFV,8BAAA,iCAYI,YAAA,EAAA,KAAA,EAAA,gBAKJ,qBAAA,kBAAA,mBAGE,cAAA,EAqBF,yBAfI,mDAAA,yDAAA,yDAGE,MAAA,KE7JF,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,UFqKJ,OACE,YAAA,EAAA,IAAA,EAAA,qBC3HA,mBAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,MAAA,EAAA,IAAA,EAAA,sBAAA,EAAA,IAAA,IAAA,gBDsIV,eEtLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAKF,YEvLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAMF,eExLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAOF,cEzLI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF8KF,aAAA,QAeF,UEjMI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFuMJ,cE3MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFwMJ,sBE5MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFyMJ,mBE7MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF0MJ,sBE9MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF2MJ,qBE/MI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF+MJ,sBElLI,iBAAA,yKACA,iBAAA,oKACA,iBAAA,iKFyLJ,YACE,cAAA,IC9KA,mBAAA,EAAA,IAAA,IAAA,iBACQ,WAAA,EAAA,IAAA,IAAA,iBDgLV,wBAAA,8BAAA,8BAGE,YAAA,EAAA,KAAA,EAAA,QEnOE,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFiOF,aAAA,QALF,+BAAA,qCAAA,qCAQI,YAAA,KAUJ,OCnME,mBAAA,EAAA,IAAA,IAAA,gBACQ,WAAA,EAAA,IAAA,IAAA,gBD4MV,8BE5PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFyPJ,8BE7PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF0PJ,8BE9PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF2PJ,2BE/PI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF4PJ,8BEhQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SF6PJ,6BEjQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFoQJ,MExQI,iBAAA,oDACA,iBAAA,+CACA,iBAAA,wEAAA,iBAAA,kDACA,OAAA,+GACA,kBAAA,SFsQF,aAAA,QC3NA,mBAAA,MAAA,EAAA,IAAA,IAAA,gBAAA,EAAA,IAAA,EAAA,qBACQ,WAAA,MAAA,EAAA,IAAA,IAAA,gBAAA,EAAA,IAAA,EAAA","sourcesContent":["/*!\n * Bootstrap v3.3.7 (http://getbootstrap.com)\n * Copyright 2011-2016 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)\n */\n\n//\n// Load core variables and mixins\n// --------------------------------------------------\n\n@import \"variables.less\";\n@import \"mixins.less\";\n\n\n//\n// Buttons\n// --------------------------------------------------\n\n// Common styles\n.btn-default,\n.btn-primary,\n.btn-success,\n.btn-info,\n.btn-warning,\n.btn-danger {\n text-shadow: 0 -1px 0 rgba(0,0,0,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n // Reset the shadow\n &:active,\n &.active {\n .box-shadow(inset 0 3px 5px rgba(0,0,0,.125));\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n .box-shadow(none);\n }\n\n .badge {\n text-shadow: none;\n }\n}\n\n// Mixin for generating new styles\n.btn-styles(@btn-color: #555) {\n #gradient > .vertical(@start-color: @btn-color; @end-color: darken(@btn-color, 12%));\n .reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners; see https://github.com/twbs/bootstrap/issues/10620\n background-repeat: repeat-x;\n border-color: darken(@btn-color, 14%);\n\n &:hover,\n &:focus {\n background-color: darken(@btn-color, 12%);\n background-position: 0 -15px;\n }\n\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n border-color: darken(@btn-color, 14%);\n }\n\n &.disabled,\n &[disabled],\n fieldset[disabled] & {\n &,\n &:hover,\n &:focus,\n &.focus,\n &:active,\n &.active {\n background-color: darken(@btn-color, 12%);\n background-image: none;\n }\n }\n}\n\n// Common styles\n.btn {\n // Remove the gradient for the pressed/active state\n &:active,\n &.active {\n background-image: none;\n }\n}\n\n// Apply the mixin to the buttons\n.btn-default { .btn-styles(@btn-default-bg); text-shadow: 0 1px 0 #fff; border-color: #ccc; }\n.btn-primary { .btn-styles(@btn-primary-bg); }\n.btn-success { .btn-styles(@btn-success-bg); }\n.btn-info { .btn-styles(@btn-info-bg); }\n.btn-warning { .btn-styles(@btn-warning-bg); }\n.btn-danger { .btn-styles(@btn-danger-bg); }\n\n\n//\n// Images\n// --------------------------------------------------\n\n.thumbnail,\n.img-thumbnail {\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n\n\n//\n// Dropdowns\n// --------------------------------------------------\n\n.dropdown-menu > li > a:hover,\n.dropdown-menu > li > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-hover-bg; @end-color: darken(@dropdown-link-hover-bg, 5%));\n background-color: darken(@dropdown-link-hover-bg, 5%);\n}\n.dropdown-menu > .active > a,\n.dropdown-menu > .active > a:hover,\n.dropdown-menu > .active > a:focus {\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n background-color: darken(@dropdown-link-active-bg, 5%);\n}\n\n\n//\n// Navbar\n// --------------------------------------------------\n\n// Default navbar\n.navbar-default {\n #gradient > .vertical(@start-color: lighten(@navbar-default-bg, 10%); @end-color: @navbar-default-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered\n border-radius: @navbar-border-radius;\n @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 5px rgba(0,0,0,.075);\n .box-shadow(@shadow);\n\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: darken(@navbar-default-link-active-bg, 5%); @end-color: darken(@navbar-default-link-active-bg, 2%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.075));\n }\n}\n.navbar-brand,\n.navbar-nav > li > a {\n text-shadow: 0 1px 0 rgba(255,255,255,.25);\n}\n\n// Inverted navbar\n.navbar-inverse {\n #gradient > .vertical(@start-color: lighten(@navbar-inverse-bg, 10%); @end-color: @navbar-inverse-bg);\n .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered; see https://github.com/twbs/bootstrap/issues/10257\n border-radius: @navbar-border-radius;\n .navbar-nav > .open > a,\n .navbar-nav > .active > a {\n #gradient > .vertical(@start-color: @navbar-inverse-link-active-bg; @end-color: lighten(@navbar-inverse-link-active-bg, 2.5%));\n .box-shadow(inset 0 3px 9px rgba(0,0,0,.25));\n }\n\n .navbar-brand,\n .navbar-nav > li > a {\n text-shadow: 0 -1px 0 rgba(0,0,0,.25);\n }\n}\n\n// Undo rounded corners in static and fixed navbars\n.navbar-static-top,\n.navbar-fixed-top,\n.navbar-fixed-bottom {\n border-radius: 0;\n}\n\n// Fix active state of dropdown items in collapsed mode\n@media (max-width: @grid-float-breakpoint-max) {\n .navbar .navbar-nav .open .dropdown-menu > .active > a {\n &,\n &:hover,\n &:focus {\n color: #fff;\n #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%));\n }\n }\n}\n\n\n//\n// Alerts\n// --------------------------------------------------\n\n// Common styles\n.alert {\n text-shadow: 0 1px 0 rgba(255,255,255,.2);\n @shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 2px rgba(0,0,0,.05);\n .box-shadow(@shadow);\n}\n\n// Mixin for generating new styles\n.alert-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 7.5%));\n border-color: darken(@color, 15%);\n}\n\n// Apply the mixin to the alerts\n.alert-success { .alert-styles(@alert-success-bg); }\n.alert-info { .alert-styles(@alert-info-bg); }\n.alert-warning { .alert-styles(@alert-warning-bg); }\n.alert-danger { .alert-styles(@alert-danger-bg); }\n\n\n//\n// Progress bars\n// --------------------------------------------------\n\n// Give the progress background some depth\n.progress {\n #gradient > .vertical(@start-color: darken(@progress-bg, 4%); @end-color: @progress-bg)\n}\n\n// Mixin for generating new styles\n.progress-bar-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 10%));\n}\n\n// Apply the mixin to the progress bars\n.progress-bar { .progress-bar-styles(@progress-bar-bg); }\n.progress-bar-success { .progress-bar-styles(@progress-bar-success-bg); }\n.progress-bar-info { .progress-bar-styles(@progress-bar-info-bg); }\n.progress-bar-warning { .progress-bar-styles(@progress-bar-warning-bg); }\n.progress-bar-danger { .progress-bar-styles(@progress-bar-danger-bg); }\n\n// Reset the striped class because our mixins don't do multiple gradients and\n// the above custom styles override the new `.progress-bar-striped` in v3.2.0.\n.progress-bar-striped {\n #gradient > .striped();\n}\n\n\n//\n// List groups\n// --------------------------------------------------\n\n.list-group {\n border-radius: @border-radius-base;\n .box-shadow(0 1px 2px rgba(0,0,0,.075));\n}\n.list-group-item.active,\n.list-group-item.active:hover,\n.list-group-item.active:focus {\n text-shadow: 0 -1px 0 darken(@list-group-active-bg, 10%);\n #gradient > .vertical(@start-color: @list-group-active-bg; @end-color: darken(@list-group-active-bg, 7.5%));\n border-color: darken(@list-group-active-border, 7.5%);\n\n .badge {\n text-shadow: none;\n }\n}\n\n\n//\n// Panels\n// --------------------------------------------------\n\n// Common styles\n.panel {\n .box-shadow(0 1px 2px rgba(0,0,0,.05));\n}\n\n// Mixin for generating new styles\n.panel-heading-styles(@color) {\n #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 5%));\n}\n\n// Apply the mixin to the panel headings only\n.panel-default > .panel-heading { .panel-heading-styles(@panel-default-heading-bg); }\n.panel-primary > .panel-heading { .panel-heading-styles(@panel-primary-heading-bg); }\n.panel-success > .panel-heading { .panel-heading-styles(@panel-success-heading-bg); }\n.panel-info > .panel-heading { .panel-heading-styles(@panel-info-heading-bg); }\n.panel-warning > .panel-heading { .panel-heading-styles(@panel-warning-heading-bg); }\n.panel-danger > .panel-heading { .panel-heading-styles(@panel-danger-heading-bg); }\n\n\n//\n// Wells\n// --------------------------------------------------\n\n.well {\n #gradient > .vertical(@start-color: darken(@well-bg, 5%); @end-color: @well-bg);\n border-color: darken(@well-bg, 10%);\n @shadow: inset 0 1px 3px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1);\n .box-shadow(@shadow);\n}\n","// Vendor Prefixes\n//\n// All vendor mixins are deprecated as of v3.2.0 due to the introduction of\n// Autoprefixer in our Gruntfile. They have been removed in v4.\n\n// - Animations\n// - Backface visibility\n// - Box shadow\n// - Box sizing\n// - Content columns\n// - Hyphens\n// - Placeholder text\n// - Transformations\n// - Transitions\n// - User Select\n\n\n// Animations\n.animation(@animation) {\n -webkit-animation: @animation;\n -o-animation: @animation;\n animation: @animation;\n}\n.animation-name(@name) {\n -webkit-animation-name: @name;\n animation-name: @name;\n}\n.animation-duration(@duration) {\n -webkit-animation-duration: @duration;\n animation-duration: @duration;\n}\n.animation-timing-function(@timing-function) {\n -webkit-animation-timing-function: @timing-function;\n animation-timing-function: @timing-function;\n}\n.animation-delay(@delay) {\n -webkit-animation-delay: @delay;\n animation-delay: @delay;\n}\n.animation-iteration-count(@iteration-count) {\n -webkit-animation-iteration-count: @iteration-count;\n animation-iteration-count: @iteration-count;\n}\n.animation-direction(@direction) {\n -webkit-animation-direction: @direction;\n animation-direction: @direction;\n}\n.animation-fill-mode(@fill-mode) {\n -webkit-animation-fill-mode: @fill-mode;\n animation-fill-mode: @fill-mode;\n}\n\n// Backface visibility\n// Prevent browsers from flickering when using CSS 3D transforms.\n// Default value is `visible`, but can be changed to `hidden`\n\n.backface-visibility(@visibility) {\n -webkit-backface-visibility: @visibility;\n -moz-backface-visibility: @visibility;\n backface-visibility: @visibility;\n}\n\n// Drop shadows\n//\n// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's\n// supported browsers that have box shadow capabilities now support it.\n\n.box-shadow(@shadow) {\n -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1\n box-shadow: @shadow;\n}\n\n// Box sizing\n.box-sizing(@boxmodel) {\n -webkit-box-sizing: @boxmodel;\n -moz-box-sizing: @boxmodel;\n box-sizing: @boxmodel;\n}\n\n// CSS3 Content Columns\n.content-columns(@column-count; @column-gap: @grid-gutter-width) {\n -webkit-column-count: @column-count;\n -moz-column-count: @column-count;\n column-count: @column-count;\n -webkit-column-gap: @column-gap;\n -moz-column-gap: @column-gap;\n column-gap: @column-gap;\n}\n\n// Optional hyphenation\n.hyphens(@mode: auto) {\n word-wrap: break-word;\n -webkit-hyphens: @mode;\n -moz-hyphens: @mode;\n -ms-hyphens: @mode; // IE10+\n -o-hyphens: @mode;\n hyphens: @mode;\n}\n\n// Placeholder text\n.placeholder(@color: @input-color-placeholder) {\n // Firefox\n &::-moz-placeholder {\n color: @color;\n opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526\n }\n &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+\n &::-webkit-input-placeholder { color: @color; } // Safari and Chrome\n}\n\n// Transformations\n.scale(@ratio) {\n -webkit-transform: scale(@ratio);\n -ms-transform: scale(@ratio); // IE9 only\n -o-transform: scale(@ratio);\n transform: scale(@ratio);\n}\n.scale(@ratioX; @ratioY) {\n -webkit-transform: scale(@ratioX, @ratioY);\n -ms-transform: scale(@ratioX, @ratioY); // IE9 only\n -o-transform: scale(@ratioX, @ratioY);\n transform: scale(@ratioX, @ratioY);\n}\n.scaleX(@ratio) {\n -webkit-transform: scaleX(@ratio);\n -ms-transform: scaleX(@ratio); // IE9 only\n -o-transform: scaleX(@ratio);\n transform: scaleX(@ratio);\n}\n.scaleY(@ratio) {\n -webkit-transform: scaleY(@ratio);\n -ms-transform: scaleY(@ratio); // IE9 only\n -o-transform: scaleY(@ratio);\n transform: scaleY(@ratio);\n}\n.skew(@x; @y) {\n -webkit-transform: skewX(@x) skewY(@y);\n -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+\n -o-transform: skewX(@x) skewY(@y);\n transform: skewX(@x) skewY(@y);\n}\n.translate(@x; @y) {\n -webkit-transform: translate(@x, @y);\n -ms-transform: translate(@x, @y); // IE9 only\n -o-transform: translate(@x, @y);\n transform: translate(@x, @y);\n}\n.translate3d(@x; @y; @z) {\n -webkit-transform: translate3d(@x, @y, @z);\n transform: translate3d(@x, @y, @z);\n}\n.rotate(@degrees) {\n -webkit-transform: rotate(@degrees);\n -ms-transform: rotate(@degrees); // IE9 only\n -o-transform: rotate(@degrees);\n transform: rotate(@degrees);\n}\n.rotateX(@degrees) {\n -webkit-transform: rotateX(@degrees);\n -ms-transform: rotateX(@degrees); // IE9 only\n -o-transform: rotateX(@degrees);\n transform: rotateX(@degrees);\n}\n.rotateY(@degrees) {\n -webkit-transform: rotateY(@degrees);\n -ms-transform: rotateY(@degrees); // IE9 only\n -o-transform: rotateY(@degrees);\n transform: rotateY(@degrees);\n}\n.perspective(@perspective) {\n -webkit-perspective: @perspective;\n -moz-perspective: @perspective;\n perspective: @perspective;\n}\n.perspective-origin(@perspective) {\n -webkit-perspective-origin: @perspective;\n -moz-perspective-origin: @perspective;\n perspective-origin: @perspective;\n}\n.transform-origin(@origin) {\n -webkit-transform-origin: @origin;\n -moz-transform-origin: @origin;\n -ms-transform-origin: @origin; // IE9 only\n transform-origin: @origin;\n}\n\n\n// Transitions\n\n.transition(@transition) {\n -webkit-transition: @transition;\n -o-transition: @transition;\n transition: @transition;\n}\n.transition-property(@transition-property) {\n -webkit-transition-property: @transition-property;\n transition-property: @transition-property;\n}\n.transition-delay(@transition-delay) {\n -webkit-transition-delay: @transition-delay;\n transition-delay: @transition-delay;\n}\n.transition-duration(@transition-duration) {\n -webkit-transition-duration: @transition-duration;\n transition-duration: @transition-duration;\n}\n.transition-timing-function(@timing-function) {\n -webkit-transition-timing-function: @timing-function;\n transition-timing-function: @timing-function;\n}\n.transition-transform(@transition) {\n -webkit-transition: -webkit-transform @transition;\n -moz-transition: -moz-transform @transition;\n -o-transition: -o-transform @transition;\n transition: transform @transition;\n}\n\n\n// User select\n// For selecting text on the page\n\n.user-select(@select) {\n -webkit-user-select: @select;\n -moz-user-select: @select;\n -ms-user-select: @select; // IE10+\n user-select: @select;\n}\n","// Gradients\n\n#gradient {\n\n // Horizontal gradient, from left to right\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n // Vertical gradient, from top to bottom\n //\n // Creates two color stops, start and end, by specifying a color and position for each color stop.\n // Color stops are not available in IE9 and below.\n .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) {\n background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12\n background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n background-repeat: repeat-x;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down\n }\n\n .directional(@start-color: #555; @end-color: #333; @deg: 45deg) {\n background-repeat: repeat-x;\n background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+\n background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12\n background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+\n }\n .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) {\n background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color);\n background-repeat: no-repeat;\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)\",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback\n }\n .radial(@inner-color: #555; @outer-color: #333) {\n background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color);\n background-image: radial-gradient(circle, @inner-color, @outer-color);\n background-repeat: no-repeat;\n }\n .striped(@color: rgba(255,255,255,.15); @angle: 45deg) {\n background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent);\n }\n}\n","// Reset filters for IE\n//\n// When you need to remove a gradient background, do not forget to use this to reset\n// the IE filter for IE9 and below.\n\n.reset-filter() {\n filter: e(%(\"progid:DXImageTransform.Microsoft.gradient(enabled = false)\"));\n}\n"]} -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesog/scan/050d36cff97af8d07183b32ca77dd962bd1f8c3f/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesog/scan/050d36cff97af8d07183b32ca77dd962bd1f8c3f/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesog/scan/050d36cff97af8d07183b32ca77dd962bd1f8c3f/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesog/scan/050d36cff97af8d07183b32ca77dd962bd1f8c3f/static/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /static/images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesog/scan/050d36cff97af8d07183b32ca77dd962bd1f8c3f/static/images/search.png -------------------------------------------------------------------------------- /static/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.3.7 (http://getbootstrap.com) 3 | * Copyright 2011-2016 Twitter, Inc. 4 | * Licensed under the MIT license 5 | */ 6 | if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1||b[0]>3)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){if(a(b.target).is(this))return b.handleObj.handler.apply(this,arguments)}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.7",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a("#"===f?[]:f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.7",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c).prop(c,!0)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c).prop(c,!1))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")?(c.prop("checked")&&(a=!1),b.find(".active").removeClass("active"),this.$element.addClass("active")):"checkbox"==c.prop("type")&&(c.prop("checked")!==this.$element.hasClass("active")&&(a=!1),this.$element.toggleClass("active")),c.prop("checked",this.$element.hasClass("active")),a&&c.trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active")),this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target).closest(".btn");b.call(d,"toggle"),a(c.target).is('input[type="radio"], input[type="checkbox"]')||(c.preventDefault(),d.is("input,button")?d.trigger("focus"):d.find("input:visible,button:visible").first().trigger("focus"))}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus",/^focus(in)?$/.test(b.type))})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=null,this.sliding=null,this.interval=null,this.$active=null,this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.7",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){if(!/input|textarea/i.test(a.target.tagName)){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()}},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c=this.getItemIndex(b),d="prev"==a&&0===c||"next"==a&&c==this.$items.length-1;if(d&&!this.options.wrap)return b;var e="prev"==a?-1:1,f=(c+e)%this.$items.length;return this.$items.eq(f)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));if(!(a>this.$items.length-1||a<0))return this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){if(!this.sliding)return this.slide("next")},c.prototype.prev=function(){if(!this.sliding)return this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i=this;if(f.hasClass("active"))return this.sliding=!1;var j=f[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:h});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(f)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(m)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&/show|hide/.test(b)&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a('[data-toggle="collapse"][href="#'+b.id+'"],[data-toggle="collapse"][data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.7",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.children(".panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":e.data();c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function c(c){c&&3===c.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=b(d),f={relatedTarget:this};e.hasClass("open")&&(c&&"click"==c.type&&/input|textarea/i.test(c.target.tagName)&&a.contains(e[0],c.target)||(e.trigger(c=a.Event("hide.bs.dropdown",f)),c.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger(a.Event("hidden.bs.dropdown",f)))))}))}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.7",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=b(e),g=f.hasClass("open");if(c(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(document.createElement("div")).addClass("dropdown-backdrop").insertAfter(a(this)).on("click",c);var h={relatedTarget:this};if(f.trigger(d=a.Event("show.bs.dropdown",h)),d.isDefaultPrevented())return;e.trigger("focus").attr("aria-expanded","true"),f.toggleClass("open").trigger(a.Event("shown.bs.dropdown",h))}return!1}},g.prototype.keydown=function(c){if(/(38|40|27|32)/.test(c.which)&&!/input|textarea/i.test(c.target.tagName)){var d=a(this);if(c.preventDefault(),c.stopPropagation(),!d.is(".disabled, :disabled")){var e=b(d),g=e.hasClass("open");if(!g&&27!=c.which||g&&27==c.which)return 27==c.which&&e.find(f).trigger("focus"),d.trigger("click");var h=" li:not(.disabled):visible a",i=e.find(".dropdown-menu"+h);if(i.length){var j=i.index(c.target);38==c.which&&j>0&&j--,40==c.which&&jdocument.documentElement.clientHeight;this.$element.css({paddingLeft:!this.bodyIsOverflowing&&a?this.scrollbarWidth:"",paddingRight:this.bodyIsOverflowing&&!a?this.scrollbarWidth:""})},c.prototype.resetAdjustments=function(){this.$element.css({paddingLeft:"",paddingRight:""})},c.prototype.checkScrollbar=function(){var a=window.innerWidth;if(!a){var b=document.documentElement.getBoundingClientRect();a=b.right-Math.abs(b.left)}this.bodyIsOverflowing=document.body.clientWidth
',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},c.prototype.init=function(b,c,d){if(this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d),this.$viewport=this.options.viewport&&a(a.isFunction(this.options.viewport)?this.options.viewport.call(this,this.$element):this.options.viewport.selector||this.options.viewport),this.inState={click:!1,hover:!1,focus:!1},this.$element[0]instanceof document.constructor&&!this.options.selector)throw new Error("`selector` option must be specified when initializing "+this.type+" on the window.document object!");for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focusin",i="hover"==g?"mouseleave":"focusout";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},c.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},c.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusin"==b.type?"focus":"hover"]=!0),c.tip().hasClass("in")||"in"==c.hoverState?void(c.hoverState="in"):(clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?void(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show)):c.show())},c.prototype.isInStateTrue=function(){for(var a in this.inState)if(this.inState[a])return!0;return!1},c.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);if(c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusout"==b.type?"focus":"hover"]=!1),!c.isInStateTrue())return clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?void(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide)):c.hide()},c.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(b);var d=a.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(b.isDefaultPrevented()||!d)return;var e=this,f=this.tip(),g=this.getUID(this.type);this.setContent(),f.attr("id",g),this.$element.attr("aria-describedby",g),this.options.animation&&f.addClass("fade");var h="function"==typeof this.options.placement?this.options.placement.call(this,f[0],this.$element[0]):this.options.placement,i=/\s?auto?\s?/i,j=i.test(h);j&&(h=h.replace(i,"")||"top"),f.detach().css({top:0,left:0,display:"block"}).addClass(h).data("bs."+this.type,this),this.options.container?f.appendTo(this.options.container):f.insertAfter(this.$element),this.$element.trigger("inserted.bs."+this.type);var k=this.getPosition(),l=f[0].offsetWidth,m=f[0].offsetHeight;if(j){var n=h,o=this.getPosition(this.$viewport);h="bottom"==h&&k.bottom+m>o.bottom?"top":"top"==h&&k.top-mo.width?"left":"left"==h&&k.left-lg.top+g.height&&(e.top=g.top+g.height-i)}else{var j=b.left-f,k=b.left+f+c;jg.right&&(e.left=g.left+g.width-k)}return e},c.prototype.getTitle=function(){var a,b=this.$element,c=this.options;return a=b.attr("data-original-title")||("function"==typeof c.title?c.title.call(b[0]):c.title)},c.prototype.getUID=function(a){do a+=~~(1e6*Math.random());while(document.getElementById(a));return a},c.prototype.tip=function(){if(!this.$tip&&(this.$tip=a(this.options.template),1!=this.$tip.length))throw new Error(this.type+" `template` option must consist of exactly 1 top-level element!");return this.$tip},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},c.prototype.enable=function(){this.enabled=!0},c.prototype.disable=function(){this.enabled=!1},c.prototype.toggleEnabled=function(){this.enabled=!this.enabled},c.prototype.toggle=function(b){var c=this;b&&(c=a(b.currentTarget).data("bs."+this.type),c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c))),b?(c.inState.click=!c.inState.click,c.isInStateTrue()?c.enter(c):c.leave(c)):c.tip().hasClass("in")?c.leave(c):c.enter(c)},c.prototype.destroy=function(){var a=this;clearTimeout(this.timeout),this.hide(function(){a.$element.off("."+a.type).removeData("bs."+a.type),a.$tip&&a.$tip.detach(),a.$tip=null,a.$arrow=null,a.$viewport=null,a.$element=null})};var d=a.fn.tooltip;a.fn.tooltip=b,a.fn.tooltip.Constructor=c,a.fn.tooltip.noConflict=function(){return a.fn.tooltip=d,this}}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof b&&b;!e&&/destroy|hide/.test(b)||(e||d.data("bs.popover",e=new c(this,f)),"string"==typeof b&&e[b]())})}var c=function(a,b){this.init("popover",a,b)};if(!a.fn.tooltip)throw new Error("Popover requires tooltip.js");c.VERSION="3.3.7",c.DEFAULTS=a.extend({},a.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),c.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),c.prototype.constructor=c,c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content").children().detach().end()[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},c.prototype.hasContent=function(){return this.getTitle()||this.getContent()},c.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")};var d=a.fn.popover;a.fn.popover=b,a.fn.popover.Constructor=c,a.fn.popover.noConflict=function(){return a.fn.popover=d,this}}(jQuery),+function(a){"use strict";function b(c,d){this.$body=a(document.body),this.$scrollElement=a(a(c).is(document.body)?window:c),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||"")+" .nav li > a",this.offsets=[],this.targets=[],this.activeTarget=null,this.scrollHeight=0,this.$scrollElement.on("scroll.bs.scrollspy",a.proxy(this.process,this)),this.refresh(),this.process()}function c(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})}b.VERSION="3.3.7",b.DEFAULTS={offset:10},b.prototype.getScrollHeight=function(){return this.$scrollElement[0].scrollHeight||Math.max(this.$body[0].scrollHeight,document.documentElement.scrollHeight)},b.prototype.refresh=function(){var b=this,c="offset",d=0;this.offsets=[],this.targets=[],this.scrollHeight=this.getScrollHeight(),a.isWindow(this.$scrollElement[0])||(c="position",d=this.$scrollElement.scrollTop()),this.$body.find(this.selector).map(function(){var b=a(this),e=b.data("target")||b.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[c]().top+d,e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){b.offsets.push(this[0]),b.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.getScrollHeight(),d=this.options.offset+c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(this.scrollHeight!=c&&this.refresh(),b>=d)return g!=(a=f[f.length-1])&&this.activate(a);if(g&&b=e[a]&&(void 0===e[a+1]||b .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.7",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return e=a-d&&"bottom"},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=Math.max(a(document).height(),a(document.body).height());"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); -------------------------------------------------------------------------------- /static/js/npm.js: -------------------------------------------------------------------------------- 1 | // This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment. 2 | require('../../js/transition.js') 3 | require('../../js/alert.js') 4 | require('../../js/button.js') 5 | require('../../js/carousel.js') 6 | require('../../js/collapse.js') 7 | require('../../js/dropdown.js') 8 | require('../../js/modal.js') 9 | require('../../js/tooltip.js') 10 | require('../../js/popover.js') 11 | require('../../js/scrollspy.js') 12 | require('../../js/tab.js') 13 | require('../../js/affix.js') -------------------------------------------------------------------------------- /updated_data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesog/scan/050d36cff97af8d07183b32ca77dd962bd1f8c3f/updated_data.png -------------------------------------------------------------------------------- /views/_footer.html: -------------------------------------------------------------------------------- 1 | {{- define "footer" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{- end }} 10 | -------------------------------------------------------------------------------- /views/_header.html: -------------------------------------------------------------------------------- 1 | {{ define "header" -}} 2 | 3 | 4 | 5 | Scan 6 | 7 | 8 | 22 | 23 | 24 | 78 | {{- if not .Authenticated }} 79 |
80 |

Login required

81 |
Please log in to view data
82 |
83 | {{- end }} 84 |
85 |
86 | {{- end }} 87 | -------------------------------------------------------------------------------- /views/admin.html: -------------------------------------------------------------------------------- 1 | {{ define "admin" -}} 2 | {{ template "header" . }} 3 | {{- if .Authenticated }} 4 | {{- if gt (len .Errors) 0 }} 5 |
6 |

Error

7 |
8 | {{- index .Errors 0 }} 9 |
10 |
11 | {{- end }} 12 |
13 |
14 | 15 | 16 |
17 | 18 |
19 |
20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {{- $user := .User.Email }} 31 | {{- range .Users }} 32 | 33 | 34 | 35 | 36 | {{- end }} 37 | 38 |
Email
{{ if ne . $user }}{{ end }}{{.}}
39 |
40 |
41 |
42 | {{- end }} 43 | {{- template "footer" }} 44 | {{- end }} 45 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | {{ define "index" -}} 2 | {{ template "header" . }} 3 | {{- if .Authenticated }} 4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {{- $AllResults := .AllResults }} 18 | {{- range .Results }} 19 | 20 | {{- if or $AllResults (not .Gone) }} 21 | 26 | 27 | 28 | 29 | 30 | 31 | {{- end }} 32 | 33 | {{- else }} 34 |
35 |

No results

36 |
No results found
37 |
38 | {{- end }} 39 | 40 |
IPPortProtoFirst SeenLast Seen
22 | {{- if .New }}New{{ end -}} 23 | {{- if .Gone }}Gone{{ end -}} 24 | {{- if .HasTraceroute }}{{ end -}} 25 | {{ .IP }}{{ .Port }}{{ .Proto }}{{ .FirstSeen }}{{ .LastSeen }}
41 |
42 | {{- if .Submission.Time }} 43 |
Last submission at {{ .Submission.Time }} by {{ .Submission.Host }}{{ if .Submission.Job }} for job {{ .Submission.Job }}{{ end }}
44 | {{- end }} 45 | {{- end }} 46 | {{- template "footer" }} 47 | {{- end }} 48 | -------------------------------------------------------------------------------- /views/job.html: -------------------------------------------------------------------------------- 1 | {{ define "job" -}} 2 | {{ template "header" . }} 3 | {{- if .Authenticated }} 4 | {{- if gt (len .JobID) 0 }} 5 |

Job {{ .JobID | join ", " }} submitted.

6 | {{- end }} 7 | {{- if gt (len .Errors) 0 }} 8 |
9 |

Missing information

10 |
11 | The following information was not supplied: 12 |
    13 | {{- range .Errors }} 14 |
  • {{ . }}
  • 15 | {{- end }} 16 |
17 |
18 |
19 | {{- end }} 20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | 31 |
32 | 33 |
34 |
35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | {{- range .Jobs }} 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | {{- end }} 62 | 63 |
IDCIDRPortsProtoSubmittedReceivedCountRequested by
{{ .ID }}{{ .CIDR }}{{ .Ports }}{{ .Proto }}{{ .Submitted }}{{ or .Received "Waiting" }}{{ if not .Received.IsZero }}{{ .Count }}{{ end }}{{ .RequestedBy }}
64 |
65 |
66 | {{- if .Submission.Time }} 67 |
Last submission at {{ .Submission.Time }} by {{ .Submission.Host }}{{ if .Submission.Job }} for job {{ .Submission.Job }}{{ end }}
68 | {{- end }} 69 | {{- end }} 70 | {{- template "footer" }} 71 | {{- end }} 72 | --------------------------------------------------------------------------------