Admin client certificates as an alternative to the admin token
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:
1 parent
28a0c2952f
commit
53c05bb44d
14 files changed
+492
-12
No files matched your search
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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')]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user