commit 9510e8890a846a8025ac80bd256709f2900462d5 Author: Sergiusz 'q3k' Bazański Date: Thu Oct 6 12:01:49 2016 +0300 Initial commit. Signed-off-by: Sergiusz 'q3k' Bazański diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..28a41dd --- /dev/null +++ b/COPYING @@ -0,0 +1,23 @@ +Copyright (c) 2016, Serge Bazanski +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3beac53 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +Tog Switchifier Service +======================= + +This is a small service that collects status updates from the knife switch at Tog. + +Building +-------- + +I assume you already have a GOPATH set up. This code was tested with and developed againat Go 1.7. + + $ go get github.com/Tog-Hackerspace/switchifier + $ go build github.com/Tog-Hackerspace/switchifier + $ file switchifier + +Runnning +-------- + +This binary binds to a TCP port and listens for incoming TCP requests. It uses a SQLite database to store historical state data, and keeps a few other things (like last ping from client) in memory. + +Since this service authenticates its' client by a preshared secret, you should probably reverse-proxy this and wrap it in TLS or use another secure tunnel. + +Here's a few useful flags: + + - `--db_path`: Path to the SQLite database. The default is `switchifier.db` in the current working directory. + - `--bind_address`: Host/port to bind to. By default `:8080`, so port 8080 on all interfaces. + - `--secret`: The preshared secret used to authenticate the client. + + +Future work +----------- + + - SSL client cert authentication support. + - GRPC API. + - State overrides. + +License +------- + +See COPYING. + +API +=== + +status +------ +`GET /api/1/switchifier/status` - Get current switch status. + +Takes no parameters. + +Returns a JSON dictrionary with the following keys: + + - `okay` - true if request was successful, false if an error occured. + - `error` - a string detailing the error that occured if `okay` is false, else empty. + - `data` - a dictionary containing response data if `okay` is true, else undefined. + - `open` - true if the space is currently marked as open, else false + - `since` - nanoseconds since Unix epoch of the last `data.open` state change + - `lastKeepalive` - nanoseconds since Unix epoch of the last client update. May be zero if data is unavailable. + +Example: + + $ curl 127.0.0.1:8080/api/1/switchifier/status + {"okay":true,"error":"","data":{"open":true,"since":1475744318547307236,"lastKeepalive":1475744318562059736}} + +update +------ +`POST /api/1/switchifier/update` - Update knife switch status from client. + +Takes the following form parameters: + + - `secret` - the preshared secret required for client updates. + - `status` - the state of the current status. Truthy (open) values are `[Tt]rue`, `1`. Falsey (closed) values are everything else. + +Returns 200 if the update was succesfull. All other 5xx and 4xx codes shall be interpreted as errors. The client should periodically call this endpoint regardless of success. + +Example: + + #!/usr/bin/env python3 + import requests + + r = requests.post('http://127.0.0.1:8080/api/1/switchifier/update', data={ + 'secret': "changeme", + 'value': "true", + }) + diff --git a/main.go b/main.go new file mode 100644 index 0000000..084e10e --- /dev/null +++ b/main.go @@ -0,0 +1,222 @@ +// Main package for Tog Switchifier. See accompanying README.md file. + +// Copyright (c) 2016, Serge Bazanski +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + +package main + +import ( + "database/sql" + "encoding/json" + "flag" + "net/http" + "strings" + "time" + + "github.com/golang/glog" + _ "github.com/mattn/go-sqlite3" +) + +type switchApp struct { + db *sql.DB + secret string + lastUpdate int64 +} + +func newSwitchApp(db *sql.DB) switchApp { + return switchApp{ + db: db, + secret: secret, + } +} + +func (s *switchApp) RunSchemaUpdate() error { + sqlStmt := ` +CREATE TABLE IF NOT EXISTS switch_state_change( + timestamp INTEGER NOT NULL PRIMARY KEY, + interval INTEGER NOT NULL, + state BOOLEAN NOT NULL +);` + _, err := s.db.Exec(sqlStmt) + return err +} + +func (s *switchApp) UpdateState(state bool) error { + defer func() { + s.lastUpdate = time.Now().UnixNano() + }() + sqlStmt := ` +SELECT state FROM switch_state_change + ORDER BY timestamp DESC + LIMIT 1;` + res, err := s.db.Query(sqlStmt) + if err != nil { + return err + } + + shouldStore := false + if !res.Next() { + // Always store a state if the database is empty + shouldStore = true + } else { + // Otherwise store if there was a state change + var lastState bool + if err = res.Scan(&lastState); err != nil { + return err + } + if lastState != state { + shouldStore = true + } + } + if !shouldStore { + return nil + } + + glog.Info("Storing state change in database...") + timestamp := time.Now().UnixNano() + interval := int64(0) + if s.lastUpdate != 0 { + interval = timestamp - s.lastUpdate + } + sqlStmt = `INSERT INTO switch_state_change(timestamp, interval, state) VALUES (?, ?, ?)` + _, err = s.db.Exec(sqlStmt, timestamp, interval, state) + return err +} + +func (s *switchApp) HandleAPIUpdate(w http.ResponseWriter, r *http.Request) { + glog.Infof("%s: %s %s", r.RemoteAddr, r.Method, r.URL.String()) + if r.Method != "POST" { + w.WriteHeader(403) + return + } + // Yes, this should be a constant-time comparison. + if secret := r.FormValue("secret"); secret != s.secret { + w.WriteHeader(403) + return + } + + newValueStr := strings.ToLower(r.FormValue("value")) + if newValueStr == "" { + w.WriteHeader(400) + return + } + var newValue bool + if strings.HasPrefix(newValueStr, "t") || newValueStr == "1" { + newValue = true + } + + glog.Infof("Switch state: %v.", newValue) + err := s.UpdateState(newValue) + if err != nil { + w.WriteHeader(500) + glog.Errorf("Error in handler: %v", err) + return + } + w.WriteHeader(200) +} + +type apiGetResponse struct { + Okay bool `json:"okay"` + Error string `json:"error"` + Data struct { + Open bool `json:"open"` + Since int64 `json:"since"` + LastKeepalive int64 `json:"lastKeepalive"` + } `json:"data"` +} + +func (s *switchApp) HandleAPIGet(w http.ResponseWriter, r *http.Request) { + resp := apiGetResponse{} + defer func() { + if resp.Okay { + resp.Data.LastKeepalive = s.lastUpdate + } + respJson, err := json.Marshal(resp) + if err != nil { + w.WriteHeader(500) + w.Write([]byte(`{ okay: false, error: "Internal server error." }`)) + glog.Errorf("Error in handler: %v", err) + } + w.Write(respJson) + }() + + sqlStmt := `SELECT timestamp, state FROM switch_state_change +ORDER BY timestamp DESC +LIMIT 1` + data, err := s.db.Query(sqlStmt) + if err != nil { + resp.Okay = false + resp.Error = "Database error." + return + } + if !data.Next() { + resp.Okay = false + resp.Error = "No switch state yet." + return + } + var timestamp int64 + var state bool + if data.Scan(×tamp, &state) != nil { + resp.Okay = false + resp.Error = "Database error." + return + } + resp.Okay = true + resp.Data.Open = state + resp.Data.Since = timestamp +} + +var ( + dbPath string + bindAddress string + secret string +) + +func main() { + flag.StringVar(&dbPath, "db_path", "./switchifier.db", "Path to the SQlite3 database.") + flag.StringVar(&bindAddress, "bind_address", ":8080", "Address to bind HTTP server to.") + flag.StringVar(&secret, "secret", "changeme", "Secret for state updates.") + flag.Parse() + if dbPath == "" { + glog.Exit("Please provide a database path.") + } + glog.Infof("Starting switchifier...") + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + glog.Exit(err) + } + defer db.Close() + + switchApp := newSwitchApp(db) + if err = switchApp.RunSchemaUpdate(); err != nil { + glog.Exitf("Could not run schema update: %v", err) + } + glog.Info("Schema updates applied.") + + http.HandleFunc("/api/1/switchifier/update", switchApp.HandleAPIUpdate) + http.HandleFunc("/api/1/switchifier/status", switchApp.HandleAPIGet) + + glog.Infof("Listening on %s.", bindAddress) + glog.Exit(http.ListenAndServe(bindAddress, nil)) +}