diff --git a/CHANGELOG.md b/CHANGELOG.md index a89f67c..81e2653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,9 @@ publish a tag that has no section here. `network-config`, so replies from a storage or tenant VLAN leave through that VLAN's gateway. - With `admin_http` other than `allow`, browsers that open the web UI over HTTP are redirected to HTTPS (nodes keep HTTP for images and the agent). +- Admin client certificates: `rollcall admin-cert request NAME` (key stays on the client, CSR only), + `list`, `revoke`; the region accepts them as admin over HTTPS; CLI/console use them from + `client.json`, the web UI works without a token when the browser presents one (`.p12` written). - `rollcall version`; CD-built binary carries `git describe`. - Release notes from `CHANGELOG.md`. diff --git a/README.md b/README.md index 0d2ed4e..d936815 100644 --- a/README.md +++ b/README.md @@ -430,6 +430,14 @@ plane is: `ca_dir` (default `/etc/rollcall/ca`) holds `ca.pem` and another architecture get `rack_binaries/rollcall-linux-`; `"auto_update": false` in `rack.json` opts out. `rollcall controllers` shows each rack's version. +* **Admin certificates.** Instead of the admin token, a person or laptop can + hold a client certificate: `rollcall admin-cert request NAME` (with the + token once) makes a key locally, sends only a CSR and gets + `admin:NAME` back; the CLI and console use it from `client.json`, and the + `.p12` it writes goes into the browser's keychain, after which the web UI + signs in by itself. `rollcall admin-cert list | revoke NAME` (revocation + takes effect within 30 seconds). Certificates are checked against the CA, + the client-auth usage and the serials the region recorded. * **Clients.** On the region host the CLI reads the CA from `ca_dir`; elsewhere `ROLLCALL_CA=ca.pem`, or the console shows the CA fingerprint on first connect and keeps the CA in `~/.config/rollcall/client.json` once you diff --git a/cmd/rollcall/admincert.go b/cmd/rollcall/admincert.go new file mode 100644 index 0000000..d3576f4 --- /dev/null +++ b/cmd/rollcall/admincert.go @@ -0,0 +1,219 @@ +package main + +// Admin client certificates: an alternative to the admin token. A browser or +// CLI that presents a certificate from the installation's CA with the name +// "admin:", made for client authentication and with a serial the region +// recorded (and not revoked), is an admin. The key is made on the client; only +// a CSR travels. Issuing needs admin rights once (the token, or another admin +// certificate); revoking takes effect within the cache period (30 s). + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "log" + "net/http" + "regexp" + "strings" + "sync" + "time" +) + +const adminCertSchema = ` +CREATE TABLE IF NOT EXISTS admin_certs ( + serial text PRIMARY KEY, + name text NOT NULL, + expires timestamptz NOT NULL, + created timestamptz NOT NULL DEFAULT now(), + issued_by text, + revoked timestamptz +); +` + +type AdminCert struct { + Serial string `json:"serial"` + Name string `json:"name"` + Expires time.Time `json:"expires"` + Created time.Time `json:"created"` + IssuedBy string `json:"issued_by"` + Revoked *time.Time `json:"revoked,omitempty"` +} + +func (s *Store) AddAdminCert(ctx context.Context, c AdminCert) error { + _, err := s.pool.Exec(ctx, `INSERT INTO admin_certs(serial, name, expires, issued_by) VALUES ($1,$2,$3,$4)`, c.Serial, c.Name, c.Expires, c.IssuedBy) + return err +} + +func (s *Store) AdminCerts(ctx context.Context) ([]AdminCert, error) { + rows, err := s.pool.Query(ctx, `SELECT serial, name, expires, created, coalesce(issued_by,''), revoked FROM admin_certs ORDER BY created`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AdminCert{} + for rows.Next() { + var c AdminCert + if err := rows.Scan(&c.Serial, &c.Name, &c.Expires, &c.Created, &c.IssuedBy, &c.Revoked); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// RevokeAdminCerts revokes by serial or by name (all of that name); returns how many. +func (s *Store) RevokeAdminCerts(ctx context.Context, ref string) (int64, error) { + tag, err := s.pool.Exec(ctx, `UPDATE admin_certs SET revoked=now() WHERE revoked IS NULL AND (serial=$1 OR name=$1)`, strings.ToLower(ref)) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} + +func (s *Store) validAdminSerials(ctx context.Context) (map[string]string, error) { + rows, err := s.pool.Query(ctx, `SELECT serial, name FROM admin_certs WHERE revoked IS NULL AND expires > now()`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var serial, name string + if err := rows.Scan(&serial, &name); err != nil { + return nil, err + } + out[serial] = name + } + return out, rows.Err() +} + +type adminCertCache struct { + mu sync.Mutex + at time.Time + serial map[string]string +} + +func (a *App) adminCertsCached(ctx context.Context) map[string]string { + a.adminCC.mu.Lock() + defer a.adminCC.mu.Unlock() + if a.adminCC.serial != nil && time.Since(a.adminCC.at) < 30*time.Second || a.store == nil { + return a.adminCC.serial + } + m, err := a.store.validAdminSerials(ctx) + if err != nil { + log.Printf("admin certificates: %v", err) + return a.adminCC.serial // keep the last good view on a database hiccup + } + a.adminCC.serial, a.adminCC.at = m, time.Now() + return m +} + +func (a *App) dropAdminCertCache() { + a.adminCC.mu.Lock() + a.adminCC.serial = nil + a.adminCC.mu.Unlock() +} + +// peerAdmin is the admin name of a verified client certificate, "" otherwise. +func (a *App) peerAdmin(r *http.Request) string { + if r.TLS == nil || len(r.TLS.VerifiedChains) == 0 || len(r.TLS.PeerCertificates) == 0 { + return "" + } + c := r.TLS.PeerCertificates[0] + name, ok := strings.CutPrefix(c.Subject.CommonName, "admin:") + if !ok || name == "" { + return "" + } + clientAuth := false + for _, u := range c.ExtKeyUsage { + clientAuth = clientAuth || u == x509.ExtKeyUsageClientAuth + } + if !clientAuth { + return "" + } + if a.adminCertsCached(r.Context())[c.SerialNumber.Text(16)] != name { + return "" + } + return name +} + +// who describes the admin making a request, for the log. +func (a *App) who(r *http.Request) string { + if n := a.peerAdmin(r); n != "" { + return "admin:" + n + } + return "token from " + r.RemoteAddr +} + +var adminNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) + +func (a *App) handleAdminCerts(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + cs, err := a.store.AdminCerts(r.Context()) + if err != nil { + fail(w, 500, err) + return + } + writeJSON(w, 200, cs) + case http.MethodPost: + var req struct { + Name string `json:"name"` + CSR string `json:"csr"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + fail(w, 400, err) + return + } + req.Name = strings.ToLower(strings.TrimSpace(req.Name)) + if !adminNameRe.MatchString(req.Name) { + fail(w, 400, errors.New("name: lower-case letters, digits, . _ - (e.g. the laptop's or person's name)")) + return + } + blk, _ := pem.Decode([]byte(req.CSR)) + if blk == nil || blk.Type != "CERTIFICATE REQUEST" { + fail(w, 400, errors.New("csr: a PEM CERTIFICATE REQUEST is required (the key stays on the client)")) + return + } + csr, err := x509.ParseCertificateRequest(blk.Bytes) + if err == nil { + err = csr.CheckSignature() + } + if err != nil { + fail(w, 400, fmt.Errorf("csr: %w", err)) + return + } + certPEM, cert, err := a.ca.sign(csr.PublicKey, "admin:"+req.Name, nil, x509.ExtKeyUsageClientAuth) + if err != nil { + fail(w, 500, err) + return + } + by := a.who(r) + if err := a.store.AddAdminCert(r.Context(), AdminCert{Serial: cert.SerialNumber.Text(16), Name: req.Name, Expires: cert.NotAfter, IssuedBy: by}); err != nil { + fail(w, 500, err) + return + } + a.dropAdminCertCache() + log.Printf("admin certificate %q (serial %s, until %s) issued by %s", req.Name, cert.SerialNumber.Text(16), cert.NotAfter.Format("2006-01-02"), by) + writeJSON(w, 200, obj{"name": req.Name, "serial": cert.SerialNumber.Text(16), "expires": cert.NotAfter, "cert": string(certPEM), "ca": string(a.ca.PEM)}) + } +} + +func (a *App) handleAdminCertRevoke(w http.ResponseWriter, r *http.Request) { + ref := r.PathValue("ref") + n, err := a.store.RevokeAdminCerts(r.Context(), ref) + if err != nil { + fail(w, 500, err) + return + } + if n == 0 { + fail(w, 404, fmt.Errorf("no valid admin certificate %q", ref)) + return + } + a.dropAdminCertCache() + log.Printf("admin certificate %q revoked (%d) by %s", ref, n, a.who(r)) + writeJSON(w, 200, obj{"revoked": n}) +} diff --git a/cmd/rollcall/admincert_client.go b/cmd/rollcall/admincert_client.go new file mode 100644 index 0000000..4a02a04 --- /dev/null +++ b/cmd/rollcall/admincert_client.go @@ -0,0 +1,180 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + pkcs12 "software.sslmate.com/src/go-pkcs12" +) + +// clientCertificate: the admin client certificate from ROLLCALL_CERT/ROLLCALL_KEY +// or client.json, nil when there is none. +func clientCertificate() *tls.Certificate { + certFile, keyFile := os.Getenv("ROLLCALL_CERT"), os.Getenv("ROLLCALL_KEY") + if certFile == "" { + if cc, ok := loadClientConf(); ok { + certFile, keyFile = cc.Cert, cc.Key + } + } + if certFile == "" || keyFile == "" { + return nil + } + pair, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: admin certificate %s: %v\n", certFile, err) + return nil + } + return &pair +} + +// rollcall admin-cert request NAME | list | revoke NAME|SERIAL +func (c *client) adminCertCmd(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "", "list": + var cs []AdminCert + if err := c.do("GET", "/api/v1/admin-certs", nil, &cs); err != nil { + return err + } + fmt.Printf("%-20s %-34s %-11s %-11s %s\n", "NAME", "SERIAL", "EXPIRES", "STATE", "ISSUED BY") + for _, x := range cs { + st := "valid" + if x.Revoked != nil { + st = "revoked" + } else if time.Now().After(x.Expires) { + st = "expired" + } + fmt.Printf("%-20s %-34s %-11s %-11s %s\n", x.Name, x.Serial, x.Expires.Format("2006-01-02"), st, x.IssuedBy) + } + return nil + case "revoke": + if len(args) < 2 { + usage() + } + var res struct { + Revoked int `json:"revoked"` + } + if err := c.do("DELETE", "/api/v1/admin-certs/"+args[1], nil, &res); err != nil { + return err + } + fmt.Printf("revoked %d certificate(s) for %s\n", res.Revoked, args[1]) + return nil + case "request": + if len(args) < 2 { + usage() + } + return c.requestAdminCert(strings.ToLower(args[1])) + default: + usage() + } + return nil +} + +func (c *client) requestAdminCert(name string) error { + if !strings.HasPrefix(c.base, "https://") { + return errors.New("request the certificate over HTTPS (ROLLCALL_URL=https://..., or connect the console to https://)") + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{Subject: pkix.Name{CommonName: "admin:" + name}}, key) + if err != nil { + return err + } + var res struct { + Serial string `json:"serial"` + Expires time.Time `json:"expires"` + Cert string `json:"cert"` + CA string `json:"ca"` + } + if err := c.do("POST", "/api/v1/admin-certs", obj{"name": name, "csr": string(pemEncode("CERTIFICATE REQUEST", der))}, &res); err != nil { + return err + } + dir := filepath.Dir(clientConfPath()) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + certFile := filepath.Join(dir, "admin-"+name+".pem") + keyFile := filepath.Join(dir, "admin-"+name+"-key.pem") + p12File := filepath.Join(dir, "admin-"+name+".p12") + caFile := filepath.Join(dir, "rollcall-ca.pem") + kder, _ := x509.MarshalECPrivateKey(key) + if err := os.WriteFile(keyFile, pemEncode("EC PRIVATE KEY", kder), 0o600); err != nil { + return err + } + if err := os.WriteFile(certFile, []byte(res.Cert), 0o600); err != nil { + return err + } + if err := os.WriteFile(caFile, []byte(res.CA), 0o644); err != nil { + return err + } + // a PKCS#12 bundle for the browser / macOS keychain (3DES: every keychain imports it) + blk, _ := pem.Decode([]byte(res.Cert)) + leaf, err := x509.ParseCertificate(blk.Bytes) + if err != nil { + return err + } + cab, _ := pem.Decode([]byte(res.CA)) + ca, err := x509.ParseCertificate(cab.Bytes) + if err != nil { + return err + } + pass := randomToken(9) + p12, err := pkcs12.LegacyDES.Encode(key, leaf, []*x509.Certificate{ca}, pass) + if err != nil { + return err + } + if err := os.WriteFile(p12File, p12, 0o600); err != nil { + return err + } + // the CLI and console use it from now on + cc, _ := loadClientConf() + if cc.URL == "" { + cc.URL = c.base + } + if cc.CA == "" { + cc.CA = res.CA + } + cc.Cert, cc.Key = certFile, keyFile + if err := saveClientConf(cc); err != nil { + return err + } + // prove it: a request with the certificate alone + pair, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return err + } + probe := &client{base: c.base, http: newHTTPClient(res.CA), ca: res.CA} + probe.http.Transport.(*httpTransport).TLSClientConfig.Certificates = []tls.Certificate{pair} + verdict := "works without the token" + if err := probe.do("GET", "/api/v1/admin-certs", nil, nil); err != nil { + verdict = "NOT accepted yet: " + err.Error() + } + fmt.Printf(`admin certificate "admin:%s" serial %s, valid until %s: %s + %s + %s (key, stays here) + %s (for the browser, password: %s) + %s (the installation's CA) +client.json now uses the certificate; the token can be removed from it. +macOS browser: + security import %s -k ~/Library/Keychains/login.keychain-db -P %s + security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db %s +then open %s and pick "admin:%s" when the browser asks for a certificate. +`, name, res.Serial, res.Expires.Format("2006-01-02"), verdict, certFile, keyFile, p12File, pass, caFile, p12File, pass, caFile, c.base, name) + return nil +} diff --git a/cmd/rollcall/ca_test.go b/cmd/rollcall/ca_test.go index 44f12b5..375486b 100644 --- a/cmd/rollcall/ca_test.go +++ b/cmd/rollcall/ca_test.go @@ -195,3 +195,42 @@ func TestUIRedirectsToHTTPS(t *testing.T) { t.Errorf("no TLS listener but redirected: %d", c) } } + +func TestAdminClientCertificate(t *testing.T) { + ca, _ := newCA("test") + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + _, admin, _ := ca.sign(&key.PublicKey, "admin:t440s", nil, x509.ExtKeyUsageClientAuth) + _, server, _ := ca.sign(&key.PublicKey, "admin:t440s", []string{"x.example"}, x509.ExtKeyUsageServerAuth) + _, rack, _ := ca.sign(&key.PublicKey, "rack:r2", nil, x509.ExtKeyUsageClientAuth) + a := &App{cfg: &Config{AdminToken: "secret"}} + a.adminCC.serial, a.adminCC.at = map[string]string{admin.SerialNumber.Text(16): "t440s", server.SerialNumber.Text(16): "t440s"}, time.Now() + req := func(c *x509.Certificate, verified bool) *http.Request { + r := httptest.NewRequest("GET", "/api/v1/machines", nil) + r.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{c}} + if verified { + r.TLS.VerifiedChains = [][]*x509.Certificate{{c, ca.Cert}} + } + return r + } + if !a.isAdmin(req(admin, true)) { + t.Error("registered admin certificate refused") + } + if a.isAdmin(req(admin, false)) { + t.Error("unverified certificate accepted") + } + if a.isAdmin(req(server, true)) { + t.Error("server certificate accepted as admin") + } + if a.isAdmin(req(rack, true)) { + t.Error("rack certificate accepted as admin") + } + a.adminCC.serial = map[string]string{} // revoked + if a.isAdmin(req(admin, true)) { + t.Error("revoked certificate accepted") + } + tok := httptest.NewRequest("GET", "/", nil) + tok.Header.Set("Authorization", "Bearer secret") + if !a.isAdmin(tok) { + t.Error("token no longer works") + } +} diff --git a/cmd/rollcall/client.go b/cmd/rollcall/client.go index d177de8..c323f90 100644 --- a/cmd/rollcall/client.go +++ b/cmd/rollcall/client.go @@ -39,6 +39,9 @@ func clientTLS(caPEM string) *tls.Config { } } tc := &tls.Config{MinVersion: tls.VersionTLS12} + if cert := clientCertificate(); cert != nil { + tc.Certificates = []tls.Certificate{*cert} + } if len(pem) > 0 { pool, err := x509.SystemCertPool() if err != nil || pool == nil { @@ -50,6 +53,8 @@ func clientTLS(caPEM string) *tls.Config { return tc } +type httpTransport = http.Transport + func newHTTPClient(caPEM string) *http.Client { return &http.Client{Timeout: 90 * time.Second, Transport: &http.Transport{TLSClientConfig: clientTLS(caPEM), Proxy: http.ProxyFromEnvironment}} } @@ -78,8 +83,8 @@ func newClient() (*client, error) { caPEM = cc.CA } } - if base == "" || token == "" { - return nil, fmt.Errorf("set ROLLCALL_URL and ROLLCALL_TOKEN, make the server config readable, or run rollcall in a terminal to connect") + if base == "" || (token == "" && clientCertificate() == nil) { + return nil, fmt.Errorf("set ROLLCALL_URL and ROLLCALL_TOKEN (or an admin certificate: rollcall admin-cert request), make the server config readable, or run rollcall in a terminal to connect") } if caPEM == "" { if cc, ok := loadClientConf(); ok && strings.TrimRight(cc.URL, "/") == strings.TrimRight(base, "/") { @@ -99,7 +104,9 @@ func (c *client) do(method, path string, body any, out any) error { if err != nil { return err } - req.Header.Set("Authorization", "Bearer "+c.token) + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { @@ -271,6 +278,8 @@ func runClient(cmd string, args []string) error { return c.bmcCmd(args) case "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam": return c.netCmd(cmd, args) + case "admin-cert": + return c.adminCertCmd(args) default: return c.statelessCmd(cmd, args) } @@ -706,7 +715,9 @@ func (c *client) rawBody(method, path, ctype string, body []byte) ([]byte, error if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+c.token) + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } req.Header.Set("Content-Type", ctype) resp, err := c.http.Do(req) if err != nil { @@ -725,7 +736,9 @@ func (c *client) raw(method, path string) ([]byte, error) { if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+c.token) + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } resp, err := c.http.Do(req) if err != nil { return nil, err diff --git a/cmd/rollcall/go.mod b/cmd/rollcall/go.mod index 7a9253a..916c913 100644 --- a/cmd/rollcall/go.mod +++ b/cmd/rollcall/go.mod @@ -10,6 +10,7 @@ require ( github.com/klauspost/compress v1.18.0 github.com/mattn/go-isatty v0.0.24 golang.org/x/crypto v0.37.0 + software.sslmate.com/src/go-pkcs12 v0.7.4 ) require ( diff --git a/cmd/rollcall/go.sum b/cmd/rollcall/go.sum index cb36d11..47a89bb 100644 --- a/cmd/rollcall/go.sum +++ b/cmd/rollcall/go.sum @@ -75,3 +75,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +software.sslmate.com/src/go-pkcs12 v0.7.4 h1:OvJ7g/OB9RuVAOzahlFLns6HeIcP+SJotsDqmLfhd8w= +software.sslmate.com/src/go-pkcs12 v0.7.4/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/cmd/rollcall/main.go b/cmd/rollcall/main.go index fa6a897..9a16454 100644 --- a/cmd/rollcall/main.go +++ b/cmd/rollcall/main.go @@ -62,6 +62,7 @@ func usage() { rollcall subnet list | show CIDR | add CIDR -fabric F [-vid N] [-purpose P] [-gateway G] [-dns a,b] [-domain D] | rm CIDR [-force] rollcall range add CIDR dynamic|reserved|static START END | rm ID rollcall ip list [-subnet CIDR] | reserve IP [-name N] | release IP + rollcall admin-cert request NAME | list | revoke NAME|SERIAL admin client certificate (browser/CLI without the token) rollcall ipam [status] | sync [-dry] | prefixes | import external IPAM (NetBox): state, reconcile, delegated prefixes rollcall layout list | show NAME | add NAME FILE.json | rm NAME rollcall net show ID | set ID [-layout L] [-rack R] [-static IFACE=IP] [-unstatic IP] @@ -125,7 +126,7 @@ func main() { case "list", "show", "events", "power", "bootdev", "recommission", "delete", "images", "profile", "assign", "unassign", "overlay", "render", "deploy", "release", "jobs", "job", "tag", "tags", "location", - "bmc", "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam": + "bmc", "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam", "admin-cert": if err := runClient(cmd, args); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) diff --git a/cmd/rollcall/server.go b/cmd/rollcall/server.go index f482120..6b88250 100644 --- a/cmd/rollcall/server.go +++ b/cmd/rollcall/server.go @@ -37,6 +37,8 @@ type App struct { ipam ipamProvider // nil = built-in IPAM only ipamSt *ipamState + + adminCC adminCertCache } type apiError struct { @@ -64,7 +66,10 @@ func tokenEqual(a, b string) bool { func (a *App) isAdmin(r *http.Request) bool { h := r.Header.Get("Authorization") - return strings.HasPrefix(h, "Bearer ") && tokenEqual(strings.TrimPrefix(h, "Bearer "), a.cfg.AdminToken) + if strings.HasPrefix(h, "Bearer ") && tokenEqual(strings.TrimPrefix(h, "Bearer "), a.cfg.AdminToken) { + return true + } + return a.peerAdmin(r) != "" // an admin client certificate (admincert.go) } // adminOverHTTP applies admin_http to requests that did not come over TLS. @@ -228,6 +233,9 @@ func (a *App) routes() http.Handler { mux.HandleFunc("POST /api/v1/ips", a.adminAuth(a.handleIPs)) mux.HandleFunc("DELETE /api/v1/ips/{ip}", a.adminAuth(a.handleIPDelete)) mux.HandleFunc("GET /api/v1/ipam", a.adminAuth(a.handleIPAM)) + mux.HandleFunc("GET /api/v1/admin-certs", a.adminAuth(a.handleAdminCerts)) + mux.HandleFunc("POST /api/v1/admin-certs", a.adminAuth(a.handleAdminCerts)) + mux.HandleFunc("DELETE /api/v1/admin-certs/{ref}", a.adminAuth(a.handleAdminCertRevoke)) mux.HandleFunc("POST /api/v1/ipam/sync", a.adminAuth(a.handleIPAMSync)) mux.HandleFunc("GET /api/v1/ipam/prefixes", a.adminAuth(a.handleIPAMPrefixes)) mux.HandleFunc("POST /api/v1/ipam/prefixes", a.adminAuth(a.handleIPAMPrefixes)) diff --git a/cmd/rollcall/store.go b/cmd/rollcall/store.go index 8515d89..cde694a 100644 --- a/cmd/rollcall/store.go +++ b/cmd/rollcall/store.go @@ -232,6 +232,9 @@ func openStore(ctx context.Context, url string) (*Store, error) { if _, err := pool.Exec(ctx, schema); err != nil { return nil, fmt.Errorf("schema: %w", err) } + if _, err := pool.Exec(ctx, adminCertSchema); err != nil { + return nil, fmt.Errorf("schema: %w", err) + } return &Store{pool: pool}, nil } diff --git a/cmd/rollcall/tui.go b/cmd/rollcall/tui.go index 4d1c984..60bf17c 100644 --- a/cmd/rollcall/tui.go +++ b/cmd/rollcall/tui.go @@ -37,7 +37,9 @@ func interactive() bool { type clientConf struct { URL string `json:"url"` Token string `json:"token"` - CA string `json:"ca,omitempty"` // PEM of the region's CA, trusted when it was first seen + CA string `json:"ca,omitempty"` // PEM of the region's CA, trusted when it was first seen + Cert string `json:"cert,omitempty"` // admin client certificate (rollcall admin-cert request) and its key + Key string `json:"key,omitempty"` } func clientConfPath() string { @@ -52,7 +54,7 @@ func clientConfPath() string { func loadClientConf() (clientConf, bool) { var c clientConf b, err := os.ReadFile(clientConfPath()) - if err != nil || json.Unmarshal(b, &c) != nil || c.URL == "" || c.Token == "" { + if err != nil || json.Unmarshal(b, &c) != nil || c.URL == "" || (c.Token == "" && c.Cert == "") { return c, false } return c, true diff --git a/cmd/rollcall/web/app.js b/cmd/rollcall/web/app.js index 4571c15..8a71b5f 100644 --- a/cmd/rollcall/web/app.js +++ b/cmd/rollcall/web/app.js @@ -19,7 +19,9 @@ const locStr = l => { if (!l) return ''; const p = [l.building, l.room, l.rack]. let timer = null; async function api(method, path, body) { - const r = await fetch(path, { method, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); + const headers = { 'Content-Type': 'application/json' }; + if (token) headers.Authorization = 'Bearer ' + token; // without one, an admin client certificate may do + const r = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined }); if (r.status === 401) { askToken(); throw new Error('unauthorized'); } const data = await r.json().catch(() => ({})); if (!r.ok) throw new Error(data.error || r.statusText); @@ -65,7 +67,6 @@ function dialog(title, fields, note, okLabel = 'OK') { // ---------------------------------------------------------------- data async function refresh(full) { - if (!token) return askToken(); try { const [machines, tags, profiles, os, racks, subnets, controllers, layouts, fabrics] = await Promise.all([api('GET', '/api/v1/machines'), api('GET', '/api/v1/tags'), api('GET', '/api/v1/profiles'), api('GET', '/api/v1/os'), api('GET', '/api/v1/racks'), api('GET', '/api/v1/subnets'), api('GET', '/api/v1/controllers'), api('GET', '/api/v1/layouts'), api('GET', '/api/v1/fabrics')]); diff --git a/cmd/rollcall/web/index.html b/cmd/rollcall/web/index.html index 22ebb35..f032589 100644 --- a/cmd/rollcall/web/index.html +++ b/cmd/rollcall/web/index.html @@ -30,7 +30,7 @@

Admin token

-

The admin_token from the server's config.json. Stored only in this browser.

+

The admin_token from the server's config.json. Stored only in this browser. Or use an admin client certificate (rollcall admin-cert request NAME) and the browser signs you in by itself.