A guest login is a full account with a clock on it
Mitchell, on the read-only version built earlier today: scratch it. He hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing solved a problem he doesn't have. So the allow-list, the middleware check and every "not for a guest" branch in the UI are gone. What is left is the part that was the point: a throwaway account that expires by itself, is checked on every request, swept once a minute, and can be ended in one tap. A guest now gets the whole console on every machine, terminal included. Said once, since it is his call and he has made it: that means a guest has a root-capable shell on every host, and can make an account or a token of its own that outlives the guest login. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8a7167bf03
commit
179f342a2c
@@ -18,7 +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. |
|
| Guest logins | A **guest** is an account like any other, with `guest: true` and an expiry: `login`, sessions and prefs need no special case, and **it may do everything an account may do**. The expiry is the whole feature — checked on every request and swept once a minute — with revoking as the manual version of it. | Built 2026-09-27. A read-only version was built first and **cut the same day**: Mitchell hands these to people he trusts and revokes them otherwise, so a console with half its tabs missing was solving a problem he doesn't have. Consequences, stated once and accepted: a guest has the Terminal tab, which is a root-capable shell on every host, and can make an account or token of its own that outlives the guest login. The one thing a guest is not is *an account*: it is left out of the Users list, out of "the last account can't be removed", and out of the single account an empty user name resolves to. |
|
||||||
| 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
|
||||||
|
|||||||
@@ -55,13 +55,12 @@ users` lists them, `hamlab user-rm <user>` removes one. Machine settings — ref
|
|||||||
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
|
**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
|
throwaway user name and password, good for half an hour by default (up to 24 hours), and
|
||||||
pair to paste into a message. A guest sees the dashboard, the containers, the disks, the
|
hands you the pair to paste into a message. A guest is an ordinary account with a clock on
|
||||||
hardware and the machines this hub manages, and can change none of it — no terminal, no
|
it — the whole console, every machine, the terminal included — that stops working when the
|
||||||
files, no logs or container environments, no settings, no power. It can be revoked in one
|
time is up, wherever it is signed in, or the moment you revoke it. There is no reduced
|
||||||
tap, and it disappears on its own when the time is up, wherever it is signed in. What a
|
guest: give one to somebody you would hand the password to, and revoke it if you change
|
||||||
guest may reach is a list of routes in `internal/api/guests.go`, so a route added later
|
your mind.
|
||||||
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:
|
||||||
|
|
||||||
|
|||||||
+10
-97
@@ -1,21 +1,22 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
// Guest logins: a temporary account for showing somebody the console. It expires by itself
|
// Guest logins: a temporary account for handing to somebody you want to show around. It is
|
||||||
// (half an hour by default), it can be revoked in one tap, and it may only look — it cannot
|
// an ordinary account with a clock on it — every tab, every action, on this machine and on
|
||||||
// change a thing, on this machine or on any machine this hub manages.
|
// every machine this hub manages — that stops working at its expiry (half an hour by
|
||||||
|
// default) or the moment it is revoked.
|
||||||
//
|
//
|
||||||
// The gate is a list of what a guest may reach, and nothing else, so a route added later is
|
// Deliberately not a reduced account: Mitchell, 2026-09-27, replacing a read-only version
|
||||||
// shut to guests until somebody decides otherwise. It is applied to the *scoped* path, which
|
// built the same day. He gives these to people he trusts and revokes them otherwise, and a
|
||||||
// is what makes it hold for managed machines too: the hub proxies to a node as itself, with a
|
// console with half its buttons missing is not the console he wanted to show off. What that
|
||||||
// token that is full control of that node, so the node cannot tell a guest from its owner —
|
// means in practice: a guest has the Terminal tab, which is a shell as the user the daemon
|
||||||
// the decision has to be made here, before the request leaves.
|
// runs as — root on every host here — and can make an account or a token of its own that
|
||||||
|
// outlives the guest login.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,86 +32,6 @@ const (
|
|||||||
guestReapEvery = time.Minute
|
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 ----
|
// ---- the accounts themselves ----
|
||||||
|
|
||||||
func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -135,10 +56,6 @@ func (s *Server) listGuests(w http.ResponseWriter, r *http.Request) {
|
|||||||
// readable, here as everywhere else a password is generated.
|
// readable, here as everywhere else a password is generated.
|
||||||
func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
|
||||||
me := actorOf(r)
|
me := actorOf(r)
|
||||||
if me.Guest {
|
|
||||||
jsonErr(w, 403, "a guest login can only look around")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Minutes int `json:"minutes"`
|
Minutes int `json:"minutes"`
|
||||||
}
|
}
|
||||||
@@ -194,10 +111,6 @@ func (s *Server) addGuest(w http.ResponseWriter, r *http.Request) {
|
|||||||
// revokeGuest ends a guest login now. No password: it only ever takes access away, and the
|
// 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.
|
// whole point of the thing is that ending it is one tap.
|
||||||
func (s *Server) revokeGuest(w http.ResponseWriter, r *http.Request) {
|
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")
|
name := r.PathValue("name")
|
||||||
found := false
|
found := false
|
||||||
if err := s.cfg.Update(func(c *config.Config) {
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
|
|||||||
+4
-139
@@ -3,9 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,143 +12,10 @@ import (
|
|||||||
"hamlab/internal/store"
|
"hamlab/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The router built without any of the machinery behind it: registering a route never calls
|
// A guest login is an ordinary account with a clock on it, so the clock is the thing worth
|
||||||
// its handler, so this is the real table, and what guestMay is asked about.
|
// testing: one whose time is up is stopped at the door, whether or not the minute-by-minute
|
||||||
func gateServer(t *testing.T) *Server {
|
// sweep has reached their account yet, and their session is dropped on the way — so the
|
||||||
t.Helper()
|
// console they left open goes back to the login form rather than carrying on.
|
||||||
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) {
|
func TestExpiredGuestIsRefusedAndSignedOut(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+5
-24
@@ -405,27 +405,17 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// signedIn reports whether a request carries a live browser session of a real account,
|
// signedIn reports whether a request carries a live browser session, without the
|
||||||
// without the side effects of the auth middleware. A guest login is not one: this is what
|
// side effects of the auth middleware.
|
||||||
// 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
|
||||||
}
|
}
|
||||||
ses, ok := s.sessions.Lookup(c.Value, clientKey(r))
|
_, ok := s.sessions.Lookup(c.Value, clientKey(r))
|
||||||
if !ok {
|
return 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// 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
|
// 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
|
// has gone away, or a guest login's time is up — so a console left open on a visitor's phone
|
||||||
@@ -455,8 +445,7 @@ func (s *Server) cookieActor(w http.ResponseWriter, r *http.Request) (a actor, o
|
|||||||
return actor{User: ses.User, Session: ses.ID, token: c.Value, Guest: u.Guest, Expires: u.Expires}, true, false
|
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
|
// auth accepts the browser session cookie or a bearer API token issued to a hub.
|
||||||
// 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) {
|
||||||
a, ok, answered := s.cookieActor(w, r)
|
a, ok, answered := s.cookieActor(w, r)
|
||||||
@@ -464,10 +453,6 @@ func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
if a.Guest && !s.guestMay(r) {
|
|
||||||
jsonErr(w, 403, guestRefusal)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next(w, withActor(r, a))
|
next(w, withActor(r, a))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -506,10 +491,6 @@ func (s *Server) sensorAuth(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
if a, live, answered := s.cookieActor(w, r); answered {
|
if a, live, answered := s.cookieActor(w, r); answered {
|
||||||
return
|
return
|
||||||
} else if live {
|
} else if live {
|
||||||
if a.Guest && !s.guestMay(r) {
|
|
||||||
jsonErr(w, 403, guestRefusal)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ok, trusted = true, true
|
ok, trusted = true, true
|
||||||
r = withActor(r, a)
|
r = withActor(r, a)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-5
@@ -7,16 +7,13 @@ 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()
|
const m = await api.meHere(); setMe(m)
|
||||||
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)
|
||||||
@@ -32,7 +29,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 = () => { setGuest(false); setMe((m) => (m ? null : m)) }
|
const h = () => 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)
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
+13
-17
@@ -7,7 +7,6 @@ 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'
|
||||||
@@ -400,9 +399,6 @@ 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) })
|
||||||
@@ -418,7 +414,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(() => { if (!guest) api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [guest])
|
useEffect(() => { api.machineUpdates().then(setUpd).catch(() => setUpd(null)) }, [])
|
||||||
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>
|
||||||
@@ -433,22 +429,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">{(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>
|
<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>
|
||||||
{!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 === '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 === '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') } }}>
|
{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>}
|
||||||
{!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 === '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 === '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 === '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={() => { if (guest) return; 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={() => 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>
|
||||||
@@ -459,7 +455,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={() => { if (!guest) setSel(c.name) }}>
|
<div key={c.id} className={'svc-row' + (c.name === sel ? ' active' : '')} onClick={() => 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>
|
||||||
@@ -476,26 +472,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> : guest ? null : <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> : <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 || 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> })}
|
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> })}
|
||||||
{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> : 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>
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 && !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>))}
|
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">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' && (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>)}
|
{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>)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-15
@@ -26,7 +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'
|
import { fmtLeft, useTimeLeft } 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 = (
|
||||||
@@ -76,10 +76,6 @@ 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.
|
||||||
@@ -109,10 +105,11 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
const [machines, setMachines] = useState<MachineStatus[]>([])
|
const [machines, setMachines] = useState<MachineStatus[]>([])
|
||||||
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
||||||
const [remoteErr, setRemoteErr] = useState('')
|
const [remoteErr, setRemoteErr] = useState('')
|
||||||
|
// A guest login is an account with a clock on it: nothing is hidden from it, but the header
|
||||||
|
// says how long it has, for whoever is holding the phone.
|
||||||
|
const leftMs = useTimeLeft(hubMe.guest_until)
|
||||||
// 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)
|
||||||
@@ -125,13 +122,12 @@ 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, guest])
|
}, [machine])
|
||||||
|
|
||||||
useEffect(() => { reload() }, [reload])
|
useEffect(() => { reload() }, [reload])
|
||||||
|
|
||||||
@@ -164,18 +160,16 @@ 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.
|
||||||
@@ -283,14 +277,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'} />
|
||||||
{!guest && <Power host={me.host} label={title} rebootNeeded={me.reboot_needed} />}
|
<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.guest && <span className="badge guest" title={`this guest 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={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 }}
|
<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 }}
|
||||||
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)} />
|
||||||
|
|||||||
+7
-7
@@ -20,9 +20,9 @@ export default function Users({ me }: { me: Me }) {
|
|||||||
|
|
||||||
const GUEST_MINUTES = [15, 30, 60, 120, 480] as const
|
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
|
// A login to hand somebody you are showing the console to: an ordinary account with a clock
|
||||||
// ended here at any moment, and it can only read. Making one is the only way to see its
|
// on it, which expires on its own and can be ended here at any moment. Making one is the only
|
||||||
// password, so it is shown until dismissed rather than in a toast.
|
// time its password is readable, so it is shown until dismissed rather than in a toast.
|
||||||
function Guests() {
|
function Guests() {
|
||||||
const [list, setList] = useState<Guest[] | null>(null)
|
const [list, setList] = useState<Guest[] | null>(null)
|
||||||
const [minutes, setMinutes] = useState<number>(30)
|
const [minutes, setMinutes] = useState<number>(30)
|
||||||
@@ -39,16 +39,16 @@ function Guests() {
|
|||||||
finally { setBusy(false) }
|
finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
const revoke = async (g: Guest) => {
|
const revoke = async (g: Guest) => {
|
||||||
if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once.`, 'Revoke')) return
|
if (!await askYes(`End the guest login ${g.name}? Whoever has it is signed out at once, everywhere.`, 'Revoke')) return
|
||||||
try { await api.revokeGuest(g.name); toast(`${g.name} revoked`); if (fresh?.name === g.name) setFresh(null); load() }
|
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') }
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
}
|
}
|
||||||
return (
|
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>
|
<div className="dash-bar"><h2 className="section">Guest logins</h2><span className="spacer" /><span className="muted small">temporary, and can be revoked</span></div>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
{list === null && <Loading label="Loading…" />}
|
{list === null && <Loading label="Loading…" />}
|
||||||
{list?.length === 0 && <div className="empty">None. One lets somebody look around without an account.</div>}
|
{list?.length === 0 && <div className="empty">None. One lets somebody in for a while without giving them an account.</div>}
|
||||||
{list?.map((g) => {
|
{list?.map((g) => {
|
||||||
const left = new Date(g.expires).getTime() - Date.now()
|
const left = new Date(g.expires).getTime() - Date.now()
|
||||||
return (
|
return (
|
||||||
@@ -67,7 +67,7 @@ function Guests() {
|
|||||||
{GUEST_MINUTES.map((m) => <option key={m} value={m}>{m < 60 ? `${m} minutes` : `${m / 60} hour${m === 60 ? '' : 's'}`}</option>)}
|
{GUEST_MINUTES.map((m) => <option key={m} value={m}>{m < 60 ? `${m} minutes` : `${m / 60} hour${m === 60 ? '' : 's'}`}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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 className="svc-actions"><Btn className="btn on" busy={busy} onClick={create}>Create guest login</Btn><span className="muted small">A guest can do everything you can, on every machine — including the terminal — until the time is up or you revoke it. Give one out accordingly.</span></div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-9
@@ -1,12 +1,5 @@
|
|||||||
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.
|
||||||
@@ -26,7 +19,6 @@ 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)`)
|
||||||
@@ -166,7 +158,7 @@ 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.
|
// A guest login: a full account with a clock on it, gone at `expires` or when revoked.
|
||||||
export interface Guest { name: string; created: string; expires: string; issued_by?: string; sessions: number; used: boolean }
|
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 FreshGuest { name: string; password: string; expires: string; minutes: number }
|
||||||
|
|
||||||
|
|||||||
+7
-25
@@ -1,27 +1,7 @@
|
|||||||
// Whether this console is being looked at through a guest login, kept here so any page can
|
// A guest login's clock. That is the whole difference between a guest and an account: it
|
||||||
// ask without it being threaded through every prop. Set from /api/me when the app loads.
|
// can do everything, until it can't, so what the console has to show is how long is left.
|
||||||
//
|
|
||||||
// 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'
|
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.
|
// fmtLeft says how long is left, in the roundest terms that are still honest.
|
||||||
export function fmtLeft(ms: number): string {
|
export function fmtLeft(ms: number): string {
|
||||||
const min = Math.floor(ms / 60000)
|
const min = Math.floor(ms / 60000)
|
||||||
@@ -30,13 +10,15 @@ export function fmtLeft(ms: number): string {
|
|||||||
return 'under a minute'
|
return 'under a minute'
|
||||||
}
|
}
|
||||||
|
|
||||||
// useCountdown re-renders the caller every 15 seconds, for a clock that has to keep up.
|
// useTimeLeft is the milliseconds until `until`, re-rendering the caller every 15 seconds so
|
||||||
export function useCountdown(active: boolean): number {
|
// the number keeps up. 0 when there is no expiry, which is every normal account.
|
||||||
|
export function useTimeLeft(until?: string): number {
|
||||||
const [, tick] = useState(0)
|
const [, tick] = useState(0)
|
||||||
|
const active = !!until
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const t = setInterval(() => tick((n) => n + 1), 15000)
|
const t = setInterval(() => tick((n) => n + 1), 15000)
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [active])
|
}, [active])
|
||||||
return msLeft() ?? 0
|
return until ? Math.max(0, new Date(until).getTime() - Date.now()) : 0
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user