summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/tfstated/main.go3
-rw-r--r--cmd/tfstated/main_test.go1
-rw-r--r--go.mod2
-rw-r--r--go.sum4
-rw-r--r--pkg/database/accounts.go34
-rw-r--r--pkg/database/sessions.go69
-rw-r--r--pkg/database/sql/000_init.sql9
-rw-r--r--pkg/model/session.go20
-rw-r--r--pkg/webui/cache.go10
-rw-r--r--pkg/webui/error.go21
-rw-r--r--pkg/webui/html/base.html20
-rw-r--r--pkg/webui/html/error.html6
-rw-r--r--pkg/webui/html/index.html3
-rw-r--r--pkg/webui/html/login.html29
-rw-r--r--pkg/webui/index.go16
-rw-r--r--pkg/webui/login.go115
-rw-r--r--pkg/webui/render.go22
-rw-r--r--pkg/webui/routes.go6
-rw-r--r--pkg/webui/run.go7
-rw-r--r--pkg/webui/sessions.go55
-rw-r--r--pkg/webui/static/favicon.svg3
-rw-r--r--pkg/webui/static/main.css3
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 {
diff --git a/go.mod b/go.mod
index bf4d9bb..d8c4238 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index a2ee54f..0626106 100644
--- a/go.sum
+++ b/go.sum
@@ -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;
+}