A guest login: temporary, look-only, revocable
Users -> Guest logins makes a throwaway user name and password, good for half an hour by default, to hand to somebody who wants to see the console. It is a normal account with `guest: true` and an expiry, so login, sessions and the saved look need no special case, and it is left out of the account list, out of "the last account can't be removed", and out of the one account an empty user name means. What a guest may do is one allow-list, checked in the auth middleware, rather than a flag ~190 handlers have to remember. The list is matched by asking the router which handler a request would reach, so it cannot drift from the routes it names, and it is checked against the scoped path — /api/nodes/grid/procs is /api/procs on grid — because the hub proxies to a node with a token that is full control of that node. The node cannot tell a guest from its owner, so the only place it can be stopped is here, before the request leaves. A guest gets the dashboard, containers, stacks, disks, hardware, ZFS and usage, on this machine and on any managed one, and nothing else: no terminal (attaching is a GET, and it is a root shell), no files, no container logs or environments, no stack files, no alert channels, no cron, no tokens, no settings, no power. Expiry is enforced on every request and swept once a minute, so a console left open on a visitor's phone stops within the minute rather than at their next click; revoking signs them out at once. The console hides what a guest cannot use and says how long is left where the power button would be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4450f1285d
commit
8a7167bf03
@@ -18,6 +18,7 @@ stub tab or a roadmap item.
|
|||||||
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
|
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
|
||||||
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
||||||
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
|
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
|
||||||
|
| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry, so login, sessions and prefs need no special case. What it may *do* is one allow-list of routes checked in the auth middleware, not a flag each handler honours. Denied by default. | Built 2026-09-27, asked for the same day. There are ~190 routes; a per-handler check would be wrong the first time a route was added. The list is matched by asking the router which handler a request would reach, so it cannot drift from the routes it is about, and it is applied to the *scoped* path (`/api/nodes/grid/files/read` → `/api/files/read`) because the hub proxies to a node with a token that is full control of it — the node cannot tell a guest from its owner, so the decision has to be made on the hub. |
|
||||||
| Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. |
|
| Machines | **A view shows one machine: the one in the switcher.** No hub-side page lists or aggregates what its nodes are doing. To see or change anything on another machine, switch to it. | Mitchell, 2026-09-25, cutting a fleet-wide updates list out of the Docker tab. The switcher is the only place a machine is chosen, so there is exactly one answer to "which machine am I looking at" — a page that quietly reaches across breaks that, and it is never obvious which machine an action would land on. The hub still *proxies* for a node, and hub-only admin (Machines, Users, Settings, Alerts) is unaffected. |
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ reset a password; your own password confirms the last two). From the shell:
|
|||||||
users` lists them, `hamlab user-rm <user>` removes one. Machine settings — refresh rate,
|
users` lists them, `hamlab user-rm <user>` removes one. Machine settings — refresh rate,
|
||||||
graph history, alert rules — stay the machine's and are shared by everyone.
|
graph history, alert rules — stay the machine's and are shared by everyone.
|
||||||
|
|
||||||
|
**Guest logins**, for showing somebody the console: Users → Guest logins makes a
|
||||||
|
throwaway user name and password, good for half an hour by default, and hands you the
|
||||||
|
pair to paste into a message. A guest sees the dashboard, the containers, the disks, the
|
||||||
|
hardware and the machines this hub manages, and can change none of it — no terminal, no
|
||||||
|
files, no logs or container environments, no settings, no power. It can be revoked in one
|
||||||
|
tap, and it disappears on its own when the time is up, wherever it is signed in. What a
|
||||||
|
guest may reach is a list of routes in `internal/api/guests.go`, so a route added later
|
||||||
|
is shut to guests until somebody says otherwise.
|
||||||
|
|
||||||
What adapts to the host, rather than assuming Debian:
|
What adapts to the host, rather than assuming Debian:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|
|||||||
+8
-1
@@ -151,7 +151,14 @@ func main() {
|
|||||||
if !u.LastSeen.IsZero() {
|
if !u.LastSeen.IsZero() {
|
||||||
seen = u.LastSeen.Local().Format("2006-01-02 15:04")
|
seen = u.LastSeen.Local().Format("2006-01-02 15:04")
|
||||||
}
|
}
|
||||||
fmt.Printf("%-20s created %s last seen %s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen)
|
note := ""
|
||||||
|
if u.Guest { // a temporary look-only login, not one of the machine's accounts
|
||||||
|
note = " guest, until " + u.Expires.Local().Format("15:04")
|
||||||
|
if u.Expired(time.Now()) {
|
||||||
|
note = " guest, expired"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("%-20s created %s last seen %s%s\n", u.Name, u.Created.Local().Format("2006-01-02"), seen, note)
|
||||||
}
|
}
|
||||||
case "user-rm":
|
case "user-rm":
|
||||||
if err := userRm(cfg, flag.Arg(1)); err != nil {
|
if err := userRm(cfg, flag.Arg(1)); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// Guest logins: a temporary account for showing somebody the console. It expires by itself
|
||||||
|
// (half an hour by default), it can be revoked in one tap, and it may only look — it cannot
|
||||||
|
// change a thing, on this machine or on any machine this hub manages.
|
||||||
|
//
|
||||||
|
// The gate is a list of what a guest may reach, and nothing else, so a route added later is
|
||||||
|
// shut to guests until somebody decides otherwise. It is applied to the *scoped* path, which
|
||||||
|
// is what makes it hold for managed machines too: the hub proxies to a node as itself, with a
|
||||||
|
// token that is full control of that node, so the node cannot tell a guest from its owner —
|
||||||
|
// the decision has to be made here, before the request leaves.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/auth"
|
||||||
|
"hamlab/internal/config"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
guestPrefix = "guest-"
|
||||||
|
guestTTLDefault = 30 * time.Minute
|
||||||
|
guestTTLMax = 24 * time.Hour
|
||||||
|
guestReapEvery = time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// guestRoutes is every route a guest login may reach, by the pattern it is registered under
|
||||||
|
// in New. Reading only, with three exceptions that are the guest's own: signing out, and the
|
||||||
|
// theme and page order that follow the login.
|
||||||
|
//
|
||||||
|
// Deliberately not here, though they only read:
|
||||||
|
// - terminals — attaching is a root-capable shell, and attaching is a GET
|
||||||
|
// - files — reading a file is reading every file, including keys and this config
|
||||||
|
// - a container's logs, inspect, events and health — env vars and logs are where secrets are
|
||||||
|
// - stack files and .env — same
|
||||||
|
// - cron/jobs, alerts — the jobs are commands; a channel URL is a key to his phone
|
||||||
|
// - KVM — a screen and a keyboard on a machine, and its assets load with a GET
|
||||||
|
// - updates — what is unpatched on his box is not a thing to show a visitor
|
||||||
|
var guestRoutes = map[string]bool{
|
||||||
|
"GET /api/me": true,
|
||||||
|
"POST /api/logout": true,
|
||||||
|
"GET /api/nodes": true, // the machine switcher: a guest may look at any of them
|
||||||
|
"GET /api/prefs/ui": true,
|
||||||
|
"PUT /api/prefs/ui": true, // the look is the login's own
|
||||||
|
"GET /api/prefs/history": true,
|
||||||
|
"GET /api/prefs/sampler": true,
|
||||||
|
"GET /api/procs": true,
|
||||||
|
"GET /api/hardware": true,
|
||||||
|
"GET /api/storage": true,
|
||||||
|
"GET /api/storage/smart/{dev}": true,
|
||||||
|
"GET /api/storage/zfs/snapshots": true,
|
||||||
|
"GET /api/zfs": true,
|
||||||
|
"GET /api/docker": true, // containers, images, volumes, networks — the lists only
|
||||||
|
"GET /api/docker/stacks": true,
|
||||||
|
"GET /api/services": true,
|
||||||
|
"GET /api/services/{unit}": true,
|
||||||
|
"GET /api/space": true,
|
||||||
|
"GET /api/space/{disk}": true,
|
||||||
|
"GET /api/space/{disk}/tree": true,
|
||||||
|
"GET /api/space/{disk}/largest": true,
|
||||||
|
"GET /api/tasks": true,
|
||||||
|
"GET /api/tasks/{id}": true,
|
||||||
|
// sensors: the read-only interface the dashboard and every graph are built on. A query
|
||||||
|
// is a POST when the list of ids is too long for a URL, and still only a read.
|
||||||
|
"GET /api/sensors": true,
|
||||||
|
"GET /api/sensors/query": true,
|
||||||
|
"POST /api/sensors/query": true,
|
||||||
|
"GET /api/sensors/stream": true,
|
||||||
|
"GET /api/sensors/history": true,
|
||||||
|
"GET /api/sensors/{id}": true,
|
||||||
|
"GET /api/sensors/{id}/history": true,
|
||||||
|
"GET /api/fleet/sensors/query": true,
|
||||||
|
"POST /api/fleet/sensors/query": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// guestScope is the path whose permission decides this request. A hub route for a managed
|
||||||
|
// machine is the same route on that machine — /api/nodes/grid/procs is /api/procs — so it is
|
||||||
|
// judged by the same line of guestRoutes. A path with nothing after the machine name is the
|
||||||
|
// machine record itself (adding or removing one), which is no guest's business.
|
||||||
|
func guestScope(p string) string {
|
||||||
|
rest, ok := strings.CutPrefix(p, "/api/nodes/")
|
||||||
|
if !ok {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
i := strings.IndexByte(rest, '/')
|
||||||
|
if i < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/api" + rest[i:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// guestMay decides a request by asking the router which handler it would reach, so the
|
||||||
|
// answer can never drift from the route it is about. An unrouted path lands on the catch-all,
|
||||||
|
// which is not in the list, so it is refused.
|
||||||
|
func (s *Server) guestMay(r *http.Request) bool {
|
||||||
|
probe := r
|
||||||
|
if p := guestScope(r.URL.Path); p != r.URL.Path {
|
||||||
|
if p == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
probe = &http.Request{Method: r.Method, URL: &url.URL{Path: p}, Host: r.Host}
|
||||||
|
}
|
||||||
|
_, pattern := s.mux.Handler(probe)
|
||||||
|
return guestRoutes[pattern]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the accounts themselves ----
|
||||||
|
|
||||||
|
func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) {
|
||||||
|
now := time.Now()
|
||||||
|
counts := s.sessions.Count()
|
||||||
|
out := []map[string]any{}
|
||||||
|
s.cfg.View(func(c *config.Config) {
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if !u.Guest || u.Expired(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"name": u.Name, "created": u.Created, "expires": u.Expires,
|
||||||
|
"issued_by": u.IssuedBy, "sessions": counts[u.Name], "used": !u.LastSeen.IsZero(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jsonOK(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addGuest makes a guest login and hands back its password — the only time it is ever
|
||||||
|
// readable, here as everywhere else a password is generated.
|
||||||
|
func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
me := actorOf(r)
|
||||||
|
if me.Guest {
|
||||||
|
jsonErr(w, 403, "a guest login can only look around")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||||
|
ttl := guestTTLDefault
|
||||||
|
if body.Minutes > 0 {
|
||||||
|
ttl = time.Duration(body.Minutes) * time.Minute
|
||||||
|
}
|
||||||
|
if ttl > guestTTLMax {
|
||||||
|
jsonErr(w, 400, "a guest login lasts at most 24 hours")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pw, err := auth.GuestPassword()
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(pw)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
for try := 0; try < 8 && name == ""; try++ {
|
||||||
|
suffix, err := auth.GuestSuffix()
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidate := guestPrefix + suffix
|
||||||
|
s.cfg.View(func(c *config.Config) {
|
||||||
|
if _, taken := c.User(candidate); !taken {
|
||||||
|
name = candidate
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
jsonErr(w, 500, "could not find a free guest name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
expires := now.Add(ttl)
|
||||||
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
|
c.Users = append(c.Users, config.User{Name: name, Hash: hash, Created: now, Guest: true, Expires: expires, IssuedBy: me.User})
|
||||||
|
}); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("guest login %q issued until %s (by %s)", name, expires.Format(time.RFC3339), clientIP(r))
|
||||||
|
jsonOK(w, 200, map[string]any{"name": name, "password": pw, "expires": expires, "minutes": int(ttl.Minutes())})
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeGuest ends a guest login now. No password: it only ever takes access away, and the
|
||||||
|
// whole point of the thing is that ending it is one tap.
|
||||||
|
func (s *Server) revokeGuest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if actorOf(r).Guest {
|
||||||
|
jsonErr(w, 403, "a guest login can only look around")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.PathValue("name")
|
||||||
|
found := false
|
||||||
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
|
kept := c.Users[:0]
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if u.Name == name && u.Guest {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, u)
|
||||||
|
}
|
||||||
|
c.Users = kept
|
||||||
|
}); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no such guest login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.forgetGuest(name)
|
||||||
|
log.Printf("guest login %q revoked (by %s)", name, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetGuest signs a guest out everywhere and drops the look they left behind.
|
||||||
|
func (s *Server) forgetGuest(name string) {
|
||||||
|
s.sessions.RevokeUser(name)
|
||||||
|
_ = s.store.Update(func(st *store.State) { delete(st.Users, name) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchGuests clears out guest logins whose time is up. Expiry is enforced on every request
|
||||||
|
// as well — this is what keeps the config file from collecting them, and what signs a guest
|
||||||
|
// out of a console they left open rather than waiting for their next click.
|
||||||
|
func (s *Server) watchGuests() {
|
||||||
|
for {
|
||||||
|
s.reapGuests()
|
||||||
|
time.Sleep(guestReapEvery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) reapGuests() {
|
||||||
|
now := time.Now()
|
||||||
|
var gone []string
|
||||||
|
var have bool
|
||||||
|
s.cfg.View(func(c *config.Config) {
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if u.Guest {
|
||||||
|
have = true
|
||||||
|
if u.Expired(now) {
|
||||||
|
gone = append(gone, u.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if !have || len(gone) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expired := map[string]bool{}
|
||||||
|
for _, n := range gone {
|
||||||
|
expired[n] = true
|
||||||
|
}
|
||||||
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
|
kept := c.Users[:0]
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if expired[u.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, u)
|
||||||
|
}
|
||||||
|
c.Users = kept
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("guests: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, n := range gone {
|
||||||
|
s.forgetGuest(n)
|
||||||
|
}
|
||||||
|
log.Printf("guest login(s) expired: %s", strings.Join(gone, ", "))
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/auth"
|
||||||
|
"hamlab/internal/config"
|
||||||
|
"hamlab/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The router built without any of the machinery behind it: registering a route never calls
|
||||||
|
// its handler, so this is the real table, and what guestMay is asked about.
|
||||||
|
func gateServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
s := &Server{}
|
||||||
|
s.mux = s.routes()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func may(t *testing.T, s *Server, method, path string) bool {
|
||||||
|
t.Helper()
|
||||||
|
return s.guestMay(&http.Request{Method: method, URL: &url.URL{Path: path}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every line of guestRoutes has to name a route that exists. A pattern with a typo in it
|
||||||
|
// would quietly match nothing — safe, but the page that needs it would break with no clue
|
||||||
|
// why, so the mistake is caught here instead.
|
||||||
|
func TestGuestRoutesAreReal(t *testing.T) {
|
||||||
|
s := gateServer(t)
|
||||||
|
for pattern := range guestRoutes {
|
||||||
|
method, path, ok := strings.Cut(pattern, " ")
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%q is not \"METHOD /path\"", pattern)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
probe := strings.NewReplacer("{dev}", "sda", "{unit}", "ssh.service", "{disk}", "sda", "{id}", "x", "{name}", "grid").Replace(path)
|
||||||
|
_, got := s.mux.Handler(&http.Request{Method: method, URL: &url.URL{Path: probe}})
|
||||||
|
if got != pattern {
|
||||||
|
t.Errorf("guestRoutes has %q; %s %s routes to %q", pattern, method, probe, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuestMayOnlyLookAround(t *testing.T) {
|
||||||
|
s := gateServer(t)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path string
|
||||||
|
want bool
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
// what a guest is for
|
||||||
|
{"GET", "/api/me", true, "who am I"},
|
||||||
|
{"POST", "/api/logout", true, "signing out"},
|
||||||
|
{"GET", "/api/procs", true, "the Processes page"},
|
||||||
|
{"GET", "/api/docker", true, "the container list"},
|
||||||
|
{"GET", "/api/docker/stacks", true, "the stack list"},
|
||||||
|
{"GET", "/api/zfs", true, "the ZFS page"},
|
||||||
|
{"GET", "/api/storage/smart/sda", true, "drive health"},
|
||||||
|
{"GET", "/api/space/sda/tree", true, "the Usage page"},
|
||||||
|
{"GET", "/api/sensors/stream", true, "live graphs"},
|
||||||
|
{"POST", "/api/sensors/query", true, "a query too long for a URL is still a read"},
|
||||||
|
{"GET", "/api/nodes", true, "the machine switcher"},
|
||||||
|
{"PUT", "/api/prefs/ui", true, "the look is the login's own"},
|
||||||
|
|
||||||
|
// the shell, and every road to it
|
||||||
|
{"GET", "/api/terminals", false, "terminals"},
|
||||||
|
{"POST", "/api/terminals", false, "opening a shell"},
|
||||||
|
{"GET", "/api/terminals/abc/attach", false, "attaching is a GET, and it is a root shell"},
|
||||||
|
{"POST", "/api/docker/containers/gitea/exec", false, "a shell in a container"},
|
||||||
|
|
||||||
|
// reads that give away too much
|
||||||
|
{"GET", "/api/files", false, "the file tree"},
|
||||||
|
{"GET", "/api/files/read", false, "reading any file, including this config and his keys"},
|
||||||
|
{"GET", "/api/docker/containers/gitea/inspect", false, "a container's environment is where secrets live"},
|
||||||
|
{"GET", "/api/docker/containers/gitea/logs", false, "logs leak"},
|
||||||
|
{"GET", "/api/docker/stacks/gitea", false, "a stack's compose file and .env"},
|
||||||
|
{"GET", "/api/alerts", false, "a channel URL is a key to his phone"},
|
||||||
|
{"GET", "/api/cron", false, "cron jobs are commands"},
|
||||||
|
{"GET", "/api/updates", false, "what is unpatched here"},
|
||||||
|
{"GET", "/api/tokens", false, "tokens"},
|
||||||
|
{"GET", "/api/users", false, "the account list"},
|
||||||
|
{"GET", "/api/sessions", false, "sessions"},
|
||||||
|
{"GET", "/api/guests", false, "a guest cannot see or make guests"},
|
||||||
|
{"GET", "/kvm/1", false, "a KVM is a keyboard on a machine"},
|
||||||
|
{"GET", "/api/debug/pprof/heap", false, "the profiler"},
|
||||||
|
|
||||||
|
// anything that changes anything
|
||||||
|
{"POST", "/api/power/poweroff", false, "turning the box off"},
|
||||||
|
{"POST", "/api/docker/stacks/gitea/deploy", false, "deploying"},
|
||||||
|
{"POST", "/api/docker/containers/gitea/restart", false, "restarting a container"},
|
||||||
|
{"POST", "/api/services/ssh.service/restart", false, "restarting a service"},
|
||||||
|
{"PUT", "/api/files/write", false, "writing a file"},
|
||||||
|
{"POST", "/api/procs/1/signal", false, "killing a process"},
|
||||||
|
{"POST", "/api/users", false, "making an account"},
|
||||||
|
{"POST", "/api/guests", false, "making a guest login"},
|
||||||
|
{"DELETE", "/api/guests/guest-abcd", false, "revoking one"},
|
||||||
|
{"POST", "/api/password", false, "changing a password"},
|
||||||
|
{"POST", "/api/tokens/read", false, "issuing a token"},
|
||||||
|
{"POST", "/api/self-update", false, "replacing the binary"},
|
||||||
|
{"POST", "/api/updates/upgrade", false, "upgrading packages"},
|
||||||
|
{"PUT", "/api/prefs/history", false, "a machine-wide setting"},
|
||||||
|
{"PUT", "/api/prefs/sampler", false, "a machine-wide setting"},
|
||||||
|
{"POST", "/api/nodes", false, "adding a machine"},
|
||||||
|
{"DELETE", "/api/nodes/grid", false, "removing one"},
|
||||||
|
{"PUT", "/api/alerts", false, "alert rules"},
|
||||||
|
{"POST", "/api/space/sda/scan", false, "starting a scan"},
|
||||||
|
|
||||||
|
// a managed machine is judged by the same table: the hub proxies as itself, with a
|
||||||
|
// token that is full control of the node, so this is the only place it can be stopped
|
||||||
|
{"GET", "/api/nodes/grid/procs", true, "looking at grid"},
|
||||||
|
{"GET", "/api/nodes/grid/sensors/stream", true, "grid's graphs"},
|
||||||
|
{"GET", "/api/nodes/grid/docker/stacks", true, "grid's stacks"},
|
||||||
|
{"GET", "/api/nodes/grid/files/read", false, "reading a file on grid"},
|
||||||
|
{"GET", "/api/nodes/grid/terminals", false, "a shell on grid"},
|
||||||
|
{"POST", "/api/nodes/grid/docker/stacks/immich/deploy", false, "deploying on grid"},
|
||||||
|
{"POST", "/api/nodes/grid/power/reboot", false, "rebooting grid"},
|
||||||
|
{"PUT", "/api/nodes/grid/files/write", false, "writing a file on grid"},
|
||||||
|
|
||||||
|
// and nothing unrouted slips through the catch-alls
|
||||||
|
{"GET", "/api/nothing-like-this", false, "an unknown API path"},
|
||||||
|
{"GET", "/api/nodes/grid/nothing-like-this", false, "an unknown path on a machine"},
|
||||||
|
{"GET", "/anything", false, "a path off the API"},
|
||||||
|
} {
|
||||||
|
if got := may(t, s, tc.method, tc.path); got != tc.want {
|
||||||
|
t.Errorf("%s %s: guest may = %v, want %v (%s)", tc.method, tc.path, got, tc.want, tc.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuestScope(t *testing.T) {
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
"/api/procs": "/api/procs",
|
||||||
|
"/api/nodes": "/api/nodes",
|
||||||
|
"/api/nodes/grid": "", // the machine record itself, not a route on the machine
|
||||||
|
"/api/nodes/grid/procs": "/api/procs",
|
||||||
|
"/api/nodes/grid/docker/containers/x/logs": "/api/docker/containers/x/logs",
|
||||||
|
"/kvm/1": "/kvm/1",
|
||||||
|
} {
|
||||||
|
if got := guestScope(path); got != want {
|
||||||
|
t.Errorf("guestScope(%q) = %q, want %q", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A guest whose time is up is stopped at the door, whether or not the minute-by-minute
|
||||||
|
// sweep has got to their account yet — and their session is dropped on the way, so the
|
||||||
|
// console they left open goes back to the login form.
|
||||||
|
func TestExpiredGuestIsRefusedAndSignedOut(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
expires time.Time
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"still has time", time.Now().Add(10 * time.Minute), true},
|
||||||
|
{"time is up", time.Now().Add(-time.Second), false},
|
||||||
|
{"no expiry at all", time.Time{}, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Default(filepath.Join(dir, "config.json"))
|
||||||
|
if err := cfg.Update(func(c *config.Config) {
|
||||||
|
c.Users = []config.User{{Name: "guest-abcd", Hash: "x", Guest: true, Expires: tc.expires}}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, err := store.Open(filepath.Join(dir, "state.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := &Server{cfg: cfg, store: st, sessions: auth.NewSessions(st)}
|
||||||
|
tok, err := s.sessions.Create("guest-abcd", "10.0.0.5", "curl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("GET", "/api/me", nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a, ok, answered := s.cookieActor(w, r)
|
||||||
|
if ok != tc.wantOK {
|
||||||
|
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
||||||
|
}
|
||||||
|
if tc.wantOK {
|
||||||
|
if !a.Guest || a.User != "guest-abcd" {
|
||||||
|
t.Errorf("actor = %+v, want the guest account marked as one", a)
|
||||||
|
}
|
||||||
|
if answered {
|
||||||
|
t.Error("answered the caller for a guest that still has time")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !answered || w.Code != 401 {
|
||||||
|
t.Errorf("answered = %v, code = %d, want true and 401", answered, w.Code)
|
||||||
|
}
|
||||||
|
if _, live := s.sessions.Lookup(tok, "10.0.0.5"); live {
|
||||||
|
t.Error("the session of an expired guest is still live")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
-19
@@ -140,9 +140,18 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
}
|
}
|
||||||
go s.keepFleetInStep()
|
go s.keepFleetInStep()
|
||||||
}
|
}
|
||||||
|
go s.watchGuests()
|
||||||
go s.watchForUpdates(context.Background())
|
go s.watchForUpdates(context.Background())
|
||||||
go s.watchDockerEvents(context.Background())
|
go s.watchDockerEvents(context.Background())
|
||||||
|
|
||||||
|
s.mux = s.routes()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// routes is the whole HTTP surface. A method of its own, rather than inline in New, so a
|
||||||
|
// test can build the real router without the machinery behind it — which is what the guest
|
||||||
|
// gate is checked against, since it decides a request by asking this router where it would go.
|
||||||
|
func (s *Server) routes() *http.ServeMux {
|
||||||
m := http.NewServeMux()
|
m := http.NewServeMux()
|
||||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
||||||
m.HandleFunc("POST /api/login", s.login)
|
m.HandleFunc("POST /api/login", s.login)
|
||||||
@@ -289,6 +298,9 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("POST /api/users", s.auth(s.addUser))
|
m.HandleFunc("POST /api/users", s.auth(s.addUser))
|
||||||
m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser))
|
m.HandleFunc("DELETE /api/users/{name}", s.auth(s.removeUser))
|
||||||
m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword))
|
m.HandleFunc("POST /api/users/{name}/password", s.auth(s.resetPassword))
|
||||||
|
m.HandleFunc("GET /api/guests", s.auth(s.listGuests))
|
||||||
|
m.HandleFunc("POST /api/guests", s.auth(s.addGuest))
|
||||||
|
m.HandleFunc("DELETE /api/guests/{name}", s.auth(s.revokeGuest))
|
||||||
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
|
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
|
||||||
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
|
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
|
||||||
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
|
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
|
||||||
@@ -318,8 +330,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
jsonErr(w, 404, "not found")
|
jsonErr(w, 404, "not found")
|
||||||
}))
|
}))
|
||||||
m.HandleFunc("/", s.static)
|
m.HandleFunc("/", s.static)
|
||||||
s.mux = m
|
return m
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sensorPath reports whether a path belongs to the read-only sensor API.
|
// sensorPath reports whether a path belongs to the read-only sensor API.
|
||||||
@@ -394,25 +405,71 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// signedIn reports whether a request carries a live browser session, without the
|
// signedIn reports whether a request carries a live browser session of a real account,
|
||||||
// side effects of the auth middleware.
|
// without the side effects of the auth middleware. A guest login is not one: this is what
|
||||||
|
// lets a KVM's own assets through, and a KVM is a keyboard on a machine.
|
||||||
func (s *Server) signedIn(r *http.Request) bool {
|
func (s *Server) signedIn(r *http.Request) bool {
|
||||||
c, err := r.Cookie(cookieName)
|
c, err := r.Cookie(cookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
_, ok := s.sessions.Lookup(c.Value, clientKey(r))
|
ses, ok := s.sessions.Lookup(c.Value, clientKey(r))
|
||||||
return ok
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var u config.User
|
||||||
|
var found bool
|
||||||
|
s.cfg.View(func(cf *config.Config) { u, found = cf.User(ses.User) })
|
||||||
|
return found && !u.Guest
|
||||||
}
|
}
|
||||||
|
|
||||||
// auth accepts the browser session cookie or a bearer API token issued to a hub.
|
// guestRefusal is what a guest login is told, wherever it reaches for something.
|
||||||
|
const guestRefusal = "a guest login can only look around"
|
||||||
|
|
||||||
|
// cookieActor resolves the session cookie to who is calling. answered means the caller has
|
||||||
|
// already been sent an error and the handler must not run: the account behind a live session
|
||||||
|
// has gone away, or a guest login's time is up — so a console left open on a visitor's phone
|
||||||
|
// stops at the expiry, not at whatever they click next.
|
||||||
|
func (s *Server) cookieActor(w http.ResponseWriter, r *http.Request) (a actor, ok, answered bool) {
|
||||||
|
c, err := r.Cookie(cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return actor{}, false, false
|
||||||
|
}
|
||||||
|
ses, live := s.sessions.Lookup(c.Value, clientKey(r))
|
||||||
|
if !live {
|
||||||
|
return actor{}, false, false
|
||||||
|
}
|
||||||
|
var u config.User
|
||||||
|
var found bool
|
||||||
|
s.cfg.View(func(cf *config.Config) { u, found = cf.User(ses.User) })
|
||||||
|
if !found || u.Expired(time.Now()) {
|
||||||
|
s.sessions.Delete(c.Value)
|
||||||
|
s.setCookie(w, r, "", -1)
|
||||||
|
if found {
|
||||||
|
jsonErr(w, 401, "this guest login has expired")
|
||||||
|
} else {
|
||||||
|
jsonErr(w, 401, "that account no longer exists")
|
||||||
|
}
|
||||||
|
return actor{}, false, true
|
||||||
|
}
|
||||||
|
return actor{User: ses.User, Session: ses.ID, token: c.Value, Guest: u.Guest, Expires: u.Expires}, true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// auth accepts the browser session cookie or a bearer API token issued to a hub. A guest
|
||||||
|
// login only reaches the handful of routes guestRoutes names (internal/api/guests.go).
|
||||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if c, err := r.Cookie(cookieName); err == nil {
|
a, ok, answered := s.cookieActor(w, r)
|
||||||
if ses, ok := s.sessions.Lookup(c.Value, clientKey(r)); ok {
|
if answered {
|
||||||
next(w, withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value}))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ok {
|
||||||
|
if a.Guest && !s.guestMay(r) {
|
||||||
|
jsonErr(w, 403, guestRefusal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, withActor(r, a))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||||
var hashes, names []string
|
var hashes, names []string
|
||||||
@@ -446,11 +503,15 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ok, trusted := false, false
|
ok, trusted := false, false
|
||||||
var patterns []string
|
var patterns []string
|
||||||
if c, err := r.Cookie(cookieName); err == nil {
|
if a, live, answered := s.cookieActor(w, r); answered {
|
||||||
if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid {
|
return
|
||||||
ok, trusted = true, true
|
} else if live {
|
||||||
r = withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value})
|
if a.Guest && !s.guestMay(r) {
|
||||||
|
jsonErr(w, 403, guestRefusal)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
ok, trusted = true, true
|
||||||
|
r = withActor(r, a)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
|
presented, fromURL := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), false
|
||||||
@@ -919,11 +980,11 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
// name is allowed only while there is exactly one account — what a client from before
|
// name is allowed only while there is exactly one account — what a client from before
|
||||||
// user names sends, and what a hub pairing with a one-user node may leave out.
|
// user names sends, and what a hub pairing with a one-user node may leave out.
|
||||||
func (s *Server) checkLogin(username, password string) (string, bool) {
|
func (s *Server) checkLogin(username, password string) (string, bool) {
|
||||||
var users []config.User
|
var users, real []config.User
|
||||||
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) })
|
s.cfg.View(func(c *config.Config) { users, real = append(users, c.Users...), c.Accounts() })
|
||||||
username = strings.TrimSpace(username)
|
username = strings.TrimSpace(username)
|
||||||
if username == "" && len(users) == 1 {
|
if username == "" && len(real) == 1 { // guest logins are not the account an empty name means
|
||||||
username = users[0].Name
|
username = real[0].Name
|
||||||
}
|
}
|
||||||
if username == "" {
|
if username == "" {
|
||||||
auth.CheckUser(nil, "", password) // take as long as a real check
|
auth.CheckUser(nil, "", password) // take as long as a real check
|
||||||
@@ -1058,8 +1119,14 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
addr = h // the address this console listens on, for the machine menu
|
addr = h // the address this console listens on, for the machine menu
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
me := actorOf(r)
|
||||||
|
var guestUntil any
|
||||||
|
if me.Guest {
|
||||||
|
guestUntil = me.Expires
|
||||||
|
}
|
||||||
jsonOK(w, 200, map[string]any{
|
jsonOK(w, 200, map[string]any{
|
||||||
"host": s.hostName(), "user": s.user, "account": actorOf(r).User, "version": s.version,
|
"host": s.hostName(), "user": s.user, "account": me.User, "version": s.version,
|
||||||
|
"guest": me.Guest, "guest_until": guestUntil,
|
||||||
"load": load, "uptime": uptime, "service_started": s.started,
|
"load": load, "uptime": uptime, "service_started": s.started,
|
||||||
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "addr": addr,
|
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "addr": addr,
|
||||||
"update_available": updateVersion,
|
"update_available": updateVersion,
|
||||||
|
|||||||
+12
-2
@@ -26,6 +26,10 @@ type actor struct {
|
|||||||
Session string // session ID, "" for a token
|
Session string // session ID, "" for a token
|
||||||
token string // the raw cookie token, so a handler can spare the current session
|
token string // the raw cookie token, so a handler can spare the current session
|
||||||
Token string // token name, "" for a session
|
Token string // token name, "" for a session
|
||||||
|
// Guest marks a temporary look-around login, and Expires is when it stops working. What
|
||||||
|
// a guest may reach is decided in the middleware, not here; see internal/api/guests.go.
|
||||||
|
Guest bool
|
||||||
|
Expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type ctxKey int
|
type ctxKey int
|
||||||
@@ -44,6 +48,8 @@ func actorOf(r *http.Request) actor {
|
|||||||
// who names the caller for a log line: "core@10.0.0.5" for a person, "token:hub:core@…" for a hub.
|
// who names the caller for a log line: "core@10.0.0.5" for a person, "token:hub:core@…" for a hub.
|
||||||
func (a actor) String() string {
|
func (a actor) String() string {
|
||||||
switch {
|
switch {
|
||||||
|
case a.User != "" && a.Guest:
|
||||||
|
return "guest:" + a.User
|
||||||
case a.User != "":
|
case a.User != "":
|
||||||
return a.User
|
return a.User
|
||||||
case a.Token != "":
|
case a.Token != "":
|
||||||
@@ -59,7 +65,7 @@ func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
counts := s.sessions.Count()
|
counts := s.sessions.Count()
|
||||||
var out []map[string]any
|
var out []map[string]any
|
||||||
s.cfg.View(func(c *config.Config) {
|
s.cfg.View(func(c *config.Config) {
|
||||||
for _, u := range c.Users {
|
for _, u := range c.Accounts() { // guest logins are listed on their own, see listGuests
|
||||||
var seen any
|
var seen any
|
||||||
if !u.LastSeen.IsZero() {
|
if !u.LastSeen.IsZero() {
|
||||||
seen = u.LastSeen
|
seen = u.LastSeen
|
||||||
@@ -84,6 +90,10 @@ func (s *Server) addUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, 400, "a user name is 1–32 lower-case letters, digits, . _ or -, starting with a letter or digit")
|
jsonErr(w, 400, "a user name is 1–32 lower-case letters, digits, . _ or -, starting with a letter or digit")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(name, guestPrefix) {
|
||||||
|
jsonErr(w, 400, "names starting with "+guestPrefix+" belong to guest logins")
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(body.Password) < auth.MinPasswordLen {
|
if len(body.Password) < auth.MinPasswordLen {
|
||||||
jsonErr(w, 400, "password must be at least 12 characters")
|
jsonErr(w, 400, "password must be at least 12 characters")
|
||||||
return
|
return
|
||||||
@@ -141,7 +151,7 @@ func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
found, last := false, false
|
found, last := false, false
|
||||||
if err := s.cfg.Update(func(c *config.Config) {
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
if len(c.Users) <= 1 {
|
if len(c.Accounts()) <= 1 { // guest logins don't keep the last real account alive
|
||||||
last = true
|
last = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-2
@@ -113,11 +113,14 @@ var dummyHash = func() string {
|
|||||||
return h
|
return h
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// CheckUser verifies a password for one of users. A missing user fails in the same time
|
// CheckUser verifies a password for one of users. A missing user — or a guest login whose
|
||||||
// as a wrong password.
|
// time is up — fails in the same time as a wrong password.
|
||||||
func CheckUser(users []config.User, name, pw string) bool {
|
func CheckUser(users []config.User, name, pw string) bool {
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if u.Name == name {
|
if u.Name == name {
|
||||||
|
if u.Expired(time.Now()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
return VerifyPassword(u.Hash, pw)
|
return VerifyPassword(u.Hash, pw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +128,51 @@ func CheckUser(users []config.User, name, pw string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- guest logins ----
|
||||||
|
|
||||||
|
// guestAlphabet leaves out every letter and digit that can be misread in a screenshot or
|
||||||
|
// over the phone: i, l, o, 0 and 1. A guest login is read aloud and typed by hand.
|
||||||
|
const guestAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||||
|
|
||||||
|
// randomFrom returns n characters of guestAlphabet, chosen without modulo bias.
|
||||||
|
func randomFrom(n int) (string, error) {
|
||||||
|
out := make([]byte, 0, n)
|
||||||
|
buf := make([]byte, n)
|
||||||
|
limit := byte(256 - 256%len(guestAlphabet)) // the top of the last whole run of the alphabet
|
||||||
|
for len(out) < n {
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, b := range buf {
|
||||||
|
if b >= limit {
|
||||||
|
continue // would favour the first few letters; ask for another byte instead
|
||||||
|
}
|
||||||
|
out = append(out, guestAlphabet[int(b)%len(guestAlphabet)])
|
||||||
|
if len(out) == n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuestPassword is a password for a guest login: four groups of four, ~78 bits, in an
|
||||||
|
// alphabet with nothing ambiguous in it.
|
||||||
|
func GuestPassword() (string, error) {
|
||||||
|
var groups []string
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
g, err := randomFrom(4)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
groups = append(groups, g)
|
||||||
|
}
|
||||||
|
return strings.Join(groups, "-"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GuestSuffix is the tail of a generated guest name.
|
||||||
|
func GuestSuffix() (string, error) { return randomFrom(4) }
|
||||||
|
|
||||||
// ---- sessions ----
|
// ---- sessions ----
|
||||||
|
|
||||||
// Sessions is a token store backed by the state file. Expiry is sliding.
|
// Sessions is a token store backed by the state file. Expiry is sliding.
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ type User struct {
|
|||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
LastSeen time.Time `json:"last_seen,omitempty"`
|
||||||
|
// Guest marks a temporary login made to show somebody the console: it may only read,
|
||||||
|
// it expires by itself at Expires, and it is not one of the machine's accounts — it is
|
||||||
|
// left out of the account list, does not count towards "the last account", and cannot
|
||||||
|
// be the one an empty user name means. See internal/api/guests.go.
|
||||||
|
Guest bool `json:"guest,omitempty"`
|
||||||
|
// Expires is when a guest login stops working. A guest without one has already
|
||||||
|
// expired, which is the safe way to read a half-written file.
|
||||||
|
Expires time.Time `json:"expires,omitempty"`
|
||||||
|
// IssuedBy is the account that made this guest login.
|
||||||
|
IssuedBy string `json:"issued_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired reports whether a guest login's time is up. A real account never expires; a
|
||||||
|
// guest with no expiry at all counts as expired rather than as forever.
|
||||||
|
func (u User) Expired(now time.Time) bool {
|
||||||
|
return u.Guest && (u.Expires.IsZero() || now.After(u.Expires))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field
|
// UpdateSource is where this machine looks for a newer Ham Lab, and how often. The field
|
||||||
@@ -145,6 +161,18 @@ func (c *Config) MigrateUsers(name string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts is every real account: the machine's users, without the guest logins.
|
||||||
|
// Call under View/Update or on a config nobody else holds.
|
||||||
|
func (c *Config) Accounts() []User {
|
||||||
|
out := make([]User, 0, len(c.Users))
|
||||||
|
for _, u := range c.Users {
|
||||||
|
if !u.Guest {
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// User finds an account by name. Call under View/Update or on a config nobody else holds.
|
// User finds an account by name. Call under View/Update or on a config nobody else holds.
|
||||||
func (c *Config) User(name string) (User, bool) {
|
func (c *Config) User(name string) (User, bool) {
|
||||||
for _, u := range c.Users {
|
for _, u := range c.Users {
|
||||||
|
|||||||
+5
-2
@@ -7,13 +7,16 @@ import { applyTheme, currentThemeId } from './themes'
|
|||||||
import { getHistorySec, getRefreshMs, setHistorySec, setRefreshMs } from './prefs'
|
import { getHistorySec, getRefreshMs, setHistorySec, setRefreshMs } from './prefs'
|
||||||
import { setAccents } from './accents'
|
import { setAccents } from './accents'
|
||||||
import { setRailOrder } from './railorder'
|
import { setRailOrder } from './railorder'
|
||||||
|
import { setGuest } from './guest'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [me, setMe] = useState<Me | null | undefined>(undefined)
|
const [me, setMe] = useState<Me | null | undefined>(undefined)
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const m = await api.meHere(); setMe(m)
|
const m = await api.meHere()
|
||||||
|
setGuest(!!m.guest, m.guest_until) // before anything renders: pages ask this, not a prop
|
||||||
|
setMe(m)
|
||||||
api.uiPrefs().then((p) => {
|
api.uiPrefs().then((p) => {
|
||||||
if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme)
|
if (p.theme && p.theme !== currentThemeId()) applyTheme(p.theme)
|
||||||
if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs)
|
if (p.refreshMs && p.refreshMs !== getRefreshMs()) setRefreshMs(p.refreshMs)
|
||||||
@@ -29,7 +32,7 @@ export default function App() {
|
|||||||
useEffect(() => { refresh() }, [refresh])
|
useEffect(() => { refresh() }, [refresh])
|
||||||
// Any page that gets a 401 from the hub sends us straight back to the login form.
|
// Any page that gets a 401 from the hub sends us straight back to the login form.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const h = () => setMe((m) => (m ? null : m))
|
const h = () => { setGuest(false); setMe((m) => (m ? null : m)) }
|
||||||
window.addEventListener('hamlab:unauthenticated', h)
|
window.addEventListener('hamlab:unauthenticated', h)
|
||||||
return () => window.removeEventListener('hamlab:unauthenticated', h)
|
return () => window.removeEventListener('hamlab:unauthenticated', h)
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
+17
-13
@@ -7,6 +7,7 @@ import TaskOutput from './TaskOutput'
|
|||||||
import StackNew, { DupFrom } from './StackNew'
|
import StackNew, { DupFrom } from './StackNew'
|
||||||
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
|
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
|
||||||
import { useHashSel } from './hashsel'
|
import { useHashSel } from './hashsel'
|
||||||
|
import { isGuest } from './guest'
|
||||||
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
|
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
|
||||||
import Sparkline from './Sparkline'
|
import Sparkline from './Sparkline'
|
||||||
import { fmtBytes } from './fmt'
|
import { fmtBytes } from './fmt'
|
||||||
@@ -399,6 +400,9 @@ export default function Docker() {
|
|||||||
const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null)
|
const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null)
|
||||||
const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('')
|
const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('')
|
||||||
const rate = useRefreshMs()
|
const rate = useRefreshMs()
|
||||||
|
// A guest login may read the lists and nothing else: no detail pane (a container's
|
||||||
|
// environment and logs are where the secrets are), no actions, no image checks.
|
||||||
|
const guest = isGuest()
|
||||||
const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, [])
|
const load = useCallback(async () => { try { setD(await api.docker()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } api.stacks().then((r) => setStacks(r.stacks)).catch(() => setStacks([])) }, [])
|
||||||
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
|
useEffect(() => { load(); const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)); return () => clearInterval(t) }, [load, rate])
|
||||||
const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) })
|
const live = useSensorStream<{ c: Record<string, Sensor> }>({ c: 'container.*' }, { everyMs: Math.max(2000, rate) })
|
||||||
@@ -414,7 +418,7 @@ export default function Docker() {
|
|||||||
// What the registry last said about each image, so a container can be marked without
|
// What the registry last said about each image, so a container can be marked without
|
||||||
// asking again. Only ever read from the cache here — checking is a deliberate act.
|
// asking again. Only ever read from the cache here — checking is a deliberate act.
|
||||||
const [upd, setUpd] = useState<MachineUpdates | null>(null)
|
const [upd, setUpd] = useState<MachineUpdates | null>(null)
|
||||||
useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [])
|
useEffect(() => { if (!guest) api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [guest])
|
||||||
const imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd])
|
const imgState = useMemo(() => Object.fromEntries((upd?.images ?? []).map((i) => [i.image, i.state])), [upd])
|
||||||
const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length
|
const behind = (upd?.images ?? []).filter((i) => i.state === 'update').length
|
||||||
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
|
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
|
||||||
@@ -429,22 +433,22 @@ export default function Docker() {
|
|||||||
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '') + (shownTab === 'updates' ? ' wide' : '')}>
|
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '') + (shownTab === 'updates' ? ' wide' : '')}>
|
||||||
<div className="svc-list">
|
<div className="svc-list">
|
||||||
<div className="svc-tools">
|
<div className="svc-tools">
|
||||||
<div className="chips">{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div>
|
<div className="chips">{(guest ? ['containers', 'stacks', 'images', 'volumes', 'networks'] as Tab[] : ['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div>
|
||||||
{(shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
|
{!guest && (shownTab === 'containers' || shownTab === 'stacks') && <div className="svc-actions"><button className="btn" onClick={() => setMaking(shownTab === 'stacks' ? 'stack' : 'container')}>{shownTab === 'stacks' ? '+ New stack' : '+ New container'}</button></div>}
|
||||||
{shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
|
{!guest && shownTab === 'images' && <form className="chan-add" onSubmit={async (e) => { e.preventDefault(); try { setPullTask((await api.pullImage(pull.trim())).id) } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>
|
||||||
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
<input className="mono" value={pull} onChange={(e) => setPull(e.target.value)} placeholder="pull an image — nginx:alpine, ghcr.io/owner/name:tag" spellCheck={false} autoCapitalize="off" autoCorrect="off" />
|
||||||
<button className="btn on" disabled={!pull.trim()}>Pull</button>
|
<button className="btn on" disabled={!pull.trim()}>Pull</button>
|
||||||
{(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && <Btn className="btn danger" title="remove every image no container uses" confirm={`Remove all ${unused} image${unused === 1 ? '' : 's'} that no container uses? They can be pulled again, but a stack that is down will have to download its images before it next starts.`} onClick={async () => { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused}</Btn> })()}
|
{(() => { const unused = d.images.filter((im) => !im.in_use).length; return unused > 0 && <Btn className="btn danger" title="remove every image no container uses" confirm={`Remove all ${unused} image${unused === 1 ? '' : 's'} that no container uses? They can be pulled again, but a stack that is down will have to download its images before it next starts.`} onClick={async () => { try { toast((await api.pruneImages(true)).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Remove unused · {unused}</Btn> })()}
|
||||||
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
|
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
|
||||||
</form>}
|
</form>}
|
||||||
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
|
{!guest && shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
|
||||||
{shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()}
|
{!guest && shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()}
|
||||||
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
|
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
|
||||||
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="svc-rows">
|
<div className="svc-rows">
|
||||||
{shownTab === 'stacks' && (stacks ?? []).map((st) => (
|
{shownTab === 'stacks' && (stacks ?? []).map((st) => (
|
||||||
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info')}>
|
<div key={st.name} className={'svc-row' + (st.name === selStack ? ' active' : '')} onClick={() => { if (guest) return; st.editable ? setSel('stack:' + st.name) : toast(st.note ?? 'not editable here', 'info') }}>
|
||||||
<span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
|
<span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} />
|
||||||
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div>
|
<div className="svc-name"><div className="n">{st.name}</div><div className="d">{st.editable ? (st.held_by ? `on ${st.held_by}: ` : '') + st.host_file : st.note}</div></div>
|
||||||
<div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div>
|
<div className="svc-state">{st.containers.length === 0 ? 'not deployed' : `${st.running}/${st.containers.length}`}{!st.editable && <><br />read-only</>}</div>
|
||||||
@@ -455,7 +459,7 @@ export default function Docker() {
|
|||||||
<div key={stack}>
|
<div key={stack}>
|
||||||
<h3 className="stack-head">{stack} <span className="muted">· {list.filter((c) => c.state === 'running').length}/{list.length}</span></h3>
|
<h3 className="stack-head">{stack} <span className="muted">· {list.filter((c) => c.state === 'running').length}/{list.length}</span></h3>
|
||||||
{list.map((c) => (
|
{list.map((c) => (
|
||||||
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => setSel(c.name)}>
|
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => { if (!guest) setSel(c.name) }}>
|
||||||
<span className={'sdot ' + dot(c)} />
|
<span className={'sdot ' + dot(c)} />
|
||||||
<div className="svc-name"><div className="n">{c.name}<UpdateDot state={imgState[c.image]} /></div><div className="d">{c.image}</div></div>
|
<div className="svc-name"><div className="n">{c.name}<UpdateDot state={imgState[c.image]} /></div><div className="d">{c.image}</div></div>
|
||||||
<div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
|
<div className="svc-state">{c.state === 'running' ? <>{fmtValue(sid(c, 'mem')?.value ?? c.mem, 'B')}<br />{fmtValue(sid(c, 'cpu')?.value, '%')}</> : c.state}</div>
|
||||||
@@ -472,26 +476,26 @@ export default function Docker() {
|
|||||||
else { const typed = await askText(`Delete the volume "${name}" and everything in it?\n\nNothing uses it right now — but if it belongs to a stack that is only down, this is that stack's data. It can't be brought back.\n\nType its name to confirm.`, '', 'Delete'); if (typed !== name) { if (typed !== null) toast('that is not its name — nothing was deleted', 'info'); return } }
|
else { const typed = await askText(`Delete the volume "${name}" and everything in it?\n\nNothing uses it right now — but if it belongs to a stack that is only down, this is that stack's data. It can't be brought back.\n\nType its name to confirm.`, '', 'Delete'); if (typed !== name) { if (typed !== null) toast('that is not its name — nothing was deleted', 'info'); return } }
|
||||||
try { await api.removeVolume(name); toast('volume deleted'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') }
|
try { await api.removeVolume(name); toast('volume deleted'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') }
|
||||||
}
|
}
|
||||||
return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
|
return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : guest ? null : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
|
||||||
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
|
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
|
||||||
)}
|
)}
|
||||||
{shownTab === 'networks' && (
|
{shownTab === 'networks' && (
|
||||||
<table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead>
|
<table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead>
|
||||||
<tbody>{d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined
|
<tbody>{d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined
|
||||||
return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })}
|
return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own || guest ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })}
|
||||||
{d.networks.length === 0 && <tr><td colSpan={4} className="muted small">No networks.</td></tr>}</tbody></table>
|
{d.networks.length === 0 && <tr><td colSpan={4} className="muted small">No networks.</td></tr>}</tbody></table>
|
||||||
)}
|
)}
|
||||||
{shownTab === 'images' && (
|
{shownTab === 'images' && (
|
||||||
<table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
|
<table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
|
||||||
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr>)}</tbody></table>
|
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : guest ? null : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr>)}</tbody></table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
|
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><CloseBtn onClick={() => setSel('')} /><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
|
||||||
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
|
{shownTab === 'containers' && (cur && !guest ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack && !guest ? null : <div className="svc-empty muted">{guest ? 'A guest login sees the lists only.' : 'Select a container.'}</div>))}
|
||||||
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
|
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
|
||||||
{shownTab === 'updates' && <Updates upd={upd} onChecked={setUpd} />}
|
{shownTab === 'updates' && <Updates upd={upd} onChecked={setUpd} />}
|
||||||
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
{shownTab === 'stacks' && (guest ? <div className="svc-empty muted">A guest login sees the lists only.</div> : selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-4
@@ -26,6 +26,7 @@ import { LIST_FLOOR, useRefreshMs } from './prefs'
|
|||||||
import Power from './Power'
|
import Power from './Power'
|
||||||
import { applyAccent } from './accents'
|
import { applyAccent } from './accents'
|
||||||
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
|
import { Btn, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui'
|
||||||
|
import { fmtLeft, useCountdown } from './guest'
|
||||||
|
|
||||||
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
// Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour).
|
||||||
const FolderIcon = (
|
const FolderIcon = (
|
||||||
@@ -75,6 +76,10 @@ const GROUPS: Group[] = [
|
|||||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
||||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
||||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
||||||
|
// What a guest login is shown: the pages whose reads the server allows it (guestRoutes in
|
||||||
|
// internal/api/guests.go). Every other page would be a wall of 403s, and the two that are
|
||||||
|
// missing on purpose — Terminal and Files — are the ones a look-around login must not have.
|
||||||
|
const GUEST_VIEWS: View[] = ['procs', 'docker', 'disks', 'usage', 'zfs', 'hardware', 'widgets']
|
||||||
|
|
||||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||||
// forward work, reload restores the tab, and links can point at a page on a machine.
|
// forward work, reload restores the tab, and links can point at a page on a machine.
|
||||||
@@ -106,6 +111,8 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
const [remoteErr, setRemoteErr] = useState('')
|
const [remoteErr, setRemoteErr] = useState('')
|
||||||
// A machine is titled by its own hostname; the name it was added under stays its identity
|
// A machine is titled by its own hostname; the name it was added under stays its identity
|
||||||
// (the URL, its accent, what the hub's API calls it). See titleOf.
|
// (the URL, its accent, what the hub's API calls it). See titleOf.
|
||||||
|
const guest = !!hubMe.guest
|
||||||
|
const leftMs = useCountdown(guest)
|
||||||
const title = titleOf(machine, machines, hubMe.host)
|
const title = titleOf(machine, machines, hubMe.host)
|
||||||
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: title, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
|
const me: Me = machine ? (remoteMe ?? { ...hubMe, host: title, load: '', version: '…', uptime: 0, reboot_needed: false }) : hubMe
|
||||||
const listRate = useRefreshMs(LIST_FLOOR)
|
const listRate = useRefreshMs(LIST_FLOOR)
|
||||||
@@ -118,12 +125,13 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
const handles = useRef<Record<string, TermHandle>>({})
|
const handles = useRef<Record<string, TermHandle>>({})
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
|
if (guest) { setTerms([]); return } // a guest has no shell, here or anywhere
|
||||||
try {
|
try {
|
||||||
const list = await api.terminals()
|
const list = await api.terminals()
|
||||||
setTerms(list)
|
setTerms(list)
|
||||||
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
|
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
|
||||||
} catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') }
|
} catch (e) { setTerms([]); if (machine) setRemoteErr(e instanceof Error ? e.message : 'unreachable') }
|
||||||
}, [machine])
|
}, [machine, guest])
|
||||||
|
|
||||||
useEffect(() => { reload() }, [reload])
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
@@ -156,16 +164,18 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
// keep the hash in step, and follow it when the browser navigates
|
// keep the hash in step, and follow it when the browser navigates
|
||||||
const [firing, setFiring] = useState(0)
|
const [firing, setFiring] = useState(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (guest) return
|
||||||
const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) }
|
const tick = () => { if (document.visibilityState === 'visible') api.alerts().then((a) => setFiring(a.firing.length)).catch(() => {}) }
|
||||||
tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick)
|
tick(); const t = setInterval(tick, 15000); window.addEventListener('hamlab:alerts', tick)
|
||||||
return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) }
|
return () => { clearInterval(t); window.removeEventListener('hamlab:alerts', tick) }
|
||||||
}, [])
|
}, [guest])
|
||||||
// The machine's colour: set on <html>, so the header stripe and the switcher pick it up.
|
// The machine's colour: set on <html>, so the header stripe and the switcher pick it up.
|
||||||
// Hub-only pages make no sense on a remote machine. Judged once both have settled, not while
|
// Hub-only pages make no sense on a remote machine. Judged once both have settled, not while
|
||||||
// the machine is changing: the URL sets the machine and the page in one go, and deciding from
|
// the machine is changing: the URL sets the machine and the page in one go, and deciding from
|
||||||
// inside the machine change read the page we were leaving — so a link from Settings to a
|
// inside the machine change read the page we were leaving — so a link from Settings to a
|
||||||
// machine's Disks arrived at its Dashboard.
|
// machine's Disks arrived at its Dashboard.
|
||||||
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view])
|
useEffect(() => { if (machine && HUB_ONLY.includes(view)) setView('procs') }, [machine, view])
|
||||||
|
useEffect(() => { if (guest && !GUEST_VIEWS.includes(view)) setView('procs') }, [guest, view])
|
||||||
|
|
||||||
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
|
useEffect(() => { const paint = () => applyAccent(machine ?? hubMe.host); paint(); window.addEventListener('hamlab:accents', paint); return () => window.removeEventListener('hamlab:accents', paint) }, [machine, hubMe.host])
|
||||||
// The tab's title says where you are, so history entries and open tabs can be told apart.
|
// The tab's title says where you are, so history entries and open tabs can be told apart.
|
||||||
@@ -273,13 +283,14 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
|
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
|
||||||
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
|
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${title} unreachable` : online ? 'connected' : 'disconnected'} />
|
||||||
<Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />
|
{!guest && <Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />}
|
||||||
|
{guest && <span className="badge guest" title={`this login expires ${new Date(hubMe.guest_until ?? '').toLocaleTimeString()}`}>guest · {fmtLeft(leftMs)}</span>}
|
||||||
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
|
{hubMe.account && <span className="host account" title="signed in as">{hubMe.account}</span>}
|
||||||
<Btn className="link" onClick={logout}>logout</Btn>
|
<Btn className="link" onClick={logout}>logout</Btn>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="body">
|
<div className="body">
|
||||||
<Rail groups={machine ? GROUPS.filter((g) => !HUB_ONLY.includes(g.id as View)) : GROUPS} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }}
|
<Rail groups={GROUPS.filter((g) => (guest ? GUEST_VIEWS.includes(g.id as View) : !machine || !HUB_ONLY.includes(g.id as View)))} view={view} badges={{ alerts: firing, settings: hubMe.update_available ? 1 : 0 }}
|
||||||
header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
header={<MachineSwitcher hub={hubMe.host} hubAddr={hubMe.addr} current={machine} machines={machines} onChange={switchMachine} onManage={() => { setView('machines'); setDrawer(false) }} />}
|
||||||
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
|
onView={async (id) => { if (id !== view && !(await okToLeave())) return; setView(id as View); setDrawer(false) }} />
|
||||||
<div className="backdrop" onClick={() => setDrawer(false)} />
|
<div className="backdrop" onClick={() => setDrawer(false)} />
|
||||||
|
|||||||
+80
-1
@@ -3,19 +3,98 @@
|
|||||||
// a token rather than a login, so a node's accounts are managed on that node's console.
|
// a token rather than a login, so a node's accounts are managed on that node's console.
|
||||||
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { api, Me, Session, User } from './api'
|
import { api, FreshGuest, Guest, Me, Session, User } from './api'
|
||||||
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||||
|
import { fmtLeft } from './guest'
|
||||||
|
|
||||||
export default function Users({ me }: { me: Me }) {
|
export default function Users({ me }: { me: Me }) {
|
||||||
return (
|
return (
|
||||||
<div className="dash settings">
|
<div className="dash settings">
|
||||||
<Account me={me} />
|
<Account me={me} />
|
||||||
<Sessions />
|
<Sessions />
|
||||||
|
<Guests />
|
||||||
<Accounts me={me} />
|
<Accounts me={me} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GUEST_MINUTES = [15, 30, 60, 120, 480] as const
|
||||||
|
|
||||||
|
// A login to hand somebody so they can look at the console: it expires on its own, it can be
|
||||||
|
// ended here at any moment, and it can only read. Making one is the only way to see its
|
||||||
|
// password, so it is shown until dismissed rather than in a toast.
|
||||||
|
function Guests() {
|
||||||
|
const [list, setList] = useState<Guest[] | null>(null)
|
||||||
|
const [minutes, setMinutes] = useState<number>(30)
|
||||||
|
const [fresh, setFresh] = useState<FreshGuest | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const load = useCallback(async () => { try { setList(await api.guests()) } catch { setList([]) } }, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
// The clock is the point of the thing, so it keeps up without a reload.
|
||||||
|
useEffect(() => { const t = setInterval(load, 30000); return () => clearInterval(t) }, [load])
|
||||||
|
const create = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
try { setFresh(await api.createGuest(minutes)); load() }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const revoke = async (g: Guest) => {
|
||||||
|
if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once.`, 'Revoke')) return
|
||||||
|
try { await api.revokeGuest(g.name); toast(`${g.name} revoked`); if (fresh?.name === g.name) setFresh(null); load() }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="dash-bar"><h2 className="section">Guest logins</h2><span className="spacer" /><span className="muted small">temporary, and can only look</span></div>
|
||||||
|
<section className="card">
|
||||||
|
{list === null && <Loading label="Loading…" />}
|
||||||
|
{list?.length === 0 && <div className="empty">None. One lets somebody look around without an account.</div>}
|
||||||
|
{list?.map((g) => {
|
||||||
|
const left = new Date(g.expires).getTime() - Date.now()
|
||||||
|
return (
|
||||||
|
<div className="row" key={g.name}>
|
||||||
|
<span className="k mono">{g.name}</span>
|
||||||
|
<span className="muted small">{left > 0 ? `expires in ${fmtLeft(left)}` : 'expiring'} · {g.sessions > 0 ? `${g.sessions} signed in` : g.used ? 'signed in earlier' : 'not used yet'}{g.issued_by ? ` · by ${g.issued_by}` : ''}</span>
|
||||||
|
<Btn className="btn danger" onClick={() => revoke(g)}>Revoke</Btn>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
{fresh && <FreshCard g={fresh} onClose={() => setFresh(null)} />}
|
||||||
|
<div className="card addform" style={{ marginTop: 8 }}>
|
||||||
|
<label>Lasts
|
||||||
|
<select value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}>
|
||||||
|
{GUEST_MINUTES.map((m) => <option key={m} value={m}>{m < 60 ? `${m} minutes` : `${m / 60} hour${m === 60 ? '' : 's'}`}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="svc-actions"><Btn className="btn on" busy={busy} onClick={create}>Create guest login</Btn><span className="muted small">A guest sees the dashboard, containers, disks and hardware, read-only. No terminal, no files, no settings, nothing it can change. It stops working when the time is up, or when you revoke it.</span></div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The credentials, once. Copy takes the whole invitation, because that is what gets pasted
|
||||||
|
// into a message.
|
||||||
|
function FreshCard({ g, onClose }: { g: FreshGuest; onClose: () => void }) {
|
||||||
|
const invite = `${location.origin}\nuser: ${g.name}\npassword: ${g.password}\nexpires ${new Date(g.expires).toLocaleTimeString()}`
|
||||||
|
const copy = async (text: string, what: string) => {
|
||||||
|
try { await navigator.clipboard.writeText(text); toast(`${what} copied`) }
|
||||||
|
catch { toast('select and copy it by hand', 'err') }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="card fresh" style={{ marginTop: 8 }}>
|
||||||
|
<div className="row"><span className="k">User</span><span className="mono">{g.name}</span><Btn className="btn" onClick={() => copy(g.name, 'user')}>Copy</Btn></div>
|
||||||
|
<div className="row"><span className="k">Password</span><span className="mono">{g.password}</span><Btn className="btn" onClick={() => copy(g.password, 'password')}>Copy</Btn></div>
|
||||||
|
<div className="svc-actions" style={{ marginTop: 8 }}>
|
||||||
|
<Btn className="btn on" onClick={() => copy(invite, 'invitation')}>Copy invitation</Btn>
|
||||||
|
<span className="muted small">Shown once. Expires {new Date(g.expires).toLocaleTimeString()}.</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="btn" onClick={onClose}>Done</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Account({ me }: { me: Me }) {
|
function Account({ me }: { me: Me }) {
|
||||||
const [cur, setCur] = useState('')
|
const [cur, setCur] = useState('')
|
||||||
const [pw1, setPw1] = useState('')
|
const [pw1, setPw1] = useState('')
|
||||||
|
|||||||
+15
-1
@@ -1,5 +1,12 @@
|
|||||||
|
import { isGuest } from './guest'
|
||||||
|
|
||||||
export class Unauthenticated extends Error { constructor(msg = 'unauthenticated') { super(msg) } }
|
export class Unauthenticated extends Error { constructor(msg = 'unauthenticated') { super(msg) } }
|
||||||
|
|
||||||
|
// What a guest login may send. Everything else that would change something is refused here
|
||||||
|
// as well as by the server, so a button a page forgot to hide fails without a round trip.
|
||||||
|
const GUEST_WRITES = new Set(['POST /api/logout', 'PUT /api/prefs/ui'])
|
||||||
|
export const guestRefusal = 'a guest login can only look around'
|
||||||
|
|
||||||
// Which machine the console is looking at. '' is this host; otherwise every /api/… call
|
// Which machine the console is looking at. '' is this host; otherwise every /api/… call
|
||||||
// is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login,
|
// is rewritten to /api/nodes/<name>/… and the hub proxies it. Hub-only calls (login,
|
||||||
// the machine list itself) pass local=true and are never rewritten.
|
// the machine list itself) pass local=true and are never rewritten.
|
||||||
@@ -19,6 +26,7 @@ function track(delta: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function req<T>(method: string, path: string, body?: unknown, local = false): Promise<T> {
|
async function req<T>(method: string, path: string, body?: unknown, local = false): Promise<T> {
|
||||||
|
if (isGuest() && method !== 'GET' && !GUEST_WRITES.has(`${method} ${path}`)) throw new Error(guestRefusal)
|
||||||
const target = local ? path : apiPath(path)
|
const target = local ? path : apiPath(path)
|
||||||
const mach = machineOf(target)
|
const mach = machineOf(target)
|
||||||
if (mach && downUntil[mach] > Date.now()) throw new Error(`${mach} unreachable (retrying shortly)`)
|
if (mach && downUntil[mach] > Date.now()) throw new Error(`${mach} unreachable (retrying shortly)`)
|
||||||
@@ -55,7 +63,7 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
|
export interface UpdateStatus { current: string; latest: string; arch: string; behind: boolean; checking: boolean; installing: boolean; enabled: boolean; auto: boolean; url: string; notes?: string; published?: string; checked?: string; error?: string }
|
||||||
export interface Me { addr?: string; host: string; user: string; account?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
|
export interface Me { addr?: string; host: string; user: string; account?: string; guest?: boolean; guest_until?: string; update_available?: string; version: string; load: string; uptime: number; reboot_needed?: boolean; fingerprint?: string; arch?: string }
|
||||||
export interface PushResult { name: string; ok: boolean; version?: string; error?: string }
|
export interface PushResult { name: string; ok: boolean; version?: string; error?: string }
|
||||||
// What a machine is called on screen: the hostname it reports, which follows a rename within
|
// What a machine is called on screen: the hostname it reports, which follows a rename within
|
||||||
// a poll. The name it was added under stays its identity — the URL, its accent colour, and
|
// a poll. The name it was added under stays its identity — the URL, its accent colour, and
|
||||||
@@ -158,6 +166,9 @@ export interface UIPrefs { theme?: string; refreshMs?: number; accents?: Record<
|
|||||||
export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean }
|
export interface User { name: string; created: string; last_seen?: string; sessions: number; self: boolean }
|
||||||
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
|
export interface Session { id: string; created: string; last_seen: string; expires: string; ip?: string; agent?: string; current: boolean }
|
||||||
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
|
export interface TokenInfo { name: string; created: string; scope?: string; sensors?: string[]; issued_by?: string }
|
||||||
|
// A guest login: temporary, look-only, gone at `expires` or when revoked.
|
||||||
|
export interface Guest { name: string; created: string; expires: string; issued_by?: string; sessions: number; used: boolean }
|
||||||
|
export interface FreshGuest { name: string; password: string; expires: string; minutes: number }
|
||||||
|
|
||||||
export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string }
|
export interface SpaceProgress { files: number; dirs: number; reused: number; bytes: number; denied: number; current: string }
|
||||||
export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> }
|
export interface SpaceScan { disk: string; roots: string[]; started: string; finished?: string; running: boolean; loading?: boolean; error?: string; progress: SpaceProgress; as_root: boolean; method?: string; by_root?: Record<string, string>; partial?: boolean; files: number; dirs: number; size: number; denied: number; read: number; reused: number; partial_dirs: number; bases?: Record<string, string> }
|
||||||
@@ -261,6 +272,9 @@ export const api = {
|
|||||||
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
|
addUser: (name: string, password: string) => req<{ name: string }>('POST', '/api/users', { name, password }, true),
|
||||||
removeUser: (name: string, current: string) => req<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true),
|
removeUser: (name: string, current: string) => req<void>('DELETE', `/api/users/${encodeURIComponent(name)}`, { current }, true),
|
||||||
resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true),
|
resetPassword: (name: string, current: string, password: string) => req<void>('POST', `/api/users/${encodeURIComponent(name)}/password`, { current, password }, true),
|
||||||
|
guests: () => req<Guest[]>('GET', '/api/guests', undefined, true),
|
||||||
|
createGuest: (minutes: number) => req<FreshGuest>('POST', '/api/guests', { minutes }, true),
|
||||||
|
revokeGuest: (name: string) => req<void>('DELETE', `/api/guests/${encodeURIComponent(name)}`, undefined, true),
|
||||||
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
|
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
|
||||||
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
|
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
|
||||||
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
|
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Whether this console is being looked at through a guest login, kept here so any page can
|
||||||
|
// ask without it being threaded through every prop. Set from /api/me when the app loads.
|
||||||
|
//
|
||||||
|
// This is politeness, not security: a guest is stopped by the server, which answers 403 to
|
||||||
|
// everything but the handful of reads it allows (internal/api/guests.go). What it buys is a
|
||||||
|
// console that doesn't offer a visitor buttons that would only fail.
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
let guest = false
|
||||||
|
let until = ''
|
||||||
|
|
||||||
|
export function setGuest(on: boolean, expires?: string) {
|
||||||
|
guest = on
|
||||||
|
until = expires ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isGuest = () => guest
|
||||||
|
|
||||||
|
// msLeft is how long this guest login has, or null when it isn't one.
|
||||||
|
export function msLeft(): number | null {
|
||||||
|
if (!guest || !until) return null
|
||||||
|
return Math.max(0, new Date(until).getTime() - Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtLeft says how long is left, in the roundest terms that are still honest.
|
||||||
|
export function fmtLeft(ms: number): string {
|
||||||
|
const min = Math.floor(ms / 60000)
|
||||||
|
if (min >= 120) return `${Math.floor(min / 60)} h ${min % 60} min`
|
||||||
|
if (min >= 1) return `${min} min`
|
||||||
|
return 'under a minute'
|
||||||
|
}
|
||||||
|
|
||||||
|
// useCountdown re-renders the caller every 15 seconds, for a clock that has to keep up.
|
||||||
|
export function useCountdown(active: boolean): number {
|
||||||
|
const [, tick] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
const t = setInterval(() => tick((n) => n + 1), 15000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [active])
|
||||||
|
return msLeft() ?? 0
|
||||||
|
}
|
||||||
+5
-1
@@ -418,8 +418,12 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
|
.mitem .mname { flex: 1; } .mitem.manage { color: var(--accent2); }
|
||||||
.addform { display: flex; flex-direction: column; gap: 10px; }
|
.addform { display: flex; flex-direction: column; gap: 10px; }
|
||||||
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
.addform label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||||
.addform input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
|
.addform input, .addform select { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 15px; }
|
||||||
.header .badge.bad { color: var(--bad); border-color: var(--bad); }
|
.header .badge.bad { color: var(--bad); border-color: var(--bad); }
|
||||||
|
/* a guest login says so, and how long it has, where the power button would be */
|
||||||
|
.badge.guest { color: var(--accent2); border-color: var(--accent2); text-transform: none; margin-left: 0; white-space: nowrap; }
|
||||||
|
.card.fresh { border-color: var(--accent2); }
|
||||||
|
.card.fresh .mono { user-select: all; }
|
||||||
|
|
||||||
/* settings + themes */
|
/* settings + themes */
|
||||||
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user