diff options
-rw-r--r-- | cmd/tfstated/main.go | 3 | ||||
-rw-r--r-- | cmd/tfstated/main_test.go | 1 | ||||
-rw-r--r-- | go.mod | 2 | ||||
-rw-r--r-- | go.sum | 4 | ||||
-rw-r--r-- | pkg/database/accounts.go | 34 | ||||
-rw-r--r-- | pkg/database/sessions.go | 69 | ||||
-rw-r--r-- | pkg/database/sql/000_init.sql | 9 | ||||
-rw-r--r-- | pkg/model/session.go | 20 | ||||
-rw-r--r-- | pkg/webui/cache.go | 10 | ||||
-rw-r--r-- | pkg/webui/error.go | 21 | ||||
-rw-r--r-- | pkg/webui/html/base.html | 20 | ||||
-rw-r--r-- | pkg/webui/html/error.html | 6 | ||||
-rw-r--r-- | pkg/webui/html/index.html | 3 | ||||
-rw-r--r-- | pkg/webui/html/login.html | 29 | ||||
-rw-r--r-- | pkg/webui/index.go | 16 | ||||
-rw-r--r-- | pkg/webui/login.go | 115 | ||||
-rw-r--r-- | pkg/webui/render.go | 22 | ||||
-rw-r--r-- | pkg/webui/routes.go | 6 | ||||
-rw-r--r-- | pkg/webui/run.go | 7 | ||||
-rw-r--r-- | pkg/webui/sessions.go | 55 | ||||
-rw-r--r-- | pkg/webui/static/favicon.svg | 3 | ||||
-rw-r--r-- | pkg/webui/static/main.css | 3 |
22 files changed, 452 insertions, 6 deletions
diff --git a/cmd/tfstated/main.go b/cmd/tfstated/main.go index a1c74ca..e228d53 100644 --- a/cmd/tfstated/main.go +++ b/cmd/tfstated/main.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "sync" + "syscall" "time" "git.adyxax.org/adyxax/tfstated/pkg/backend" @@ -19,7 +20,7 @@ func run( db *database.DB, getenv func(string) string, ) error { - ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer cancel() if err := db.InitAdminAccount(); err != nil { diff --git a/cmd/tfstated/main_test.go b/cmd/tfstated/main_test.go index ae9f2d7..b28edbd 100644 --- a/cmd/tfstated/main_test.go +++ b/cmd/tfstated/main_test.go @@ -57,7 +57,6 @@ func TestMain(m *testing.M) { ctx, db, getenv, - os.Stderr, ) err = waitForReady(ctx, 5*time.Second, "http://127.0.0.1:8082/healthz") if err != nil { @@ -5,5 +5,5 @@ go 1.23.4 require ( github.com/mattn/go-sqlite3 v1.14.24 go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad - golang.org/x/crypto v0.31.0 + golang.org/x/crypto v0.32.0 ) @@ -2,5 +2,5 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad h1:QYbHaaFqx6hMor1L6iMSmyhMFvXQXhKaNk9nefug07M= go.n16f.net/uuid v0.0.0-20240707135755-e4fd26b968ad/go.mod h1:hvPEWZmyP50in1DH72o5vUvoXFFyfRU6oL+p2tAcbgU= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= diff --git a/pkg/database/accounts.go b/pkg/database/accounts.go index d73fe27..377ca80 100644 --- a/pkg/database/accounts.go +++ b/pkg/database/accounts.go @@ -47,6 +47,38 @@ func (db *DB) InitAdminAccount() error { }) } +func (db *DB) LoadAccountById(id int) (*model.Account, error) { + account := model.Account{ + Id: id, + } + var ( + created int64 + lastLogin int64 + ) + err := db.QueryRow( + `SELECT username, salt, password_hash, is_admin, created, last_login, settings + FROM accounts + WHERE id = ?;`, + id, + ).Scan(&account.Username, + &account.Salt, + &account.PasswordHash, + &account.IsAdmin, + &created, + &lastLogin, + &account.Settings, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to load account by id %d: %w", id, err) + } + account.Created = time.Unix(created, 0) + account.LastLogin = time.Unix(lastLogin, 0) + return &account, nil +} + func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) { account := model.Account{ Username: username, @@ -72,7 +104,7 @@ func (db *DB) LoadAccountByUsername(username string) (*model.Account, error) { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return nil, err + return nil, fmt.Errorf("failed to load account by username %s: %w", username, err) } account.Created = time.Unix(created, 0) account.LastLogin = time.Unix(lastLogin, 0) diff --git a/pkg/database/sessions.go b/pkg/database/sessions.go new file mode 100644 index 0000000..decba8e --- /dev/null +++ b/pkg/database/sessions.go @@ -0,0 +1,69 @@ +package database + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "git.adyxax.org/adyxax/tfstated/pkg/model" + "go.n16f.net/uuid" +) + +func (db *DB) CreateSession(account *model.Account) (string, error) { + var sessionId uuid.UUID + if err := sessionId.Generate(uuid.V4); err != nil { + return "", fmt.Errorf("failed to generate session id: %w", err) + } + if _, err := db.Exec( + `INSERT INTO sessions(id, account_id, data) + VALUES (?, ?, ?);`, + sessionId.String(), + account.Id, + "", + ); err != nil { + return "", fmt.Errorf("failed insert new session in database: %w", err) + } + return sessionId.String(), nil +} + +func (db *DB) LoadSessionById(id string) (*model.Session, error) { + session := model.Session{ + Id: id, + } + var ( + created int64 + updated int64 + ) + err := db.QueryRow( + `SELECT account_id, + created, + updated, + data + FROM sessions + WHERE id = ?;`, + id, + ).Scan(&session.AccountId, + &created, + &updated, + &session.Data, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to load session by id %s: %w", id, err) + } + session.Created = time.Unix(created, 0) + session.Updated = time.Unix(updated, 0) + return &session, nil +} + +func (db *DB) TouchSession(sessionId string) error { + now := time.Now().UTC() + _, err := db.Exec(`UPDATE sessions SET updated = ? WHERE id = ?`, now.Unix(), sessionId) + if err != nil { + return fmt.Errorf("failed to touch updated for session %s: %w", sessionId, err) + } + return nil +} diff --git a/pkg/database/sql/000_init.sql b/pkg/database/sql/000_init.sql index b635442..e14142b 100644 --- a/pkg/database/sql/000_init.sql +++ b/pkg/database/sql/000_init.sql @@ -14,6 +14,15 @@ CREATE TABLE accounts ( ) STRICT; CREATE UNIQUE INDEX accounts_username on accounts(username); +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + account_id INTEGER NOT NULL, + created INTEGER NOT NULL DEFAULT (unixepoch()), + updated INTEGER NOT NULL DEFAULT (unixepoch()), + data TEXT NOT NULL, + FOREIGN KEY(account_id) REFERENCES accounts(id) ON DELETE CASCADE +) STRICT; + CREATE TABLE states ( id INTEGER PRIMARY KEY, path TEXT NOT NULL, diff --git a/pkg/model/session.go b/pkg/model/session.go new file mode 100644 index 0000000..afa6a77 --- /dev/null +++ b/pkg/model/session.go @@ -0,0 +1,20 @@ +package model + +import ( + "time" +) + +type SessionContextKey struct{} + +type Session struct { + Id string + AccountId int + Created time.Time + Updated time.Time + Data any +} + +func (session *Session) IsExpired() bool { + // TODO + return false +} diff --git a/pkg/webui/cache.go b/pkg/webui/cache.go new file mode 100644 index 0000000..cef999b --- /dev/null +++ b/pkg/webui/cache.go @@ -0,0 +1,10 @@ +package webui + +import "net/http" + +func cache(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/webui/error.go b/pkg/webui/error.go new file mode 100644 index 0000000..05d8e9e --- /dev/null +++ b/pkg/webui/error.go @@ -0,0 +1,21 @@ +package webui + +import ( + "html/template" + "net/http" +) + +var errorTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/error.html")) + +func errorResponse(w http.ResponseWriter, status int, err error) { + type ErrorData struct { + Err error + Status int + StatusText string + } + render(w, errorTemplates, status, &ErrorData{ + Err: err, + Status: status, + StatusText: http.StatusText(status), + }) +} diff --git a/pkg/webui/html/base.html b/pkg/webui/html/base.html new file mode 100644 index 0000000..1c15cc5 --- /dev/null +++ b/pkg/webui/html/base.html @@ -0,0 +1,20 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="icon" href="/static/favicon.svg"> + <link rel="stylesheet" href="/static/main.css"> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> + <title>tfstated</title> + </head> + <body> + <header> + </header> + <main class="container"> + {{ template "main" . }} + </main> + <footer> + </footer> + </body> +</html> diff --git a/pkg/webui/html/error.html b/pkg/webui/html/error.html new file mode 100644 index 0000000..d2ea7e1 --- /dev/null +++ b/pkg/webui/html/error.html @@ -0,0 +1,6 @@ +{{ define "main" }} +<article> + <h1>{{ .Status }} - {{ .StatusText }}</h1> + <p>{{ .Err }}</p> +</article> +{{ end }} diff --git a/pkg/webui/html/index.html b/pkg/webui/html/index.html new file mode 100644 index 0000000..5c50159 --- /dev/null +++ b/pkg/webui/html/index.html @@ -0,0 +1,3 @@ +{{ define "main" }} +<h1>TODO</h1> +{{ end }} diff --git a/pkg/webui/html/login.html b/pkg/webui/html/login.html new file mode 100644 index 0000000..0c2a167 --- /dev/null +++ b/pkg/webui/html/login.html @@ -0,0 +1,29 @@ +{{ define "main" }} +{{ if .Forbidden }} +<article> + <p class="error-message">Invalid username or password</p> +</article> +{{ end }} +<form action="/login" method="post"> + <fieldset> + <label> + Username + <input type="text" + placeholder="Username" + name="username" + value="{{ .Username }}" + {{ if .Forbidden }}aria-invalid="true"{{ end }} + required> + </label> + <label> + Password + <input type="password" + placeholder="Password" + name="password" + {{ if .Forbidden }}aria-invalid="true"{{ end }} + required> + </label> + </fieldset> + <button type="submit" value="login">Login</button> +</form> +{{ end }} diff --git a/pkg/webui/index.go b/pkg/webui/index.go new file mode 100644 index 0000000..9c81729 --- /dev/null +++ b/pkg/webui/index.go @@ -0,0 +1,16 @@ +package webui + +import ( + "html/template" + "net/http" +) + +var indexTemplates = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/index.html")) + +func handleIndexGET() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store, no-cache") + + render(w, indexTemplates, http.StatusOK, nil) + }) +} diff --git a/pkg/webui/login.go b/pkg/webui/login.go new file mode 100644 index 0000000..d004d82 --- /dev/null +++ b/pkg/webui/login.go @@ -0,0 +1,115 @@ +package webui + +import ( + "context" + "fmt" + "html/template" + "net/http" + "regexp" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +var loginTemplate = template.Must(template.ParseFS(htmlFS, "html/base.html", "html/login.html")) + +type loginPage struct { + Forbidden bool + Username string +} + +func handleLoginGET() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store, no-cache") + + session := r.Context().Value(model.SessionContextKey{}) + if session != nil { + http.Redirect(w, r, "/", http.StatusFound) + return + } + + render(w, loginTemplate, http.StatusOK, loginPage{}) + }) +} + +func handleLoginPOST(db *database.DB) http.Handler { + var validUsername = regexp.MustCompile(`^[a-zA-Z]\w*$`) + renderForbidden := func(w http.ResponseWriter, username string) { + render(w, loginTemplate, http.StatusForbidden, loginPage{ + Forbidden: true, + Username: username, + }) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + errorResponse(w, http.StatusBadRequest, err) + return + } + username := r.FormValue("username") + password := r.FormValue("password") + + if username == "" || password == "" { // the webui cannot issue this + errorResponse(w, http.StatusBadRequest, fmt.Errorf("Forbidden")) + return + } + if ok := validUsername.MatchString(username); !ok { + renderForbidden(w, username) + return + } + account, err := db.LoadAccountByUsername(username) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + if account == nil || !account.CheckPassword(password) { + renderForbidden(w, username) + return + } + if err := db.TouchAccount(account); err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + sessionId, err := db.CreateSession(account) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: sessionId, + Quoted: false, + Path: "/", + MaxAge: 8 * 3600, // 1 hour sessions + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: true, + }) + http.Redirect(w, r, "/", http.StatusFound) + }) +} + +func loginMiddleware(db *database.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store, no-cache") + session := r.Context().Value(model.SessionContextKey{}) + if session == nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + account, err := db.LoadAccountById(session.(*model.Session).AccountId) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + if account == nil { + // this could happen if the account was deleted in the short + // time between retrieving the session and here + http.Redirect(w, r, "/login", http.StatusFound) + return + } + ctx := context.WithValue(r.Context(), model.AccountContextKey{}, account) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/pkg/webui/render.go b/pkg/webui/render.go new file mode 100644 index 0000000..23f5e51 --- /dev/null +++ b/pkg/webui/render.go @@ -0,0 +1,22 @@ +package webui + +import ( + "bytes" + "fmt" + "html/template" + "net/http" +) + +func render(w http.ResponseWriter, t *template.Template, status int, data any) { + var buf bytes.Buffer + if err := t.ExecuteTemplate(&buf, "base.html", data); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(fmt.Sprintf( + "%s: failed to execute template: %+v", + http.StatusText(http.StatusInternalServerError), + err))) + } else { + w.WriteHeader(status) + _, _ = buf.WriteTo(w) + } +} diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go index ef16040..5cf31c0 100644 --- a/pkg/webui/routes.go +++ b/pkg/webui/routes.go @@ -10,5 +10,11 @@ func addRoutes( mux *http.ServeMux, db *database.DB, ) { + session := sessionsMiddleware(db) + requireLogin := loginMiddleware(db) mux.Handle("GET /healthz", handleHealthz()) + mux.Handle("GET /login", session(handleLoginGET())) + mux.Handle("POST /login", session(handleLoginPOST(db))) + mux.Handle("GET /static/", cache(http.FileServer(http.FS(staticFS)))) + mux.Handle("GET /", session(requireLogin(handleIndexGET()))) } diff --git a/pkg/webui/run.go b/pkg/webui/run.go index f1b20cb..664b9e5 100644 --- a/pkg/webui/run.go +++ b/pkg/webui/run.go @@ -2,6 +2,7 @@ package webui import ( "context" + "embed" "log/slog" "net" "net/http" @@ -10,6 +11,12 @@ import ( "git.adyxax.org/adyxax/tfstated/pkg/middlewares/logger" ) +//go:embed html/* +var htmlFS embed.FS + +//go:embed static/* +var staticFS embed.FS + func Run( ctx context.Context, db *database.DB, diff --git a/pkg/webui/sessions.go b/pkg/webui/sessions.go new file mode 100644 index 0000000..6d492d5 --- /dev/null +++ b/pkg/webui/sessions.go @@ -0,0 +1,55 @@ +package webui + +import ( + "context" + "errors" + "fmt" + "net/http" + + "git.adyxax.org/adyxax/tfstated/pkg/database" + "git.adyxax.org/adyxax/tfstated/pkg/model" +) + +const cookieName = "tfstated" + +func sessionsMiddleware(db *database.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(cookieName) + if err != nil && !errors.Is(err, http.ErrNoCookie) { + errorResponse(w, http.StatusInternalServerError, fmt.Errorf("failed to get request cookie \"%s\": %w", cookieName, err)) + return + } + if err == nil { + if len(cookie.Value) != 36 { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Quoted: false, + Path: "/", + MaxAge: 0, // remove invalid cookie + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: true, + }) + } else { + session, err := db.LoadSessionById(cookie.Value) + if err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + if !session.IsExpired() { + if err := db.TouchSession(cookie.Value); err != nil { + errorResponse(w, http.StatusInternalServerError, err) + return + } + ctx := context.WithValue(r.Context(), model.SessionContextKey{}, session) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/pkg/webui/static/favicon.svg b/pkg/webui/static/favicon.svg new file mode 100644 index 0000000..56f9365 --- /dev/null +++ b/pkg/webui/static/favicon.svg @@ -0,0 +1,3 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg xmlns="http://www.w3.org/2000/svg" style="background-color: rgb(0, 0, 0);" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="98px" height="94px" viewBox="-0.5 -0.5 98 94"><defs/><rect fill="#000000" width="100%" height="100%" x="0" y="0"/><g><g data-cell-id="0"><g data-cell-id="1"><g data-cell-id="d8vDQkJiZMsKFp_Zf0So-1"><g><rect x="0" y="0" width="98" height="94" fill="none" stroke="none" pointer-events="all"/><path d="M 89.23 80.02 L 89.23 31.87 L 76.66 31.87 L 76.66 80.02 L 66.61 80.02 L 66.61 31.87 L 54.04 31.87 L 54.04 80.02 L 43.99 80.02 L 43.99 31.87 L 31.42 31.87 L 31.42 80.02 L 21.36 80.02 L 21.36 31.87 L 8.8 31.87 L 8.8 80.02 L 8.79 80.02 L 0 94 L 98 93.98 Z M 2.49 26.57 L 95.51 26.57 L 49 0 Z" fill="#e6e6e6" stroke="none" pointer-events="all"/></g></g></g></g></g></svg>
\ No newline at end of file diff --git a/pkg/webui/static/main.css b/pkg/webui/static/main.css new file mode 100644 index 0000000..a2472b7 --- /dev/null +++ b/pkg/webui/static/main.css @@ -0,0 +1,3 @@ +.error-message { + color:red; +} |