commit 3d302ee541194e2e1229d8449d9541cd57df148a Author: Peter Li Date: Sat Feb 7 17:48:24 2026 -0800 git ignore diff --git a/cmd/satoru/main.go b/cmd/satoru/main.go new file mode 100644 index 0000000..f60b6d4 --- /dev/null +++ b/cmd/satoru/main.go @@ -0,0 +1,418 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/a-h/templ" + "github.com/go-chi/chi/v5" + "golang.org/x/crypto/bcrypt" + + "satoru/internal/store" + "satoru/internal/webui" +) + +const ( + sessionCookieName = "satoru_session" + sessionTTL = 24 * time.Hour * 14 +) + +type app struct { + store *store.Store +} + +func main() { + if err := os.MkdirAll("data", 0o755); err != nil { + log.Fatal(err) + } + dbPath := filepath.Join("data", "satoru.db") + st, err := store.Open(dbPath) + if err != nil { + log.Fatal(err) + } + defer st.Close() + + a := &app{store: st} + r := chi.NewRouter() + + fileServer := http.FileServer(http.Dir("web/static")) + r.Handle("/static/*", http.StripPrefix("/static/", fileServer)) + + r.Get("/", a.handleHome) + r.Get("/account/password", a.handlePasswordPage) + r.Post("/account/password", a.handlePasswordSubmit) + r.Post("/sites", a.handleSiteCreate) + r.Post("/sites/{id}/run", a.handleSiteRun) + r.Get("/signup", a.handleSignupPage) + r.Post("/signup", a.handleSignupSubmit) + r.Get("/signin", a.handleSigninPage) + r.Post("/signin", a.handleSigninSubmit) + r.Post("/signout", a.handleSignoutSubmit) + + addr := ":8080" + log.Printf("satoru listening on http://localhost%s", addr) + if err := http.ListenAndServe(addr, r); err != nil { + log.Fatal(err) + } +} + +func (a *app) handleHome(w http.ResponseWriter, r *http.Request) { + user, err := a.currentUser(r.Context(), r) + if err != nil { + templ.Handler(webui.Home(time.Now(), store.User{})).ServeHTTP(w, r) + return + } + + sites, err := a.store.ListSites(r.Context()) + if err != nil { + http.Error(w, "failed to load sites", http.StatusInternalServerError) + return + } + + data := webui.DashboardData{ + Now: time.Now(), + User: user, + Sites: sites, + RuntimeChecks: runtimeChecks(), + FlashMessage: r.URL.Query().Get("msg"), + WorkflowStages: defaultWorkflowStages(), + } + templ.Handler(webui.Dashboard(data)).ServeHTTP(w, r) +} + +func (a *app) handleSignupPage(w http.ResponseWriter, r *http.Request) { + if _, err := a.currentUser(r.Context(), r); err == nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + templ.Handler(webui.Signup(webui.AuthPageData{})).ServeHTTP(w, r) +} + +func (a *app) handleSignupSubmit(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + + username := normalizeUsername(r.FormValue("username")) + password := r.FormValue("password") + + form := webui.AuthPageData{Username: username} + if !validUsername(username) { + form.Error = "Username must be 3-32 chars using letters, numbers, ., _, or -." + templ.Handler(webui.Signup(form)).ServeHTTP(w, r) + return + } + if len(password) < 8 { + form.Error = "Password must be at least 8 characters." + templ.Handler(webui.Signup(form)).ServeHTTP(w, r) + return + } + + hashBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "failed to create account", http.StatusInternalServerError) + return + } + + user, err := a.store.CreateUser(r.Context(), username, string(hashBytes)) + if err != nil { + if errors.Is(err, store.ErrUsernameTaken) { + form.Error = "That username is already registered." + templ.Handler(webui.Signup(form)).ServeHTTP(w, r) + return + } + http.Error(w, "failed to create account", http.StatusInternalServerError) + return + } + + if err := a.issueSession(w, r, user.ID); err != nil { + http.Error(w, "failed to create session", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (a *app) handleSigninPage(w http.ResponseWriter, r *http.Request) { + if _, err := a.currentUser(r.Context(), r); err == nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + templ.Handler(webui.Signin(webui.AuthPageData{})).ServeHTTP(w, r) +} + +func (a *app) handleSigninSubmit(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + + username := normalizeUsername(r.FormValue("username")) + password := r.FormValue("password") + form := webui.AuthPageData{Username: username} + + user, err := a.store.UserByUsername(r.Context(), username) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + form.Error = "Invalid username or password." + templ.Handler(webui.Signin(form)).ServeHTTP(w, r) + return + } + http.Error(w, "failed to sign in", http.StatusInternalServerError) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + form.Error = "Invalid username or password." + templ.Handler(webui.Signin(form)).ServeHTTP(w, r) + return + } + + if err := a.issueSession(w, r, user.ID); err != nil { + http.Error(w, "failed to create session", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (a *app) handleSignoutSubmit(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookieName) + if err == nil && c.Value != "" { + _ = a.store.DeleteSessionByTokenHash(r.Context(), hashToken(c.Value)) + } + + clearSessionCookie(w) + http.Redirect(w, r, "/signin", http.StatusSeeOther) +} + +func (a *app) handlePasswordPage(w http.ResponseWriter, r *http.Request) { + user, err := a.currentUser(r.Context(), r) + if err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + templ.Handler(webui.ChangePassword(webui.PasswordPageData{User: user})).ServeHTTP(w, r) +} + +func (a *app) handlePasswordSubmit(w http.ResponseWriter, r *http.Request) { + user, err := a.currentUser(r.Context(), r) + if err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + + current := r.FormValue("current_password") + next := r.FormValue("new_password") + confirm := r.FormValue("confirm_password") + + form := webui.PasswordPageData{User: user} + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil { + form.Error = "Current password is incorrect." + templ.Handler(webui.ChangePassword(form)).ServeHTTP(w, r) + return + } + if len(next) < 8 { + form.Error = "New password must be at least 8 characters." + templ.Handler(webui.ChangePassword(form)).ServeHTTP(w, r) + return + } + if next != confirm { + form.Error = "New password and confirmation do not match." + templ.Handler(webui.ChangePassword(form)).ServeHTTP(w, r) + return + } + + hashBytes, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "failed to update password", http.StatusInternalServerError) + return + } + if err := a.store.UpdateUserPasswordHash(r.Context(), user.ID, string(hashBytes)); err != nil { + http.Error(w, "failed to update password", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/?msg=password-updated", http.StatusSeeOther) +} + +func (a *app) handleSiteCreate(w http.ResponseWriter, r *http.Request) { + if _, err := a.currentUser(r.Context(), r); err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + + sshUser := strings.TrimSpace(r.FormValue("ssh_user")) + host := strings.TrimSpace(r.FormValue("host")) + remotePath := strings.TrimSpace(r.FormValue("remote_path")) + if sshUser == "" || host == "" || remotePath == "" { + http.Redirect(w, r, "/?msg=site-invalid", http.StatusSeeOther) + return + } + + if _, err := a.store.CreateSite(r.Context(), sshUser, host, remotePath); err != nil { + http.Error(w, "failed to add site", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/?msg=site-added", http.StatusSeeOther) +} + +func (a *app) handleSiteRun(w http.ResponseWriter, r *http.Request) { + if _, err := a.currentUser(r.Context(), r); err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.Error(w, "invalid site id", http.StatusBadRequest) + return + } + + site, err := a.store.SiteByID(r.Context(), id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.NotFound(w, r) + return + } + http.Error(w, "failed to load site", http.StatusInternalServerError) + return + } + + status, output := runSSHHello(r.Context(), site) + if err := a.store.UpdateSiteRunResult(r.Context(), site.ID, status, output, time.Now()); err != nil { + http.Error(w, "failed to store run result", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/?msg=site-ran", http.StatusSeeOther) +} + +func (a *app) issueSession(w http.ResponseWriter, r *http.Request, userID int64) error { + token, err := generateToken() + if err != nil { + return err + } + + expiresAt := time.Now().Add(sessionTTL) + if err := a.store.CreateSession(r.Context(), userID, hashToken(token), expiresAt); err != nil { + return err + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil, + Expires: expiresAt, + }) + return nil +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + Expires: time.Unix(0, 0), + }) +} + +func (a *app) currentUser(ctx context.Context, r *http.Request) (store.User, error) { + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + return store.User{}, http.ErrNoCookie + } + return a.store.UserBySessionTokenHash(ctx, hashToken(c.Value)) +} + +func generateToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +var usernamePattern = regexp.MustCompile(`^[a-z0-9._-]{3,32}$`) + +func normalizeUsername(v string) string { + return strings.ToLower(strings.TrimSpace(v)) +} + +func validUsername(v string) bool { + return usernamePattern.MatchString(v) +} + +func runtimeChecks() []webui.RuntimeCheck { + tools := []string{"restic", "rsync", "ssh"} + out := make([]webui.RuntimeCheck, 0, len(tools)) + for _, name := range tools { + path, err := exec.LookPath(name) + if err != nil { + out = append(out, webui.RuntimeCheck{Name: name, Installed: false, Details: "not found in PATH"}) + continue + } + out = append(out, webui.RuntimeCheck{Name: name, Installed: true, Details: path}) + } + return out +} + +func runSSHHello(ctx context.Context, site store.Site) (string, string) { + target := fmt.Sprintf("%s@%s", site.SSHUser, site.Host) + cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(cmdCtx, "ssh", target, "echo hello from satoru") + out, err := cmd.CombinedOutput() + output := strings.TrimSpace(string(out)) + if output == "" { + output = "(no output)" + } + if err != nil { + return "failed", output + } + return "ok", output +} + +func defaultWorkflowStages() []webui.WorkflowStage { + return []webui.WorkflowStage{ + {Title: "Pull from Edge over SSH", Description: "Satoru connects to Linux edge hosts using local keys and pulls approved paths."}, + {Title: "Stage on Backup Server", Description: "Pulled data lands on the backup host first, keeping edge systems isolated from B2."}, + {Title: "Restic to B2", Description: "Restic runs centrally on this Satoru instance and uploads snapshots to Backblaze B2."}, + {Title: "Audit and Recover", Description: "Each site records run output/status for operational visibility before full job history is added."}, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8ecfc04 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module satoru + +go 1.25.7 + +require ( + github.com/a-h/templ v0.3.977 + github.com/go-chi/chi/v5 v5.2.5 + golang.org/x/crypto v0.47.0 + modernc.org/sqlite v1.44.3 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.40.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..374bd35 --- /dev/null +++ b/go.sum @@ -0,0 +1,61 @@ +github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= +github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY= +modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..dc8c5a4 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,350 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +type Store struct { + db *sql.DB +} + +type User struct { + ID int64 + Username string + PasswordHash string + IsAdmin bool + CreatedAt time.Time +} + +type Site struct { + ID int64 + SSHUser string + Host string + RemotePath string + CreatedAt time.Time + LastRunStatus sql.NullString + LastRunOutput sql.NullString + LastRunAt sql.NullTime +} + +func Open(path string) (*Store, error) { + dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + + if err := db.Ping(); err != nil { + return nil, err + } + + s := &Store{db: db} + if err := s.migrate(context.Background()); err != nil { + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) migrate(ctx context.Context) error { + const usersSQL = ` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +);` + + const sessionsSQL = ` +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL +);` + + const sitesSQL = ` +CREATE TABLE IF NOT EXISTS sites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ssh_user TEXT NOT NULL, + host TEXT NOT NULL, + remote_path TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_run_status TEXT, + last_run_output TEXT, + last_run_at DATETIME +);` + + if _, err := s.db.ExecContext(ctx, usersSQL); err != nil { + return err + } + if err := s.migrateUsersLegacyEmail(ctx); err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, sessionsSQL); err != nil { + return err + } + if _, err := s.db.ExecContext(ctx, sitesSQL); err != nil { + return err + } + return nil +} + +func (s *Store) migrateUsersLegacyEmail(ctx context.Context) error { + cols, err := tableColumns(ctx, s.db, "users") + if err != nil { + return err + } + if cols["username"] { + return nil + } + + if _, err := s.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN username TEXT`); err != nil { + return err + } + + if cols["email"] { + if _, err := s.db.ExecContext(ctx, `UPDATE users SET username = lower(trim(email)) WHERE username IS NULL`); err != nil { + return err + } + } + + if _, err := s.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)`); err != nil { + return err + } + return nil +} + +func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return User{}, err + } + defer tx.Rollback() + + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM users`).Scan(&count); err != nil { + return User{}, err + } + isAdmin := count == 0 + + res, err := tx.ExecContext( + ctx, + `INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)`, + username, + passwordHash, + boolToInt(isAdmin), + ) + if err != nil { + if isUniqueUsernameErr(err) { + return User{}, ErrUsernameTaken + } + return User{}, err + } + + userID, err := res.LastInsertId() + if err != nil { + return User{}, err + } + + user, err := userByIDTx(ctx, tx, userID) + if err != nil { + return User{}, err + } + + if err := tx.Commit(); err != nil { + return User{}, err + } + return user, nil +} + +func (s *Store) UserByUsername(ctx context.Context, username string) (User, error) { + const q = ` +SELECT id, username, password_hash, is_admin, created_at +FROM users +WHERE username = ?` + return scanUser(s.db.QueryRowContext(ctx, q, username)) +} + +func (s *Store) UserBySessionTokenHash(ctx context.Context, tokenHash string) (User, error) { + const q = ` +SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at +FROM sessions s +JOIN users u ON u.id = s.user_id +WHERE s.token_hash = ? AND s.expires_at > CURRENT_TIMESTAMP` + return scanUser(s.db.QueryRowContext(ctx, q, tokenHash)) +} + +func (s *Store) CreateSession(ctx context.Context, userID int64, tokenHash string, expiresAt time.Time) error { + _, err := s.db.ExecContext( + ctx, + `INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (?, ?, ?)`, + userID, + tokenHash, + expiresAt.UTC().Format(time.RFC3339), + ) + return err +} + +func (s *Store) DeleteSessionByTokenHash(ctx context.Context, tokenHash string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash) + return err +} + +func (s *Store) UpdateUserPasswordHash(ctx context.Context, userID int64, passwordHash string) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID) + return err +} + +func (s *Store) CreateSite(ctx context.Context, sshUser, host, remotePath string) (Site, error) { + res, err := s.db.ExecContext( + ctx, + `INSERT INTO sites (ssh_user, host, remote_path) VALUES (?, ?, ?)`, + sshUser, + host, + remotePath, + ) + if err != nil { + return Site{}, err + } + id, err := res.LastInsertId() + if err != nil { + return Site{}, err + } + return s.SiteByID(ctx, id) +} + +func (s *Store) ListSites(ctx context.Context) ([]Site, error) { + const q = ` +SELECT id, ssh_user, host, remote_path, created_at, last_run_status, last_run_output, last_run_at +FROM sites +ORDER BY id DESC` + rows, err := s.db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Site + for rows.Next() { + site, err := scanSite(rows) + if err != nil { + return nil, err + } + out = append(out, site) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *Store) SiteByID(ctx context.Context, id int64) (Site, error) { + const q = ` +SELECT id, ssh_user, host, remote_path, created_at, last_run_status, last_run_output, last_run_at +FROM sites +WHERE id = ?` + return scanSite(s.db.QueryRowContext(ctx, q, id)) +} + +func (s *Store) UpdateSiteRunResult(ctx context.Context, id int64, status, output string, at time.Time) error { + _, err := s.db.ExecContext( + ctx, + `UPDATE sites SET last_run_status = ?, last_run_output = ?, last_run_at = ? WHERE id = ?`, + status, + output, + at.UTC().Format(time.RFC3339), + id, + ) + return err +} + +func userByIDTx(ctx context.Context, tx *sql.Tx, id int64) (User, error) { + const q = ` +SELECT id, username, password_hash, is_admin, created_at +FROM users +WHERE id = ?` + return scanUser(tx.QueryRowContext(ctx, q, id)) +} + +type scanner interface { + Scan(dest ...any) error +} + +func scanUser(row scanner) (User, error) { + var user User + var isAdmin int + if err := row.Scan(&user.ID, &user.Username, &user.PasswordHash, &isAdmin, &user.CreatedAt); err != nil { + return User{}, err + } + user.IsAdmin = isAdmin == 1 + return user, nil +} + +func scanSite(row scanner) (Site, error) { + var site Site + if err := row.Scan( + &site.ID, + &site.SSHUser, + &site.Host, + &site.RemotePath, + &site.CreatedAt, + &site.LastRunStatus, + &site.LastRunOutput, + &site.LastRunAt, + ); err != nil { + return Site{}, err + } + return site, nil +} + +func boolToInt(v bool) int { + if v { + return 1 + } + return 0 +} + +var ErrUsernameTaken = errors.New("username already registered") + +func isUniqueUsernameErr(err error) bool { + return strings.Contains(err.Error(), "UNIQUE constraint failed: users.username") +} + +func tableColumns(ctx context.Context, db *sql.DB, table string) (map[string]bool, error) { + rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return nil, err + } + defer rows.Close() + + cols := map[string]bool{} + for rows.Next() { + var cid int + var name string + var typ string + var notNull int + var dflt sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { + return nil, err + } + cols[name] = true + } + if err := rows.Err(); err != nil { + return nil, err + } + return cols, nil +} diff --git a/internal/webui/auth.go b/internal/webui/auth.go new file mode 100644 index 0000000..970830f --- /dev/null +++ b/internal/webui/auth.go @@ -0,0 +1,100 @@ +package webui + +import ( + "context" + "fmt" + "html" + "io" + + "github.com/a-h/templ" +) + +type AuthPageData struct { + Username string + Error string +} + +func Signup(data AuthPageData) templ.Component { + return authPage( + "Sign up", + "Create your account", + "First account created becomes the initial admin.", + "/signup", + "Create account", + "/signin", + "Already have an account? Sign in", + data, + ) +} + +func Signin(data AuthPageData) templ.Component { + return authPage( + "Sign in", + "Welcome back", + "Sign in to manage backup infrastructure.", + "/signin", + "Sign in", + "/signup", + "Need an account? Sign up", + data, + ) +} + +func authPage( + title string, + heading string, + description string, + action string, + submitLabel string, + switchHref string, + switchLabel string, + data AuthPageData, +) templ.Component { + return templ.ComponentFunc(func(_ context.Context, w io.Writer) error { + errBlock := "" + if data.Error != "" { + errBlock = fmt.Sprintf(`

%s

`, html.EscapeString(data.Error)) + } + + _, err := io.WriteString(w, fmt.Sprintf(` + + + + + %s · Satoru + + + +
+

Satoru

+

%s

+

%s

+ %s +
+ + + +
+

%s

+
+ +`, + html.EscapeString(title), + html.EscapeString(heading), + html.EscapeString(description), + errBlock, + html.EscapeString(action), + html.EscapeString(data.Username), + html.EscapeString(submitLabel), + html.EscapeString(switchHref), + html.EscapeString(switchLabel), + )) + return err + }) +} diff --git a/internal/webui/dashboard.go b/internal/webui/dashboard.go new file mode 100644 index 0000000..4501a7d --- /dev/null +++ b/internal/webui/dashboard.go @@ -0,0 +1,179 @@ +package webui + +import ( + "context" + "fmt" + "html" + "io" + "strings" + "time" + + "github.com/a-h/templ" + + "satoru/internal/store" +) + +type RuntimeCheck struct { + Name string + Installed bool + Details string +} + +type WorkflowStage struct { + Title string + Description string +} + +type DashboardData struct { + Now time.Time + User store.User + Sites []store.Site + RuntimeChecks []RuntimeCheck + WorkflowStages []WorkflowStage + FlashMessage string +} + +func Dashboard(data DashboardData) templ.Component { + return templ.ComponentFunc(func(_ context.Context, w io.Writer) error { + role := "Operator" + if data.User.IsAdmin { + role = "Admin" + } + + var checks strings.Builder + for _, c := range data.RuntimeChecks { + status := "missing" + if c.Installed { + status = "installed" + } + checks.WriteString(fmt.Sprintf(`
  • %s%s
  • `, + status, + html.EscapeString(c.Name), + html.EscapeString(c.Details))) + } + + var flows strings.Builder + for _, s := range data.WorkflowStages { + flows.WriteString(fmt.Sprintf(`
  • %s

    %s

  • `, + html.EscapeString(s.Title), + html.EscapeString(s.Description))) + } + + var sites strings.Builder + if len(data.Sites) == 0 { + sites.WriteString(`

    No sites added yet. Add your first Linux SSH site below.

    `) + } + for _, site := range data.Sites { + last := "Never run" + if site.LastRunAt.Valid { + last = site.LastRunAt.Time.Local().Format(time.RFC1123) + } + runStatus := "pending" + if site.LastRunStatus.Valid { + runStatus = site.LastRunStatus.String + } + runOutput := "(no output yet)" + if site.LastRunOutput.Valid && strings.TrimSpace(site.LastRunOutput.String) != "" { + runOutput = site.LastRunOutput.String + } + + sites.WriteString(fmt.Sprintf(` +
    +
    +

    %s@%s

    +
    + +
    +
    +

    Path: %s

    +

    Last run: %s

    +

    Status: %s

    +
    %s
    +
    `, + html.EscapeString(site.SSHUser), + html.EscapeString(site.Host), + site.ID, + html.EscapeString(site.RemotePath), + html.EscapeString(last), + html.EscapeString(runStatus), + html.EscapeString(runStatus), + html.EscapeString(runOutput), + )) + } + + flash := "" + if data.FlashMessage != "" { + flash = fmt.Sprintf(`

    %s

    `, html.EscapeString(formatFlash(data.FlashMessage))) + } + + _, err := io.WriteString(w, fmt.Sprintf(` + + + + + Satoru Dashboard + + + +
    +
    +
    +

    Satoru

    +

    Managed Sites Overview

    +

    Signed in as %s (%s).

    +
    +
    + Change password +
    +
    +
    + %s +
    +

    Host Runtime Status

    +
      %s
    +
    +
    +

    Planned Workflow

    +
      %s
    +
    +
    +

    Add Site

    +
    + + + + +
    +
    +
    +

    Managed Sites

    + %s +
    +
    + +`, + html.EscapeString(data.User.Username), + html.EscapeString(role), + flash, + checks.String(), + flows.String(), + sites.String(), + )) + return err + }) +} + +func formatFlash(code string) string { + switch code { + case "site-added": + return "Site added." + case "site-ran": + return "Run completed." + case "site-invalid": + return "All site fields are required." + case "password-updated": + return "Password updated." + default: + return code + } +} diff --git a/internal/webui/home.go b/internal/webui/home.go new file mode 100644 index 0000000..7a97c61 --- /dev/null +++ b/internal/webui/home.go @@ -0,0 +1,55 @@ +package webui + +import ( + "context" + "fmt" + "html" + "io" + "time" + + "github.com/a-h/templ" + + "satoru/internal/store" +) + +func Home(now time.Time, user store.User) templ.Component { + return templ.ComponentFunc(func(_ context.Context, w io.Writer) error { + role := "Operator" + if user.IsAdmin { + role = "Admin" + } + + var body string + if user.ID == 0 { + body = `

    Sign in to continue.

    +
    + Sign in + Sign up +
    ` + } else { + body = fmt.Sprintf(`

    Signed in as %s (%s).

    +

    Current server time: %s

    +
    + +
    `, html.EscapeString(user.Username), role, now.Format(time.RFC1123)) + } + + _, err := io.WriteString(w, fmt.Sprintf(` + + + + + Satoru + + + +
    +

    Satoru

    +

    Backup Control Plane

    + %s +
    + +`, body)) + return err + }) +} diff --git a/internal/webui/password.go b/internal/webui/password.go new file mode 100644 index 0000000..b51aec0 --- /dev/null +++ b/internal/webui/password.go @@ -0,0 +1,64 @@ +package webui + +import ( + "context" + "fmt" + "html" + "io" + + "github.com/a-h/templ" + + "satoru/internal/store" +) + +type PasswordPageData struct { + User store.User + Error string +} + +func ChangePassword(data PasswordPageData) templ.Component { + return templ.ComponentFunc(func(_ context.Context, w io.Writer) error { + errBlock := "" + if data.Error != "" { + errBlock = fmt.Sprintf(`

    %s

    `, html.EscapeString(data.Error)) + } + + _, err := io.WriteString(w, fmt.Sprintf(` + + + + + Change Password · Satoru + + + +
    +

    Satoru

    +

    Change Password

    +

    User: %s

    + %s +
    + + + + +
    +

    Back to dashboard

    +
    + +`, + html.EscapeString(data.User.Username), + errBlock, + )) + return err + }) +} diff --git a/web/static/app.css b/web/static/app.css new file mode 100644 index 0000000..a209e87 --- /dev/null +++ b/web/static/app.css @@ -0,0 +1,230 @@ +:root { + color-scheme: light dark; + --bg: #0f172a; + --fg: #e2e8f0; + --muted: #94a3b8; + --card: #111827; + --border: #334155; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + display: grid; + place-items: center; + background: radial-gradient(circle at top, #1e293b 0, var(--bg) 55%); + color: var(--fg); + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +.card { + width: min(680px, calc(100vw - 2rem)); + border: 1px solid var(--border); + background: color-mix(in srgb, var(--card) 85%, black); + border-radius: 16px; + padding: 2rem; +} + +.dashboard-card { + width: min(980px, calc(100vw - 2rem)); +} + +.eyebrow { + margin: 0 0 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + font-size: 0.8rem; +} + +h1 { + margin: 0; + font-size: 2rem; +} + +.row { + display: flex; + gap: 0.75rem; + margin-top: 1rem; + align-items: center; +} + +.split { + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; +} + +.stack { + display: grid; + gap: 0.5rem; +} + +.auth-card { + width: min(480px, calc(100vw - 2rem)); +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + border: 1px solid transparent; + background: #38bdf8; + color: #082f49; + font-weight: 700; + text-decoration: none; + padding: 0.65rem 1rem; + cursor: pointer; +} + +.button:hover { + filter: brightness(1.05); +} + +.button.ghost { + background: transparent; + color: var(--fg); + border-color: var(--border); +} + +input { + width: 100%; + border: 1px solid var(--border); + border-radius: 10px; + background: color-mix(in srgb, var(--card) 75%, black); + color: var(--fg); + padding: 0.6rem 0.75rem; +} + +.error { + border: 1px solid #ef4444; + background: color-mix(in srgb, #ef4444 22%, transparent); + border-radius: 10px; + color: #fecaca; + margin: 0.75rem 0; + padding: 0.6rem 0.75rem; +} + +.notice { + border: 1px solid #22c55e; + background: color-mix(in srgb, #22c55e 18%, transparent); + border-radius: 10px; + color: #bbf7d0; + margin: 0.75rem 0; + padding: 0.6rem 0.75rem; +} + +section { + margin-top: 1.5rem; +} + +h2 { + margin: 0 0 0.75rem; + font-size: 1.2rem; +} + +.status-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.status { + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.6rem 0.75rem; + display: grid; + gap: 0.3rem; +} + +.status.installed { + border-color: #22c55e; +} + +.status.missing { + border-color: #ef4444; +} + +.workflow { + margin: 0; + padding-left: 1.2rem; + display: grid; + gap: 0.75rem; +} + +.workflow-step p { + margin: 0.4rem 0 0; + color: var(--muted); +} + +.grid-3 { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 0.75rem; + align-items: end; +} + +.site-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 0.9rem; + margin-top: 0.8rem; +} + +.site-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; +} + +.site-head h3 { + margin: 0; +} + +.pill { + display: inline-block; + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.04em; + border: 1px solid var(--border); + border-radius: 999px; + padding: 0.15rem 0.55rem; +} + +.pill.ok { + border-color: #22c55e; + color: #86efac; +} + +.pill.failed { + border-color: #ef4444; + color: #fecaca; +} + +.output { + white-space: pre-wrap; + overflow-wrap: anywhere; + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.6rem; + margin-top: 0.6rem; + background: color-mix(in srgb, var(--card) 80%, black); +} + +.muted { + margin: 0.75rem 0 0; + color: var(--muted); +} + +a { + color: #7dd3fc; +}