Every number the collector samples becomes a sensor with a stable dotted ID (cpu.core.3.usage, fan.nct6775.fan2, fs.root.used_pct). Clients discover them from the catalog and ask for exactly what they want, including as a shaped query: send the object you want, get it back with the values filled in. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1642 lines
48 KiB
Go
1642 lines
48 KiB
Go
// Package api is the HTTP/WebSocket surface: auth, terminal CRUD, attach, static UI.
|
|
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/http/pprof"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"hamlab/internal/auth"
|
|
"hamlab/internal/config"
|
|
"hamlab/internal/cron"
|
|
"hamlab/internal/files"
|
|
"hamlab/internal/metrics"
|
|
"hamlab/internal/nodes"
|
|
"hamlab/internal/priv"
|
|
"hamlab/internal/sensors"
|
|
"hamlab/internal/services"
|
|
"hamlab/internal/setup"
|
|
"hamlab/internal/storage"
|
|
"hamlab/internal/store"
|
|
"hamlab/internal/tasks"
|
|
"hamlab/internal/term"
|
|
"hamlab/internal/updates"
|
|
)
|
|
|
|
const cookieName = "hamlab_session"
|
|
|
|
type Server struct {
|
|
// Restart replaces the running process with the binary on disk; it only returns on failure.
|
|
Restart func() error
|
|
|
|
cfg *config.Config
|
|
sessions *auth.Sessions
|
|
limiter auth.Limiter
|
|
mgr *term.Manager
|
|
metrics *metrics.Collector
|
|
sensors *sensors.Registry
|
|
store *store.Store
|
|
svc *services.Manager
|
|
cron *cron.Manager
|
|
tasks *tasks.Manager
|
|
updates *updates.Manager
|
|
storage *storage.Manager
|
|
fsLocal files.FS
|
|
fsSudo files.FS // nil without passwordless sudo
|
|
nodes *nodes.Manager
|
|
fingerprint string
|
|
version string
|
|
started time.Time
|
|
user string
|
|
host string
|
|
ui fs.FS
|
|
mux *http.ServeMux
|
|
}
|
|
|
|
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, nm *nodes.Manager, fingerprint string, ui fs.FS, version string) *Server {
|
|
host, _ := os.Hostname()
|
|
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, sensors: sensors.NewRegistry(col), store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, nodes: nm, fingerprint: fingerprint, ui: ui, version: version,
|
|
started: time.Now(), host: host, user: os.Getenv("USER")}
|
|
if u, err := user.Current(); err == nil && u.Username != "" {
|
|
s.user = u.Username
|
|
}
|
|
if s.user == "" {
|
|
s.user = "?"
|
|
}
|
|
// The saved dashboard layout carries the sampling interval; apply it on boot.
|
|
var raw json.RawMessage
|
|
st.View(func(x *store.State) { raw = x.Dashboard })
|
|
s.applyPrefs(raw)
|
|
|
|
m := http.NewServeMux()
|
|
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
|
m.HandleFunc("POST /api/login", s.login)
|
|
m.HandleFunc("POST /api/tokens", s.issueToken)
|
|
m.HandleFunc("GET /api/tokens", s.auth(s.listTokens))
|
|
m.HandleFunc("DELETE /api/tokens/{name}", s.auth(s.revokeToken))
|
|
m.HandleFunc("GET /api/nodes", s.auth(s.listNodes))
|
|
m.HandleFunc("POST /api/nodes", s.auth(s.addNode))
|
|
m.HandleFunc("DELETE /api/nodes/{name}", s.auth(s.removeNode))
|
|
m.HandleFunc("/api/nodes/{name}/{rest...}", s.auth(s.proxyNode))
|
|
m.HandleFunc("POST /api/self-update", s.auth(s.selfUpdate))
|
|
m.HandleFunc("POST /api/machines/{name}/update", s.auth(s.updateMachine))
|
|
m.HandleFunc("POST /api/machines-update-all", s.auth(s.updateAllMachines))
|
|
m.HandleFunc("POST /api/logout", s.auth(s.logout))
|
|
m.HandleFunc("GET /api/me", s.auth(s.me))
|
|
m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals))
|
|
m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal))
|
|
m.HandleFunc("PATCH /api/terminals/{id}", s.auth(s.renameTerminal))
|
|
m.HandleFunc("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal))
|
|
m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach))
|
|
m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest))
|
|
m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory))
|
|
m.HandleFunc("GET /api/sensors", s.auth(s.sensorCatalog))
|
|
m.HandleFunc("GET /api/sensors/query", s.auth(s.sensorQuery))
|
|
m.HandleFunc("POST /api/sensors/query", s.auth(s.sensorQuery))
|
|
m.HandleFunc("GET /api/sensors/{id}", s.auth(s.sensorOne))
|
|
m.HandleFunc("GET /api/sensors/{id}/history", s.auth(s.sensorHistory))
|
|
m.HandleFunc("GET /api/services", s.auth(s.listServices))
|
|
m.HandleFunc("GET /api/services/{unit}", s.auth(s.showService))
|
|
m.HandleFunc("GET /api/services/{unit}/file", s.auth(s.serviceFile))
|
|
m.HandleFunc("GET /api/services/{unit}/logs", s.auth(s.serviceLogs))
|
|
m.HandleFunc("GET /api/services/{unit}/logs/tail", s.auth(s.serviceTail))
|
|
m.HandleFunc("POST /api/services/{unit}/{action}", s.auth(s.serviceAction))
|
|
m.HandleFunc("GET /api/cron", s.auth(s.cronOverview))
|
|
m.HandleFunc("GET /api/cron/source", s.auth(s.cronSource))
|
|
m.HandleFunc("PUT /api/cron/source", s.auth(s.cronWriteSource))
|
|
m.HandleFunc("GET /api/cron/jobs/{id}/logs", s.auth(s.cronJobLogs))
|
|
m.HandleFunc("GET /api/cron/jobs/{id}/output", s.auth(s.cronJobOutput))
|
|
m.HandleFunc("POST /api/cron/jobs/{id}/{action}", s.auth(s.cronJobAction))
|
|
m.HandleFunc("GET /api/cron/runs/{id}", s.auth(s.cronRun))
|
|
m.HandleFunc("POST /api/cron/timers/{unit}/{action}", s.auth(s.cronTimerAction))
|
|
m.HandleFunc("GET /api/tasks", s.auth(s.listTasks))
|
|
m.HandleFunc("GET /api/tasks/{id}", s.auth(s.getTask))
|
|
m.HandleFunc("GET /api/tasks/{id}/stream", s.auth(s.streamTask))
|
|
m.HandleFunc("POST /api/tasks/{id}/cancel", s.auth(s.cancelTask))
|
|
m.HandleFunc("GET /api/updates", s.auth(s.updatesStatus))
|
|
m.HandleFunc("POST /api/updates/refresh", s.auth(s.updatesRefresh))
|
|
m.HandleFunc("POST /api/updates/upgrade", s.auth(s.updatesUpgrade))
|
|
m.HandleFunc("GET /api/updates/changelog", s.auth(s.updatesChangelog))
|
|
m.HandleFunc("POST /api/power/{action}", s.auth(s.power))
|
|
m.HandleFunc("POST /api/updates/install", s.auth(s.updatesInstall))
|
|
m.HandleFunc("GET /api/files", s.auth(s.filesList))
|
|
m.HandleFunc("GET /api/files/stat", s.auth(s.filesStat))
|
|
m.HandleFunc("GET /api/files/read", s.auth(s.filesRead))
|
|
m.HandleFunc("GET /api/files/download", s.auth(s.filesRead))
|
|
m.HandleFunc("PUT /api/files/write", s.auth(s.filesWrite))
|
|
m.HandleFunc("PUT /api/files/upload", s.auth(s.filesUpload))
|
|
m.HandleFunc("POST /api/files/mkdir", s.auth(s.filesMkdir))
|
|
m.HandleFunc("POST /api/files/touch", s.auth(s.filesTouch))
|
|
m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename))
|
|
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
|
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
|
m.HandleFunc("GET /api/storage", s.auth(s.storageOverview))
|
|
m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart))
|
|
m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest))
|
|
m.HandleFunc("GET /api/storage/zfs/snapshots", s.auth(s.zfsSnapshots))
|
|
m.HandleFunc("POST /api/storage/zfs/scrub/{pool}/{action}", s.auth(s.zfsScrub))
|
|
m.HandleFunc("POST /api/storage/zfs/snapshot", s.auth(s.zfsSnapshot))
|
|
m.HandleFunc("GET /api/procs", s.auth(s.procList))
|
|
m.HandleFunc("POST /api/procs/{pid}/signal", s.auth(s.procSignal))
|
|
m.HandleFunc("POST /api/password", s.auth(s.changePassword))
|
|
m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs))
|
|
m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs))
|
|
// Go runtime profiles, behind the login, for chasing memory/CPU (bug 14).
|
|
m.Handle("/api/debug/pprof/", s.auth(func(w http.ResponseWriter, r *http.Request) {
|
|
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api")
|
|
pprof.Index(w, r)
|
|
}))
|
|
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
|
|
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
|
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
|
m.HandleFunc("/", s.static)
|
|
s.mux = m
|
|
return s
|
|
}
|
|
|
|
// Handler wraps the mux with headers a root-capable console should always send.
|
|
func (s *Server) Handler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("Referrer-Policy", "no-referrer")
|
|
s.mux.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func jsonOK(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func jsonErr(w http.ResponseWriter, code int, msg string) {
|
|
jsonOK(w, code, map[string]string{"error": msg})
|
|
}
|
|
|
|
func secure(r *http.Request) bool {
|
|
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
|
}
|
|
|
|
func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, maxAge int) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: cookieName, Value: tok, Path: "/", MaxAge: maxAge,
|
|
HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteStrictMode,
|
|
})
|
|
}
|
|
|
|
// auth accepts the browser session cookie or a bearer API token issued to a hub.
|
|
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(cookieName); err == nil && s.sessions.Valid(c.Value) {
|
|
next(w, r)
|
|
return
|
|
}
|
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
|
var hashes []string
|
|
s.cfg.View(func(c *config.Config) {
|
|
for _, t := range c.Tokens {
|
|
hashes = append(hashes, t.Hash)
|
|
}
|
|
})
|
|
if auth.TokenMatches(strings.TrimPrefix(h, "Bearer "), hashes) {
|
|
next(w, r)
|
|
return
|
|
}
|
|
}
|
|
jsonErr(w, 401, "unauthenticated")
|
|
}
|
|
}
|
|
|
|
// ---- API tokens: how a hub authenticates to this node ----
|
|
|
|
// issueToken trades the password for a long-lived token, named by the caller (a hub).
|
|
// Rate-limited like login; an existing token with the same name is replaced.
|
|
func (s *Server) issueToken(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.limiter.Allow(clientKey(r)); err != nil {
|
|
jsonErr(w, 429, "too many attempts, try again later")
|
|
return
|
|
}
|
|
var body struct {
|
|
Password string `json:"password"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
|
jsonErr(w, 400, "password and name required")
|
|
return
|
|
}
|
|
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
|
|
s.limiter.Fail(clientKey(r))
|
|
log.Printf("token request failed from %s", clientIP(r))
|
|
jsonErr(w, 401, "wrong password")
|
|
return
|
|
}
|
|
s.limiter.Reset(clientKey(r))
|
|
tok, hash, err := auth.NewToken()
|
|
if err != nil {
|
|
jsonErr(w, 500, "token error")
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
if err := s.cfg.Update(func(c *config.Config) {
|
|
kept := c.Tokens[:0]
|
|
for _, t := range c.Tokens {
|
|
if t.Name != name {
|
|
kept = append(kept, t)
|
|
}
|
|
}
|
|
c.Tokens = append(kept, config.Token{Name: name, Hash: hash, Created: time.Now()})
|
|
}); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
log.Printf("API token %q issued to %s", name, clientIP(r))
|
|
jsonOK(w, 200, map[string]string{"token": tok, "name": name})
|
|
}
|
|
|
|
func (s *Server) listTokens(w http.ResponseWriter, _ *http.Request) {
|
|
var out []map[string]any
|
|
s.cfg.View(func(c *config.Config) {
|
|
for _, t := range c.Tokens {
|
|
out = append(out, map[string]any{"name": t.Name, "created": t.Created})
|
|
}
|
|
})
|
|
if out == nil {
|
|
out = []map[string]any{}
|
|
}
|
|
jsonOK(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
found := false
|
|
_ = s.cfg.Update(func(c *config.Config) {
|
|
kept := c.Tokens[:0]
|
|
for _, t := range c.Tokens {
|
|
if t.Name == name {
|
|
found = true
|
|
continue
|
|
}
|
|
kept = append(kept, t)
|
|
}
|
|
c.Tokens = kept
|
|
})
|
|
if !found {
|
|
jsonErr(w, 404, "no such token")
|
|
return
|
|
}
|
|
log.Printf("API token %q revoked (from %s)", name, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// ---- nodes: the machines this hub manages ----
|
|
|
|
func (s *Server) listNodes(w http.ResponseWriter, _ *http.Request) {
|
|
jsonOK(w, 200, map[string]any{"nodes": s.nodes.List(), "host": s.host, "fingerprint": s.fingerprint})
|
|
}
|
|
|
|
func (s *Server) addNode(w http.ResponseWriter, r *http.Request) {
|
|
var body struct{ Name, URL, Password string }
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
|
jsonErr(w, 400, "bad request")
|
|
return
|
|
}
|
|
st, err := s.nodes.Add(r.Context(), strings.TrimSpace(body.Name), body.URL, body.Password)
|
|
if err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("nodes: added %s (%s) from %s", st.Name, st.URL, clientIP(r))
|
|
jsonOK(w, 201, st)
|
|
}
|
|
|
|
func (s *Server) removeNode(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if err := s.nodes.Remove(r.Context(), name); err != nil {
|
|
jsonErr(w, 404, err.Error())
|
|
return
|
|
}
|
|
log.Printf("nodes: removed %s (from %s)", name, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// selfUpdate receives a new binary from a hub, checks it runs, installs it over the
|
|
// current executable (keeping a .prev copy) and restarts the service.
|
|
func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("force") != "1" {
|
|
if t, ok := s.tasks.RunningOf("updates"); ok {
|
|
jsonErr(w, 409, "busy: a package task is running ("+t.Title+"); retry with force to interrupt it")
|
|
return
|
|
}
|
|
attached := 0
|
|
for _, t := range s.mgr.List() {
|
|
attached += t.Clients
|
|
}
|
|
if attached > 0 {
|
|
jsonErr(w, 409, fmt.Sprintf("busy: %d terminal client(s) attached; retry with force to drop them", attached))
|
|
return
|
|
}
|
|
}
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
exe, _ = filepath.EvalSymlinks(exe)
|
|
tmp := filepath.Join(s.cfg.DataDir(), "hamlab.new")
|
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
n, err := io.Copy(f, io.LimitReader(r.Body, 512<<20))
|
|
f.Close()
|
|
if err != nil || n < 1<<20 {
|
|
os.Remove(tmp)
|
|
jsonErr(w, 400, "upload failed or too small to be hamlab")
|
|
return
|
|
}
|
|
// It must at least run and identify itself before it replaces us.
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, tmp, "version").Output()
|
|
if err != nil || !strings.HasPrefix(string(out), "hamlab ") {
|
|
os.Remove(tmp)
|
|
jsonErr(w, 400, "uploaded file is not a working hamlab binary for this machine ("+runtime.GOARCH+")")
|
|
return
|
|
}
|
|
newVersion := strings.TrimSpace(strings.TrimPrefix(string(out), "hamlab "))
|
|
// Builds that have `check` must also load this machine's config and state.
|
|
if msg, err := exec.CommandContext(ctx, tmp, "-config", s.cfg.Path(), "check").CombinedOutput(); err != nil && !bytes.Contains(msg, []byte("usage:")) {
|
|
os.Remove(tmp)
|
|
jsonErr(w, 400, "the new build can't start here: "+strings.TrimSpace(string(msg)))
|
|
return
|
|
}
|
|
prev := filepath.Join(s.cfg.DataDir(), "hamlab.prev")
|
|
_ = copyFile(exe, prev)
|
|
// Atomic replace, by the new binary itself: directly when the directory is ours
|
|
// (root, or a user-owned prefix), else as root.
|
|
var msg []byte
|
|
if err = setup.CopyAtomic(tmp, exe); err != nil && priv.Available() {
|
|
full := priv.Wrap(tmp, "replace", exe)
|
|
if msg, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput(); err != nil { // a build from before `replace`
|
|
full = priv.Wrap("install", "-m", "0755", tmp, exe)
|
|
msg, err = exec.CommandContext(ctx, full[0], full[1:]...).CombinedOutput()
|
|
}
|
|
}
|
|
os.Remove(tmp)
|
|
if err != nil {
|
|
jsonErr(w, 500, "install failed: "+strings.TrimSpace(string(msg)+" "+err.Error()))
|
|
return
|
|
}
|
|
log.Printf("self-update: installed %s (was %s) from %s; restarting", newVersion, s.version, clientIP(r))
|
|
jsonOK(w, 200, map[string]string{"version": newVersion, "previous": s.version})
|
|
go func() {
|
|
time.Sleep(800 * time.Millisecond)
|
|
if s.Restart != nil {
|
|
log.Printf("self-update: exec failed (%v); putting %s back", s.Restart(), s.version)
|
|
full := priv.Wrap(prev, "replace", exe)
|
|
_ = exec.Command(full[0], full[1:]...).Run()
|
|
}
|
|
os.Exit(1) // the supervisor starts whatever is on disk
|
|
}()
|
|
}
|
|
|
|
func copyFile(src, dst string) error {
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
out.Close()
|
|
return err
|
|
}
|
|
return out.Close()
|
|
}
|
|
|
|
func (s *Server) updateMachine(w http.ResponseWriter, r *http.Request) {
|
|
exe, _ := os.Executable()
|
|
res := s.nodes.Push(r.Context(), r.PathValue("name"), exe, runtime.GOARCH, r.URL.Query().Get("force") == "1")
|
|
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", res.Name, res.OK, res.Version, res.Error, clientIP(r))
|
|
if !res.OK {
|
|
code := 400
|
|
if strings.HasPrefix(res.Error, "busy:") {
|
|
code = 409
|
|
}
|
|
jsonErr(w, code, res.Error)
|
|
return
|
|
}
|
|
jsonOK(w, 200, res)
|
|
}
|
|
|
|
func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
|
|
exe, _ := os.Executable()
|
|
res := s.nodes.PushAll(r.Context(), exe, runtime.GOARCH, r.URL.Query().Get("force") == "1")
|
|
for _, x := range res {
|
|
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", x.Name, x.OK, x.Version, x.Error, clientIP(r))
|
|
}
|
|
jsonOK(w, 200, res)
|
|
}
|
|
|
|
func (s *Server) proxyNode(w http.ResponseWriter, r *http.Request) {
|
|
p, ok := s.nodes.Proxy(r.PathValue("name"))
|
|
if !ok {
|
|
jsonErr(w, 404, "no such machine")
|
|
return
|
|
}
|
|
p.ServeHTTP(w, r)
|
|
}
|
|
|
|
// ---- auth ----
|
|
|
|
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.limiter.Allow(clientKey(r)); err != nil {
|
|
jsonErr(w, 429, "too many attempts, try again later")
|
|
return
|
|
}
|
|
var body struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
|
jsonErr(w, 400, "bad request")
|
|
return
|
|
}
|
|
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
|
|
s.limiter.Fail(clientKey(r))
|
|
log.Printf("login failed from %s", clientIP(r))
|
|
jsonErr(w, 401, "wrong password")
|
|
return
|
|
}
|
|
s.limiter.Reset(clientKey(r))
|
|
tok, err := s.sessions.Create()
|
|
if err != nil {
|
|
jsonErr(w, 500, "session error")
|
|
return
|
|
}
|
|
s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds()))
|
|
log.Printf("login ok from %s", clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(cookieName); err == nil {
|
|
s.sessions.Delete(c.Value)
|
|
}
|
|
s.setCookie(w, r, "", -1)
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// clientKey is the address the limiter buckets by: the first X-Forwarded-For hop when a
|
|
// reverse proxy set it, else the socket peer.
|
|
func clientKey(r *http.Request) string {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
func clientIP(r *http.Request) string {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
// ---- me ----
|
|
|
|
func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
|
|
load := ""
|
|
if b, err := os.ReadFile("/proc/loadavg"); err == nil {
|
|
f := strings.Fields(string(b))
|
|
if len(f) >= 3 {
|
|
load = strings.Join(f[:3], " ")
|
|
}
|
|
}
|
|
uptime := 0.0
|
|
if b, err := os.ReadFile("/proc/uptime"); err == nil {
|
|
if f := strings.Fields(string(b)); len(f) > 0 {
|
|
uptime, _ = json.Number(f[0]).Float64()
|
|
}
|
|
}
|
|
_, rebootErr := os.Stat("/run/reboot-required")
|
|
jsonOK(w, 200, map[string]any{
|
|
"host": s.host, "user": s.user, "version": s.version,
|
|
"load": load, "uptime": uptime, "service_started": s.started,
|
|
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH,
|
|
})
|
|
}
|
|
|
|
// ---- metrics (Dashboard) ----
|
|
|
|
func (s *Server) metricsLatest(w http.ResponseWriter, _ *http.Request) {
|
|
smp, ok := s.metrics.Latest()
|
|
if !ok {
|
|
jsonErr(w, 503, "no sample yet")
|
|
return
|
|
}
|
|
jsonOK(w, 200, smp)
|
|
}
|
|
|
|
func (s *Server) metricsHistory(w http.ResponseWriter, _ *http.Request) {
|
|
jsonOK(w, 200, s.metrics.History())
|
|
}
|
|
|
|
// ---- sensors: the standard read interface (SENSORS.md) ----
|
|
|
|
// sensorCatalog lists every sensor with its current value; ?ids= narrows it with
|
|
// comma-separated IDs or globs.
|
|
func (s *Server) sensorCatalog(w http.ResponseWriter, r *http.Request) {
|
|
all := s.sensors.All()
|
|
if ids := r.URL.Query().Get("ids"); ids != "" {
|
|
picked := []sensors.Sensor{}
|
|
for _, sel := range strings.Split(ids, ",") {
|
|
got, err := sensors.Select(all, strings.TrimSpace(sel))
|
|
if err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
picked = append(picked, got...)
|
|
}
|
|
all = picked
|
|
}
|
|
jsonOK(w, 200, map[string]any{"sensors": all})
|
|
}
|
|
|
|
// sensorOne is a single sensor; ?format=text is the bare value, for shell scripts and
|
|
// anything else that can only fetch a URL and read a number.
|
|
func (s *Server) sensorOne(w http.ResponseWriter, r *http.Request) {
|
|
got, _ := sensors.Select(s.sensors.All(), r.PathValue("id"))
|
|
if len(got) != 1 {
|
|
jsonErr(w, 404, "no such sensor")
|
|
return
|
|
}
|
|
if r.URL.Query().Get("format") == "text" {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
fmt.Fprintln(w, got[0].Value)
|
|
return
|
|
}
|
|
jsonOK(w, 200, got[0])
|
|
}
|
|
|
|
func (s *Server) sensorHistory(w http.ResponseWriter, r *http.Request) {
|
|
h, ok := s.sensors.History(r.PathValue("id"))
|
|
if !ok {
|
|
jsonErr(w, 404, "no history for that sensor")
|
|
return
|
|
}
|
|
jsonOK(w, 200, h)
|
|
}
|
|
|
|
// sensorQuery answers a shaped query. POST sends the shape as the body; GET builds a
|
|
// flat one from the query string (?cpu=cpu.usage&fans=fan.*). ?full=1 returns whole
|
|
// sensors instead of bare values.
|
|
func (s *Server) sensorQuery(w http.ResponseWriter, r *http.Request) {
|
|
full := r.URL.Query().Get("full") == "1"
|
|
var q json.RawMessage
|
|
if r.Method == http.MethodPost {
|
|
b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
|
|
if err != nil {
|
|
jsonErr(w, 413, "query larger than 64 KiB")
|
|
return
|
|
}
|
|
q = b
|
|
} else {
|
|
flat := map[string]string{}
|
|
for k, v := range r.URL.Query() {
|
|
if k != "full" && len(v) > 0 {
|
|
flat[k] = v[0]
|
|
}
|
|
}
|
|
q, _ = json.Marshal(flat)
|
|
}
|
|
out, err := sensors.Shape(s.sensors.All(), q, full)
|
|
if err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, out)
|
|
}
|
|
|
|
// ---- services (systemd) ----
|
|
|
|
func (s *Server) listServices(w http.ResponseWriter, r *http.Request) {
|
|
list, err := s.svc.List(r.Context())
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, map[string]any{"units": list, "sudo": s.svc.Sudo()})
|
|
}
|
|
|
|
func (s *Server) showService(w http.ResponseWriter, r *http.Request) {
|
|
d, err := s.svc.Show(r.Context(), r.PathValue("unit"))
|
|
if err != nil {
|
|
jsonErr(w, 404, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, d)
|
|
}
|
|
|
|
func (s *Server) serviceFile(w http.ResponseWriter, r *http.Request) {
|
|
out, err := s.svc.Cat(r.Context(), r.PathValue("unit"))
|
|
if err != nil {
|
|
jsonErr(w, 404, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, map[string]string{"content": out})
|
|
}
|
|
|
|
func logCount(r *http.Request) int {
|
|
n, _ := strconv.Atoi(r.URL.Query().Get("n"))
|
|
if n <= 0 {
|
|
n = 200
|
|
}
|
|
if n > 5000 {
|
|
n = 5000
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (s *Server) serviceLogs(w http.ResponseWriter, r *http.Request) {
|
|
entries, err := s.svc.Journal(r.Context(), r.PathValue("unit"), logCount(r))
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, entries)
|
|
}
|
|
|
|
// serviceTail streams journal entries as JSON text frames until the client goes away.
|
|
func (s *Server) serviceTail(w http.ResponseWriter, r *http.Request) {
|
|
unit := r.PathValue("unit")
|
|
if !services.Valid(unit) {
|
|
jsonErr(w, 400, "invalid unit name")
|
|
return
|
|
}
|
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
defer cancel()
|
|
go func() { // reader: only to notice the close (and keep pings flowing)
|
|
defer cancel()
|
|
for {
|
|
if _, _, err := conn.Read(ctx); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
t := time.NewTicker(25 * time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
|
|
err := conn.Ping(pctx)
|
|
pc()
|
|
if err != nil {
|
|
cancel()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
_ = s.svc.Tail(ctx, unit, logCount(r), func(e services.Entry) {
|
|
b, _ := json.Marshal(e)
|
|
if err := conn.Write(ctx, websocket.MessageText, b); err != nil {
|
|
cancel()
|
|
}
|
|
})
|
|
}
|
|
|
|
func (s *Server) serviceAction(w http.ResponseWriter, r *http.Request) {
|
|
unit, action := r.PathValue("unit"), r.PathValue("action")
|
|
if err := s.svc.Action(r.Context(), action, unit); err != nil {
|
|
log.Printf("systemctl %s %s: %v", action, unit, err)
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("systemctl %s %s ok (from %s)", action, unit, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// ---- cron (crontabs + systemd timers) ----
|
|
|
|
func (s *Server) cronOverview(w http.ResponseWriter, r *http.Request) {
|
|
jsonOK(w, 200, s.cron.Overview(r.Context()))
|
|
}
|
|
|
|
func (s *Server) cronSource(w http.ResponseWriter, r *http.Request) {
|
|
text, editable, err := s.cron.Source(r.Context(), r.URL.Query().Get("name"))
|
|
if err != nil {
|
|
jsonErr(w, 404, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, map[string]any{"text": text, "editable": editable})
|
|
}
|
|
|
|
func (s *Server) cronWriteSource(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
|
jsonErr(w, 400, "bad request")
|
|
return
|
|
}
|
|
name := r.URL.Query().Get("name")
|
|
if err := s.cron.WriteSource(r.Context(), name, body.Text); err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("cron: wrote %s (from %s)", name, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
func (s *Server) cronJob(w http.ResponseWriter, r *http.Request) (cron.Job, bool) {
|
|
j, ok := s.cron.FindJob(r.Context(), r.PathValue("id"))
|
|
if !ok {
|
|
jsonErr(w, 404, "no such job (crontab may have changed; reload)")
|
|
}
|
|
return j, ok
|
|
}
|
|
|
|
func (s *Server) cronJobLogs(w http.ResponseWriter, r *http.Request) {
|
|
j, ok := s.cronJob(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
entries, err := s.cron.JobLogs(r.Context(), j, logCount(r))
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, entries)
|
|
}
|
|
|
|
func (s *Server) cronJobOutput(w http.ResponseWriter, r *http.Request) {
|
|
j, ok := s.cronJob(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
out, err := s.cron.OutputTail(r.Context(), j, logCount(r))
|
|
if err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, map[string]string{"file": j.OutFile, "text": out})
|
|
}
|
|
|
|
func (s *Server) cronJobAction(w http.ResponseWriter, r *http.Request) {
|
|
j, ok := s.cronJob(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
action := r.PathValue("action")
|
|
var err error
|
|
switch action {
|
|
case "enable", "disable":
|
|
err = s.cron.Toggle(r.Context(), j, action == "enable")
|
|
case "run":
|
|
var run *cron.Run
|
|
if run, err = s.cron.StartRun(r.Context(), j); err == nil {
|
|
log.Printf("cron: run now %q as %s (from %s)", j.Command, j.User, clientIP(r))
|
|
jsonOK(w, 202, run.Snapshot())
|
|
return
|
|
}
|
|
default:
|
|
jsonErr(w, 400, "unknown action")
|
|
return
|
|
}
|
|
if err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("cron: %s %q in %s (from %s)", action, j.Command, j.Source, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
func (s *Server) cronRun(w http.ResponseWriter, r *http.Request) {
|
|
run, ok := s.cron.GetRun(r.PathValue("id"))
|
|
if !ok {
|
|
jsonErr(w, 404, "no such run")
|
|
return
|
|
}
|
|
jsonOK(w, 200, run.Snapshot())
|
|
}
|
|
|
|
func (s *Server) cronTimerAction(w http.ResponseWriter, r *http.Request) {
|
|
unit, action := r.PathValue("unit"), r.PathValue("action")
|
|
if err := s.cron.TimerAction(r.Context(), unit, action); err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("timer %s %s ok (from %s)", action, unit, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// ---- tasks (background commands with followable output) ----
|
|
|
|
func (s *Server) listTasks(w http.ResponseWriter, _ *http.Request) { jsonOK(w, 200, s.tasks.List()) }
|
|
|
|
func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
|
t, ok := s.tasks.Get(r.PathValue("id"))
|
|
if !ok {
|
|
jsonErr(w, 404, "no such task")
|
|
return
|
|
}
|
|
jsonOK(w, 200, t.Snapshot(true))
|
|
}
|
|
|
|
func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
|
|
t, ok := s.tasks.Get(r.PathValue("id"))
|
|
if !ok {
|
|
jsonErr(w, 404, "no such task")
|
|
return
|
|
}
|
|
t.Cancel()
|
|
log.Printf("task %s cancelled (from %s)", t.ID, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// streamTask sends the output so far, then live chunks as binary frames, then {"type":"exit"}.
|
|
func (s *Server) streamTask(w http.ResponseWriter, r *http.Request) {
|
|
t, ok := s.tasks.Get(r.PathValue("id"))
|
|
if !ok {
|
|
jsonErr(w, 404, "no such task")
|
|
return
|
|
}
|
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
defer cancel()
|
|
go func() {
|
|
defer cancel()
|
|
for {
|
|
if _, _, err := conn.Read(ctx); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
backlog, ch, unsub := t.Subscribe()
|
|
defer unsub()
|
|
if len(backlog) > 0 {
|
|
if err := conn.Write(ctx, websocket.MessageBinary, backlog); err != nil {
|
|
return
|
|
}
|
|
}
|
|
ping := time.NewTicker(25 * time.Second)
|
|
defer ping.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ping.C:
|
|
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
|
|
err := conn.Ping(pctx)
|
|
pc()
|
|
if err != nil {
|
|
return
|
|
}
|
|
case chunk, ok := <-ch:
|
|
if !ok {
|
|
snap := t.Snapshot(false)
|
|
b, _ := json.Marshal(map[string]any{"type": "exit", "code": snap.ExitCode, "error": snap.Err})
|
|
_ = conn.Write(ctx, websocket.MessageText, b)
|
|
return
|
|
}
|
|
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- updates ----
|
|
|
|
func (s *Server) updatesStatus(w http.ResponseWriter, r *http.Request) {
|
|
jsonOK(w, 200, s.updates.Status(r.Context()))
|
|
}
|
|
|
|
func (s *Server) updatesRefresh(w http.ResponseWriter, r *http.Request) {
|
|
t, err := s.updates.Refresh()
|
|
if err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("updates: refresh started as task %s (from %s)", t.ID, clientIP(r))
|
|
jsonOK(w, 202, t.Snapshot(false))
|
|
}
|
|
|
|
func (s *Server) updatesUpgrade(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Packages []string `json:"packages"`
|
|
}
|
|
_ = json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body)
|
|
t, err := s.updates.Upgrade(body.Packages)
|
|
if err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("updates: %s started as task %s (from %s)", t.Title, t.ID, clientIP(r))
|
|
jsonOK(w, 202, t.Snapshot(false))
|
|
}
|
|
|
|
func (s *Server) updatesChangelog(w http.ResponseWriter, r *http.Request) {
|
|
text, err := s.updates.Changelog(r.Context(), r.URL.Query().Get("pkg"))
|
|
if err != nil && text == "" {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, map[string]string{"text": text})
|
|
}
|
|
|
|
// power: reboot/poweroff, gated on the hostname being typed back.
|
|
func (s *Server) power(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Confirm string `json:"confirm"`
|
|
}
|
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
|
if body.Confirm != s.host {
|
|
jsonErr(w, 400, "confirmation must be the hostname")
|
|
return
|
|
}
|
|
action := r.PathValue("action")
|
|
log.Printf("POWER: %s requested from %s", action, clientIP(r))
|
|
if err := s.updates.Power(r.Context(), action); err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// ---- files ----
|
|
|
|
// fsFor picks the backend: ?sudo=1 asks for root, which needs passwordless sudo.
|
|
func (s *Server) fsFor(w http.ResponseWriter, r *http.Request) (files.FS, bool) {
|
|
if r.URL.Query().Get("sudo") == "1" {
|
|
if s.fsSudo == nil {
|
|
jsonErr(w, 403, "root access needs passwordless sudo on the host")
|
|
return nil, false
|
|
}
|
|
return s.fsSudo, true
|
|
}
|
|
return s.fsLocal, true
|
|
}
|
|
|
|
func fsErr(w http.ResponseWriter, err error) {
|
|
code := 400
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist):
|
|
code = 404
|
|
case errors.Is(err, os.ErrPermission), strings.Contains(err.Error(), "permission denied"):
|
|
code = 403
|
|
}
|
|
msg := err.Error()
|
|
var pe *os.PathError
|
|
var le *os.LinkError
|
|
switch {
|
|
case errors.As(err, &pe): // "open /x: permission denied" -> "permission denied"
|
|
msg = pe.Err.Error()
|
|
case errors.As(err, &le): // "rename /a /b: no such file" -> "no such file or directory"
|
|
msg = le.Err.Error()
|
|
}
|
|
jsonErr(w, code, msg)
|
|
}
|
|
|
|
func cleanQuery(w http.ResponseWriter, r *http.Request, key string) (string, bool) {
|
|
p, err := files.Clean(r.URL.Query().Get(key))
|
|
if err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return "", false
|
|
}
|
|
return p, true
|
|
}
|
|
|
|
func (s *Server) filesList(w http.ResponseWriter, r *http.Request) {
|
|
fsys, ok := s.fsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
p, ok := cleanQuery(w, r, "path")
|
|
if !ok {
|
|
return
|
|
}
|
|
l, err := fsys.List(r.Context(), p)
|
|
if err != nil {
|
|
fsErr(w, err)
|
|
return
|
|
}
|
|
jsonOK(w, 200, l)
|
|
}
|
|
|
|
func (s *Server) filesStat(w http.ResponseWriter, r *http.Request) {
|
|
fsys, ok := s.fsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
p, ok := cleanQuery(w, r, "path")
|
|
if !ok {
|
|
return
|
|
}
|
|
in, err := fsys.Stat(r.Context(), p)
|
|
if err != nil {
|
|
fsErr(w, err)
|
|
return
|
|
}
|
|
jsonOK(w, 200, in)
|
|
}
|
|
|
|
// filesRead streams a file. /download adds an attachment header; ?max= caps the bytes.
|
|
func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
|
|
fsys, ok := s.fsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
p, ok := cleanQuery(w, r, "path")
|
|
if !ok {
|
|
return
|
|
}
|
|
in, err := fsys.Stat(r.Context(), p)
|
|
if err != nil {
|
|
fsErr(w, err)
|
|
return
|
|
}
|
|
if in.Type == "dir" {
|
|
jsonErr(w, 400, "is a directory")
|
|
return
|
|
}
|
|
if in.Type == "other" {
|
|
jsonErr(w, 400, "not a regular file (device, pipe or socket)")
|
|
return
|
|
}
|
|
mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64)
|
|
if mx > 0 && in.Size > mx {
|
|
jsonErr(w, 413, "file too large")
|
|
return
|
|
}
|
|
f, err := fsys.Open(r.Context(), p)
|
|
if err != nil {
|
|
fsErr(w, err)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
var body io.Reader = f
|
|
if mx > 0 { // stat can lie (size 0 on /proc); the cap must hold on the bytes actually sent
|
|
body = io.LimitReader(f, mx+1)
|
|
}
|
|
name := filepath.Base(p)
|
|
ct := mime.TypeByExtension(filepath.Ext(name))
|
|
if ct == "" {
|
|
if in.Text {
|
|
ct = "text/plain; charset=utf-8"
|
|
} else {
|
|
ct = "application/octet-stream"
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", ct)
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
if strings.HasSuffix(r.URL.Path, "/download") {
|
|
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(name))
|
|
} else {
|
|
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name))
|
|
}
|
|
if in.Size > 0 && mx == 0 {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(in.Size, 10))
|
|
}
|
|
_, _ = io.Copy(w, body)
|
|
}
|
|
|
|
func (s *Server) filesWrite(w http.ResponseWriter, r *http.Request) {
|
|
fsys, ok := s.fsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
p, ok := cleanQuery(w, r, "path")
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := fsys.Write(r.Context(), p, io.LimitReader(r.Body, 8<<20)); err != nil {
|
|
fsErr(w, err)
|
|
return
|
|
}
|
|
log.Printf("files: wrote %s (from %s)", p, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// filesUpload streams the body into <path>/<name>.
|
|
func (s *Server) filesUpload(w http.ResponseWriter, r *http.Request) {
|
|
fsys, ok := s.fsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
dir, ok := cleanQuery(w, r, "path")
|
|
if !ok {
|
|
return
|
|
}
|
|
name := r.URL.Query().Get("name")
|
|
if name == "" || name != filepath.Base(name) || name == "." || name == ".." {
|
|
jsonErr(w, 400, "bad file name")
|
|
return
|
|
}
|
|
dst := filepath.Join(dir, name)
|
|
if r.URL.Query().Get("overwrite") != "1" {
|
|
if _, err := fsys.Stat(r.Context(), dst); err == nil {
|
|
jsonErr(w, 409, "a file with that name already exists")
|
|
return
|
|
}
|
|
}
|
|
if err := fsys.Write(r.Context(), dst, r.Body); err != nil {
|
|
fsErr(w, err)
|
|
return
|
|
}
|
|
log.Printf("files: uploaded %s (from %s)", dst, clientIP(r))
|
|
w.WriteHeader(201)
|
|
}
|
|
|
|
func (s *Server) filesOp(w http.ResponseWriter, r *http.Request, fn func(fsys files.FS, body map[string]any, p string) error) {
|
|
fsys, ok := s.fsFor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
|
|
jsonErr(w, 400, "bad request")
|
|
return
|
|
}
|
|
p, err := files.Clean(str(body["path"]))
|
|
if err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := fn(fsys, body, p); err != nil {
|
|
fsErr(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
func str(v any) string { s, _ := v.(string); return s }
|
|
|
|
func (s *Server) filesMkdir(w http.ResponseWriter, r *http.Request) {
|
|
s.filesOp(w, r, func(fsys files.FS, _ map[string]any, p string) error {
|
|
log.Printf("files: mkdir %s (from %s)", p, clientIP(r))
|
|
return fsys.Mkdir(r.Context(), p)
|
|
})
|
|
}
|
|
|
|
func (s *Server) filesTouch(w http.ResponseWriter, r *http.Request) {
|
|
s.filesOp(w, r, func(fsys files.FS, _ map[string]any, p string) error {
|
|
log.Printf("files: create %s (from %s)", p, clientIP(r))
|
|
return fsys.Touch(r.Context(), p)
|
|
})
|
|
}
|
|
|
|
func (s *Server) filesRename(w http.ResponseWriter, r *http.Request) {
|
|
s.filesOp(w, r, func(fsys files.FS, body map[string]any, from string) error {
|
|
to, err := files.Clean(str(body["to"]))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Printf("files: rename %s -> %s (from %s)", from, to, clientIP(r))
|
|
return fsys.Rename(r.Context(), from, to)
|
|
})
|
|
}
|
|
|
|
func (s *Server) filesDelete(w http.ResponseWriter, r *http.Request) {
|
|
s.filesOp(w, r, func(fsys files.FS, body map[string]any, p string) error {
|
|
rec, _ := body["recursive"].(bool)
|
|
log.Printf("files: delete %s recursive=%v (from %s)", p, rec, clientIP(r))
|
|
return fsys.Delete(r.Context(), p, rec)
|
|
})
|
|
}
|
|
|
|
func (s *Server) filesChmod(w http.ResponseWriter, r *http.Request) {
|
|
s.filesOp(w, r, func(fsys files.FS, body map[string]any, p string) error {
|
|
perm, err := files.ParsePerm(str(body["mode"]))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Printf("files: chmod %04o %s (from %s)", perm, p, clientIP(r))
|
|
return fsys.Chmod(r.Context(), p, perm)
|
|
})
|
|
}
|
|
|
|
// ---- storage (Disks tab) ----
|
|
|
|
func (s *Server) updatesInstall(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Packages []string `json:"packages"`
|
|
}
|
|
_ = json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body)
|
|
t, err := s.updates.Install(body.Packages)
|
|
if err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("updates: %s started as task %s (from %s)", t.Title, t.ID, clientIP(r))
|
|
jsonOK(w, 202, t.Snapshot(false))
|
|
}
|
|
|
|
func (s *Server) storageOverview(w http.ResponseWriter, r *http.Request) {
|
|
devs, err := storage.Devices(r.Context())
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, map[string]any{"devices": devs, "zfs": s.storage.ZFS(r.Context()), "smartctl": s.storage.HasSmartctl()})
|
|
}
|
|
|
|
func (s *Server) storageSmart(w http.ResponseWriter, r *http.Request) {
|
|
sm := s.storage.Smart(r.Context(), r.PathValue("dev"), r.URL.Query().Get("force") == "1")
|
|
if sm.Error == "unknown device" {
|
|
jsonErr(w, 404, sm.Error)
|
|
return
|
|
}
|
|
jsonOK(w, 200, sm)
|
|
}
|
|
|
|
func (s *Server) storageSmartTest(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.storage.SelfTest(r.Context(), r.PathValue("dev"), r.PathValue("kind")); err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("smart: %s test on %s (from %s)", r.PathValue("kind"), r.PathValue("dev"), clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
func (s *Server) zfsSnapshots(w http.ResponseWriter, r *http.Request) {
|
|
snaps, err := s.storage.Snapshots(r.Context(), r.URL.Query().Get("dataset"))
|
|
if err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, snaps)
|
|
}
|
|
|
|
func (s *Server) zfsScrub(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.storage.Scrub(r.Context(), r.PathValue("pool"), r.PathValue("action")); err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("zfs: scrub %s %s (from %s)", r.PathValue("action"), r.PathValue("pool"), clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
func (s *Server) zfsSnapshot(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Dataset string `json:"dataset"`
|
|
Name string `json:"name"`
|
|
}
|
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
|
if err := s.storage.CreateSnapshot(r.Context(), body.Dataset, body.Name); err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("zfs: snapshot %s@%s (from %s)", body.Dataset, body.Name, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// ---- processes ----
|
|
|
|
func (s *Server) procList(w http.ResponseWriter, _ *http.Request) {
|
|
jsonOK(w, 200, s.metrics.Processes())
|
|
}
|
|
|
|
func (s *Server) procSignal(w http.ResponseWriter, r *http.Request) {
|
|
pid, err := strconv.Atoi(r.PathValue("pid"))
|
|
if err != nil {
|
|
jsonErr(w, 400, "bad pid")
|
|
return
|
|
}
|
|
var body struct {
|
|
Signal string `json:"signal"`
|
|
}
|
|
_ = json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body)
|
|
sig := syscall.SIGTERM
|
|
if body.Signal == "KILL" {
|
|
sig = syscall.SIGKILL
|
|
}
|
|
if err := metrics.Signal(pid, sig); err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
log.Printf("procs: sent %s to pid %d (from %s)", sig, pid, clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// ---- account ----
|
|
|
|
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
|
var body struct{ Current, Password string }
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
|
jsonErr(w, 400, "bad request")
|
|
return
|
|
}
|
|
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Current) {
|
|
jsonErr(w, 403, "current password is wrong")
|
|
return
|
|
}
|
|
if len(body.Password) < 12 {
|
|
jsonErr(w, 400, "password must be at least 12 characters")
|
|
return
|
|
}
|
|
h, err := auth.HashPassword(body.Password)
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if err := s.cfg.Update(func(c *config.Config) { c.PasswordHash = h }); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
log.Printf("password changed (from %s)", clientIP(r))
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// ---- ui prefs (theme) ----
|
|
|
|
func (s *Server) getUIPrefs(w http.ResponseWriter, _ *http.Request) {
|
|
var raw json.RawMessage
|
|
s.store.View(func(st *store.State) { raw = st.UI })
|
|
if len(raw) == 0 {
|
|
raw = json.RawMessage("{}")
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(raw)
|
|
}
|
|
|
|
func (s *Server) putUIPrefs(w http.ResponseWriter, r *http.Request) {
|
|
b, err := io.ReadAll(io.LimitReader(r.Body, 16<<10))
|
|
if err != nil || !json.Valid(b) || len(b) == 0 || b[0] != '{' {
|
|
jsonErr(w, 400, "expected a JSON object")
|
|
return
|
|
}
|
|
var p struct {
|
|
Theme *string `json:"theme"`
|
|
RefreshMs *float64 `json:"refreshMs"`
|
|
}
|
|
_ = json.Unmarshal(b, &p)
|
|
if p.RefreshMs != nil && (*p.RefreshMs < 100 || *p.RefreshMs > 60000) {
|
|
jsonErr(w, 400, "refreshMs must be between 100 and 60000")
|
|
return
|
|
}
|
|
if p.Theme != nil && (len(*p.Theme) > 32 || strings.ContainsAny(*p.Theme, "<>\"'")) {
|
|
jsonErr(w, 400, "theme must be a short identifier")
|
|
return
|
|
}
|
|
if err := s.store.Update(func(st *store.State) { st.UI = json.RawMessage(b) }); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
|
|
|
|
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
|
|
var raw json.RawMessage
|
|
s.store.View(func(st *store.State) { raw = st.Dashboard })
|
|
if len(raw) == 0 {
|
|
raw = json.RawMessage("{}")
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_, _ = w.Write(raw)
|
|
}
|
|
|
|
func (s *Server) putDashPrefs(w http.ResponseWriter, r *http.Request) {
|
|
b, err := io.ReadAll(io.LimitReader(r.Body, 64<<10))
|
|
if err != nil || !json.Valid(b) || len(b) == 0 || b[0] != '{' {
|
|
jsonErr(w, 400, "expected a JSON object")
|
|
return
|
|
}
|
|
if err := s.store.Update(func(st *store.State) { st.Dashboard = json.RawMessage(b) }); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.applyPrefs(b)
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
// applyPrefs picks the few layout fields the server acts on (just the interval).
|
|
func (s *Server) applyPrefs(raw []byte) {
|
|
var p struct {
|
|
IntervalMs int `json:"intervalMs"`
|
|
}
|
|
if len(raw) == 0 || json.Unmarshal(raw, &p) != nil {
|
|
return
|
|
}
|
|
if p.IntervalMs > 0 {
|
|
s.metrics.SetInterval(time.Duration(p.IntervalMs) * time.Millisecond)
|
|
} else {
|
|
s.metrics.SetInterval(metrics.DefaultInterval)
|
|
}
|
|
}
|
|
|
|
// ---- terminals ----
|
|
|
|
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
|
|
jsonOK(w, 200, s.mgr.List())
|
|
}
|
|
|
|
func (s *Server) createTerminal(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
}
|
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
|
name := strings.TrimSpace(body.Name)
|
|
if name == "" {
|
|
name = "shell " + time.Now().Format("15:04")
|
|
}
|
|
sess, err := s.mgr.Create("", name)
|
|
if err != nil {
|
|
log.Printf("create terminal: %v", err)
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 201, map[string]string{"id": sess.ID, "name": sess.Name})
|
|
}
|
|
|
|
func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
|
|
jsonErr(w, 400, "name required")
|
|
return
|
|
}
|
|
if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil {
|
|
jsonErr(w, 404, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
func (s *Server) deleteTerminal(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.mgr.Close(r.PathValue("id")); err != nil {
|
|
jsonErr(w, 404, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(204)
|
|
}
|
|
|
|
type resizeMsg struct {
|
|
Type string `json:"type"`
|
|
Cols uint16 `json:"cols"`
|
|
Rows uint16 `json:"rows"`
|
|
}
|
|
|
|
func (s *Server) attach(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
client, snapshot, running, err := s.mgr.Attach(id)
|
|
if err != nil {
|
|
jsonErr(w, 404, err.Error())
|
|
return
|
|
}
|
|
// Origin must match Host (coder/websocket default) — blocks cross-site attaches.
|
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
|
|
if err != nil {
|
|
s.mgr.Detach(client)
|
|
return
|
|
}
|
|
defer s.mgr.Detach(client)
|
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
|
conn.SetReadLimit(1 << 20)
|
|
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
defer cancel()
|
|
|
|
if len(snapshot) > 0 {
|
|
if err := conn.Write(ctx, websocket.MessageBinary, snapshot); err != nil {
|
|
return
|
|
}
|
|
}
|
|
if !running {
|
|
_ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"exit"}`))
|
|
}
|
|
|
|
// writer: PTY → socket, plus keepalive pings so idle proxies don't cut us.
|
|
go func() {
|
|
defer cancel()
|
|
ping := time.NewTicker(25 * time.Second)
|
|
defer ping.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ping.C:
|
|
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
|
|
err := conn.Ping(pctx)
|
|
pc()
|
|
if err != nil {
|
|
return
|
|
}
|
|
case msg, ok := <-client.Ch:
|
|
if !ok {
|
|
_ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"closed"}`))
|
|
return
|
|
}
|
|
if msg.Data != nil {
|
|
if err := conn.Write(ctx, websocket.MessageBinary, msg.Data); err != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
if msg.Event == "exit" {
|
|
b, _ := json.Marshal(map[string]any{"type": "exit", "code": msg.Code})
|
|
_ = conn.Write(ctx, websocket.MessageText, b)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
// reader: socket → PTY (binary) or control (text JSON).
|
|
for {
|
|
typ, data, err := conn.Read(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
switch typ {
|
|
case websocket.MessageBinary:
|
|
if err := s.mgr.Write(id, data); err != nil && !errors.Is(err, term.ErrNotFound) {
|
|
log.Printf("pty write %s: %v", id, err)
|
|
}
|
|
case websocket.MessageText:
|
|
var m resizeMsg
|
|
if json.Unmarshal(data, &m) == nil && m.Type == "resize" {
|
|
_ = s.mgr.Resize(id, m.Cols, m.Rows)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- static UI with SPA fallback ----
|
|
|
|
func (s *Server) static(w http.ResponseWriter, r *http.Request) {
|
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
|
if p == "" {
|
|
p = "index.html"
|
|
}
|
|
f, err := s.ui.Open(p)
|
|
if err == nil {
|
|
st, _ := f.Stat()
|
|
f.Close()
|
|
if st != nil && !st.IsDir() {
|
|
if strings.HasPrefix(p, "assets/") {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
} else {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
}
|
|
http.ServeFileFS(w, r, s.ui, p)
|
|
return
|
|
}
|
|
}
|
|
// SPA fallback
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
http.ServeFileFS(w, r, s.ui, "index.html")
|
|
}
|