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
+86
-19
@@ -140,9 +140,18 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
}
|
||||
go s.keepFleetInStep()
|
||||
}
|
||||
go s.watchGuests()
|
||||
go s.watchForUpdates(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.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
|
||||
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("DELETE /api/users/{name}", s.auth(s.removeUser))
|
||||
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("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
|
||||
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")
|
||||
}))
|
||||
m.HandleFunc("/", s.static)
|
||||
s.mux = m
|
||||
return s
|
||||
return m
|
||||
}
|
||||
|
||||
// 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
|
||||
// side effects of the auth middleware.
|
||||
// signedIn reports whether a request carries a live browser session of a real account,
|
||||
// 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 {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := s.sessions.Lookup(c.Value, clientKey(r))
|
||||
return ok
|
||||
ses, ok := s.sessions.Lookup(c.Value, clientKey(r))
|
||||
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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if ses, ok := s.sessions.Lookup(c.Value, clientKey(r)); ok {
|
||||
next(w, withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value}))
|
||||
a, ok, answered := s.cookieActor(w, r)
|
||||
if answered {
|
||||
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 ") {
|
||||
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) {
|
||||
ok, trusted := false, false
|
||||
var patterns []string
|
||||
if c, err := r.Cookie(cookieName); err == nil {
|
||||
if ses, valid := s.sessions.Lookup(c.Value, clientKey(r)); valid {
|
||||
ok, trusted = true, true
|
||||
r = withActor(r, actor{User: ses.User, Session: ses.ID, token: c.Value})
|
||||
if a, live, answered := s.cookieActor(w, r); answered {
|
||||
return
|
||||
} else if live {
|
||||
if a.Guest && !s.guestMay(r) {
|
||||
jsonErr(w, 403, guestRefusal)
|
||||
return
|
||||
}
|
||||
ok, trusted = true, true
|
||||
r = withActor(r, a)
|
||||
}
|
||||
if !ok {
|
||||
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
|
||||
// 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) {
|
||||
var users []config.User
|
||||
s.cfg.View(func(c *config.Config) { users = append(users, c.Users...) })
|
||||
var users, real []config.User
|
||||
s.cfg.View(func(c *config.Config) { users, real = append(users, c.Users...), c.Accounts() })
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" && len(users) == 1 {
|
||||
username = users[0].Name
|
||||
if username == "" && len(real) == 1 { // guest logins are not the account an empty name means
|
||||
username = real[0].Name
|
||||
}
|
||||
if username == "" {
|
||||
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
|
||||
}
|
||||
})
|
||||
me := actorOf(r)
|
||||
var guestUntil any
|
||||
if me.Guest {
|
||||
guestUntil = me.Expires
|
||||
}
|
||||
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,
|
||||
"reboot_needed": rebootErr == nil, "fingerprint": s.fingerprint, "arch": runtime.GOARCH, "addr": addr,
|
||||
"update_available": updateVersion,
|
||||
|
||||
Reference in New Issue
Block a user