Files
rollcall/cmd/rollcall/admincert_client.go
T
Ilker ManapandClaude Opus 5.5 ada6566534
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 33s
admin-cert request: refresh the stored CA for the same region (it may have been rotated)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:48:40 +01:00

181 lines
5.3 KiB
Go

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 == "" || strings.TrimRight(cc.URL, "/") == strings.TrimRight(c.base, "/") {
cc.CA = res.CA // the region's current CA (it may have been rotated since)
}
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
}