Admin client certificates as an alternative to the admin token
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 1m30s

rollcall admin-cert request NAME makes a key locally and sends a CSR; the
region signs admin:NAME (client auth) and records the serial; a verified,
recorded, unrevoked certificate is an admin. CLI/console read it from
client.json, a .p12 is written for the browser; the web UI no longer insists
on a token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-28 17:44:06 +01:00
1 parent 28a0c2952f
commit 53c05bb44d
14 files changed
+492 -12

No files matched your search

+3
View File
@@ -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`.
+8
View File
@@ -430,6 +430,14 @@ plane is: `ca_dir` (default `/etc/rollcall/ca`) holds `ca.pem` and
another architecture get `rack_binaries/rollcall-linux-<arch>`;
`"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
+219
View File
@@ -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:<name>", 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})
}
+180
View File
@@ -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
}
+39
View File
@@ -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")
}
}
+18 -5
View File
@@ -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
+1
View File
@@ -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 (
+2
View File
@@ -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=
+2 -1
View File
@@ -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)
+9 -1
View File
@@ -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))
+3
View File
@@ -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
}
+4 -2
View File
@@ -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
+3 -2
View File
@@ -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')]);
+1 -1
View File
@@ -30,7 +30,7 @@
<dialog id="login">
<form method="dialog">
<h2>Admin token</h2>
<p class="muted">The <code>admin_token</code> from the server's <code>config.json</code>. Stored only in this browser.</p>
<p class="muted">The <code>admin_token</code> from the server's <code>config.json</code>. Stored only in this browser. Or use an admin client certificate (<code>rollcall admin-cert request NAME</code>) and the browser signs you in by itself.</p>
<p id="http-warn" class="warn" hidden>This page came over plain HTTP: the token crosses the network unencrypted. Use <a id="https-link" href="#">HTTPS</a> after trusting the installation's <a href="/ca.pem">CA certificate</a> <span id="ca-fp" class="mono muted"></span>.</p>
<input id="token" type="password" autocomplete="off" placeholder="token" required>
<menu><button class="btn primary" value="ok">Sign in</button></menu>