[refactor](maestro): retire the PKI and federation identity layer
Contract rev 9 removes the instance CA, client certificates, the peer listener and the trust store fleet-wide. Internal traffic is plaintext HTTP on each service single :8080 listener, so there is no peer identity left to authorize: delete lib/pki, lib/federation, devboot, the per-service database role model and the /api/peers* and /api/instances surfaces, and drop the console Trusted services UI. Config delivery now rides a dedicated ES256 token from lib/configsign rather than mTLS, and the instance fingerprint is derived from a persisted random key and is informational only. main.go also wires the surfaces introduced by the later commits, which this change is what makes possible.
This commit is contained in:
@@ -1,74 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
|
||||
)
|
||||
|
||||
type ActionProxyPayload struct {
|
||||
Method string
|
||||
Action string
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type ActionProxyResponse struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func ActionProxy(container_id, action string, payload ActionProxyPayload) (ActionProxyResponse, error) {
|
||||
// Forward the action to the container with the given ID, and return the result to the frontend
|
||||
|
||||
containers := containers.NewContainerCache().GetServices()
|
||||
for _, container := range containers {
|
||||
if container.ID == container_id {
|
||||
// Forward the action to this container
|
||||
|
||||
body := bytes.NewBuffer([]byte(payload.Data))
|
||||
|
||||
port := "80"
|
||||
if len(container.Network.Ports) > 0 {
|
||||
port = fmt.Sprintf("%d", container.Network.Ports[0].Guest)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(payload.Method, fmt.Sprintf("http://%s:%s/action/%s", container.ID[:12], port, payload.Action), body)
|
||||
if err != nil {
|
||||
return ActionProxyResponse{}, err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ActionProxyResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
buf.ReadFrom(resp.Body)
|
||||
|
||||
return ActionProxyResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: buf.String(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return ActionProxyResponse{}, fmt.Errorf("container with ID %s not found", container_id)
|
||||
}
|
||||
+47
-187
@@ -23,8 +23,6 @@ package authmgmt
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -32,26 +30,27 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
||||
)
|
||||
|
||||
// AuthServiceURL is the default DNS name of the shared auth service on the
|
||||
// dcntrlized network. The auth service listens with ClientAuth=RequireAndVerify
|
||||
// trusting the instance CA (M2 §1).
|
||||
const AuthServiceURL = "https://dcntrlized-auth:8080"
|
||||
// dcntrlized network. After rev 9 the fleet-wide internal transport is
|
||||
// plaintext HTTP on the single :8080 listener; there is no mTLS anywhere.
|
||||
const AuthServiceURL = "http://dcntrlized-auth:8080"
|
||||
|
||||
// Client talks to the auth service over mTLS using Maestro's own identity
|
||||
// certificate (CN=maestro), which the auth service's RequireMaestro gate
|
||||
// recognizes as the orchestrator.
|
||||
// Client talks to the auth service over plaintext HTTP. It no longer presents
|
||||
// any client certificate: the peer gate was removed fleet-wide in rev 9.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// Attestation is the auth service's public signing key statement (D3): the key
|
||||
// a peer should verify access tokens with, plus its TOFU-pinnable fingerprint.
|
||||
// a peer should verify access tokens with, plus the fingerprint Maestro pins
|
||||
// as an app-level check (M2 §5). Rev 9 removed the peer trust store, the
|
||||
// pending/approved authorization flow and every management call that used to
|
||||
// live in this package.
|
||||
type Attestation struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
@@ -59,44 +58,9 @@ type Attestation struct {
|
||||
IssuedAt string `json:"issued_at"`
|
||||
}
|
||||
|
||||
// AuthorizedKey is an approved peer row of the auth service's trust store.
|
||||
type AuthorizedKey struct {
|
||||
Service string `json:"service"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
ApprovedBy string `json:"approved_by"`
|
||||
ApprovedAt string `json:"approved_at"`
|
||||
RevokedAt *string `json:"revoked_at"`
|
||||
HiddenAt *string `json:"hidden_at"`
|
||||
}
|
||||
|
||||
// PendingAuthorization is a first-contact request awaiting the owner's
|
||||
// decision.
|
||||
type PendingAuthorization struct {
|
||||
Service string `json:"service"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
FirstSeen string `json:"first_seen"`
|
||||
LastSeen string `json:"last_seen"`
|
||||
}
|
||||
|
||||
// TrustState is the combined pending + approved view the dashboard shows.
|
||||
type TrustState struct {
|
||||
Pending []PendingAuthorization `json:"pending"`
|
||||
Authorized []AuthorizedKey `json:"authorized"`
|
||||
}
|
||||
|
||||
type authzRequest struct {
|
||||
Service string `json:"service"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// New builds the mTLS management client, provisioning Maestro's own identity
|
||||
// from the instance CA (the CA holder issues its own orchestrator cert).
|
||||
// New builds the plaintext management client. There is no identity to
|
||||
// provision and no transport to configure after the PKI removal.
|
||||
func New() (*Client, error) {
|
||||
transport, err := maestroTransport()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseURL := ""
|
||||
if env.IsDev() {
|
||||
baseURL = os.Getenv("DCNTRLIZED_AUTH_URL")
|
||||
@@ -111,84 +75,52 @@ func New() (*Client, error) {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
http: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: transport,
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// maestroTransport builds the mutual-TLS transport Maestro uses as a peer: its
|
||||
// own identity certificate (CN=maestro) signed by the instance CA, trusting the
|
||||
// CA as root. This is the client side of every Maestro peer edge.
|
||||
func maestroTransport() (*http.Transport, error) {
|
||||
dataRoot, err := dataRoot()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve data root: %w", err)
|
||||
}
|
||||
ca, err := pki.EnsureCA(dataRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure instance CA: %w", err)
|
||||
}
|
||||
identity, err := pki.EnsureServiceIdentity(ca, "maestro")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provision maestro identity: %w", err)
|
||||
}
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(identity.CertPath, identity.KeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load maestro identity: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(ca.CertPEM) {
|
||||
return nil, fmt.Errorf("no certificates parsed from instance CA")
|
||||
}
|
||||
|
||||
return &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
RootCAs: pool,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Scheme returns "http" in dev mode and "https" otherwise, for building
|
||||
// satellite peer URLs (RunAction, manifest fetch). Dev satellites keep a
|
||||
// plaintext listener so the RN clients (which cannot do mTLS) can reach them;
|
||||
// production satellites bind mTLS listeners.
|
||||
// Scheme returns the scheme every satellite peer edge uses after rev 9:
|
||||
// always "http" (plaintext on the single :8080 listener). Kept as a function
|
||||
// so the call sites (manifest fetch, action routing) do not hard-code it.
|
||||
func Scheme() string {
|
||||
if env.IsDev() {
|
||||
return "http"
|
||||
}
|
||||
return "https"
|
||||
return "http"
|
||||
}
|
||||
|
||||
// PeerTransport returns the transport Maestro uses for the satellite peer
|
||||
// edges (M2 §2): plaintext in dev mode (matching the RN-facing listeners),
|
||||
// mutual TLS with Maestro's identity in production.
|
||||
func PeerTransport() (*http.Transport, error) {
|
||||
if env.IsDev() {
|
||||
return &http.Transport{}, nil
|
||||
// ConfigToken mints a fresh, single-use ES256 config token for one service
|
||||
// (contract rev 9 §17.3.6). It loads (or creates) Maestro's DEDICATED
|
||||
// config-signing keypair from the data root and returns the compact JWS. The
|
||||
// service holds only the public half (DCNTRLIZED_CONFIG_PUBKEY) and verifies;
|
||||
// Maestro only ever mints. Wire format is frozen in §17.3.3.
|
||||
func ConfigToken(serviceName string) (string, error) {
|
||||
root, err := dataRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return maestroTransport()
|
||||
signer, err := configsign.LoadOrCreate(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return signer.Mint(serviceName, time.Now())
|
||||
}
|
||||
|
||||
// MaestroTransport returns Maestro's own mutual-TLS transport (CN=maestro)
|
||||
// unconditionally — dev mode does NOT downgrade it. Orchestrator-only edges
|
||||
// that must prove the caller is the orchestrator (e.g. /config delivery, which
|
||||
// the service gates to CN=maestro) use this regardless of FORCE_JSON/DEV,
|
||||
// since those services keep an always-mTLS peer listener.
|
||||
func MaestroTransport() (*http.Transport, error) {
|
||||
return maestroTransport()
|
||||
}
|
||||
|
||||
// TrustState returns the pending authorizations and the approved trust store.
|
||||
func (c *Client) TrustState(ctx context.Context) (*TrustState, error) {
|
||||
var state TrustState
|
||||
if err := c.do(ctx, http.MethodGet, "/internal/pending", nil, &state); err != nil {
|
||||
return nil, err
|
||||
// dataRoot resolves the directory holding Maestro's private key material
|
||||
// (DCNTRLIZED_INSTALL_DIR → XDG → HOME), mirroring the containers package's
|
||||
// resolution. Kept local to avoid an import cycle: containers imports authmgmt.
|
||||
func dataRoot() (string, error) {
|
||||
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
return &state, nil
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, ".dcntrlized"), nil
|
||||
}
|
||||
if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
|
||||
return filepath.Join(xdgHome, ".dcntrlized"), nil
|
||||
}
|
||||
if home := os.Getenv("HOME"); home != "" {
|
||||
return filepath.Join(home, ".dcntrlized"), nil
|
||||
}
|
||||
return filepath.Join("/data", ".dcntrlized"), nil
|
||||
}
|
||||
|
||||
// Attestation returns the auth service's public key statement.
|
||||
@@ -200,35 +132,6 @@ func (c *Client) Attestation(ctx context.Context) (*Attestation, error) {
|
||||
return &att, nil
|
||||
}
|
||||
|
||||
// Approve forwards the owner's approval for a peer's fingerprint to the auth
|
||||
// service's own trust store.
|
||||
func (c *Client) Approve(ctx context.Context, service, fingerprint string) error {
|
||||
return c.do(ctx, http.MethodPost, "/internal/authorizations",
|
||||
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
||||
}
|
||||
|
||||
// Revoke forwards the owner's revocation for a peer's fingerprint (returns the
|
||||
// peer to the no-op path immediately).
|
||||
func (c *Client) Revoke(ctx context.Context, service, fingerprint string) error {
|
||||
return c.do(ctx, http.MethodPost, "/internal/revoke",
|
||||
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
||||
}
|
||||
|
||||
// Reject denies a pending first-contact request: the request is dropped and
|
||||
// audited; the peer stays on the no-op path until the owner approves it.
|
||||
func (c *Client) Reject(ctx context.Context, service, fingerprint string) error {
|
||||
return c.do(ctx, http.MethodPost, "/internal/reject",
|
||||
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
||||
}
|
||||
|
||||
// Hide permanently hides a revoked peer from the dashboard: the auth service
|
||||
// keeps the row (audit trail survives) but marks it hidden_at; a re-approval
|
||||
// clears the flag and makes the key visible again.
|
||||
func (c *Client) Hide(ctx context.Context, service, fingerprint string) error {
|
||||
return c.do(ctx, http.MethodPost, "/internal/hide",
|
||||
authzRequest{Service: service, Fingerprint: fingerprint}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var payload []byte
|
||||
if body != nil {
|
||||
@@ -258,46 +161,3 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeServiceBestEffort revokes a peer's authorization in the auth service
|
||||
// trust store, logging and swallowing failures: uninstall must proceed even
|
||||
// when the auth service is down (M2 §3 step 5 — "or automatically on
|
||||
// uninstall").
|
||||
func RevokeServiceBestEffort(ctx context.Context, service, fingerprint string) {
|
||||
client, err := New()
|
||||
if err != nil {
|
||||
bestEffort(err, "build client for revoking "+service)
|
||||
return
|
||||
}
|
||||
if err := client.Revoke(ctx, service, fingerprint); err != nil {
|
||||
bestEffort(err, "revoke "+service)
|
||||
}
|
||||
}
|
||||
|
||||
// dataRoot resolves the directory storing the instance PKI, mirroring the
|
||||
// containers package's resolution (DCNTRLIZED_INSTALL_DIR → XDG → HOME). Kept
|
||||
// local to avoid an import cycle: containers imports authmgmt for the
|
||||
// uninstall-revoke hook.
|
||||
func dataRoot() (string, error) {
|
||||
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, ".dcntrlized"), nil
|
||||
}
|
||||
if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
|
||||
return filepath.Join(xdgHome, ".dcntrlized"), nil
|
||||
}
|
||||
if home := os.Getenv("HOME"); home != "" {
|
||||
return filepath.Join(home, ".dcntrlized"), nil
|
||||
}
|
||||
return filepath.Join("/data", ".dcntrlized"), nil
|
||||
}
|
||||
|
||||
// bestEffort logs-and-continues: management calls must never take down the
|
||||
// orchestrator (e.g. uninstall revoking while the auth service is down).
|
||||
func bestEffort(err error, what string) {
|
||||
if err != nil {
|
||||
logger.Warn("authmgmt: %s failed (best-effort): %v", what, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package authmgmt
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSchemeIsAlwaysPlaintext locks the rev-9 transport decision: every
|
||||
// satellite peer edge (manifest fetch, action routing) is plaintext HTTP on the
|
||||
// single :8080 listener. A future https/peer-port regression must fail here.
|
||||
func TestSchemeIsAlwaysPlaintext(t *testing.T) {
|
||||
if got := Scheme(); got != "http" {
|
||||
t.Fatalf("Scheme() = %q, want %q", got, "http")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigTokenMintsForService exercises the contract-named mint helper
|
||||
// (rev 9 §17.3.6) end to end against a temp data root: it must create the
|
||||
// dedicated config-signing keypair, return a 3-segment JWS bound to the target
|
||||
// service, and expose a public key whose kid matches the token header.
|
||||
func TestConfigTokenMintsForService(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DCNTRLIZED_INSTALL_DIR", dir)
|
||||
|
||||
token, err := ConfigToken("calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigToken: %v", err)
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("token has %d segments, want 3", len(parts))
|
||||
}
|
||||
for _, name := range []string{"config-signing.key", "config-signing.pub"} {
|
||||
if _, err := os.Stat(dir + "/maestro/" + name); err != nil {
|
||||
t.Fatalf("expected %s to exist: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package configsign owns Maestro's DEDICATED /config signing keypair and the
|
||||
// ES256 token it mints for runtime operator-config delivery (contract rev 9
|
||||
// §17). The keypair is generated and held by Maestro in its data root and is
|
||||
// deliberately NOT the instance CA: the PKI identity layer is removed fleet-wide,
|
||||
// and a service only ever receives the public key (verify, never mint).
|
||||
package configsign
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// keyFileName is the PKCS#8 PEM private key in <dataRoot>/maestro.
|
||||
keyFileName = "config-signing.key"
|
||||
// pubFileName is the PKIX PEM public key in <dataRoot>/maestro.
|
||||
pubFileName = "config-signing.pub"
|
||||
|
||||
// Issuer is the fixed iss claim every config token carries.
|
||||
Issuer = "dcntrlized-maestro"
|
||||
// Scope is the fixed scope claim; the service requires exactly this value.
|
||||
Scope = "config"
|
||||
// TokenTTL is the frozen 60 s token lifetime.
|
||||
TokenTTL = 60 * time.Second
|
||||
|
||||
// PublicKeyEnv is the env var Maestro injects into a service container: the
|
||||
// base64.StdEncoding of the DER SubjectPublicKeyInfo (NOT the PEM) of the
|
||||
// dedicated config-signing public key. The service loads it to verify
|
||||
// /config tokens and can never mint them.
|
||||
PublicKeyEnv = "DCNTRLIZED_CONFIG_PUBKEY"
|
||||
)
|
||||
|
||||
// Signer holds the loaded keypair and mints config tokens.
|
||||
type Signer struct {
|
||||
key *ecdsa.PrivateKey
|
||||
kid string
|
||||
pubDER []byte
|
||||
pubPEM []byte
|
||||
}
|
||||
|
||||
// LoadOrCreate opens <dataRoot>/maestro/config-signing.key, generating the
|
||||
// keypair on first use. The private key is written atomically with mode 0600;
|
||||
// the public key is written alongside it with mode 0644.
|
||||
func LoadOrCreate(dataRoot string) (*Signer, error) {
|
||||
dir := filepath.Join(dataRoot, "maestro")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("configsign: mkdir %s: %w", dir, err)
|
||||
}
|
||||
keyPath := filepath.Join(dir, keyFileName)
|
||||
pubPath := filepath.Join(dir, pubFileName)
|
||||
|
||||
if raw, err := os.ReadFile(keyPath); err == nil {
|
||||
block, _ := pem.Decode(raw)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("configsign: %s is not PEM", keyPath)
|
||||
}
|
||||
key, err := parsePrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configsign: parse %s: %w", keyPath, err)
|
||||
}
|
||||
return newSigner(key, pubPath)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("configsign: read %s: %w", keyPath, err)
|
||||
}
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configsign: generate key: %w", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configsign: marshal private key: %w", err)
|
||||
}
|
||||
if err := writeFileAtomic(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newSigner(key, pubPath)
|
||||
}
|
||||
|
||||
// newSigner derives the kid and (re)writes the public key file.
|
||||
func newSigner(key *ecdsa.PrivateKey, pubPath string) (*Signer, error) {
|
||||
spki, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configsign: marshal public key: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(spki)
|
||||
s := &Signer{
|
||||
key: key,
|
||||
kid: hex.EncodeToString(sum[:16]),
|
||||
pubDER: spki,
|
||||
pubPEM: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spki}),
|
||||
}
|
||||
if err := writeFileAtomic(pubPath, s.pubPEM, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Kid is the key id placed in the JWT header; a service can recompute it from
|
||||
// the public key it holds.
|
||||
func (s *Signer) Kid() string { return s.kid }
|
||||
|
||||
// PublicKeyBase64 is the single-line env value Maestro injects into services:
|
||||
// base64.StdEncoding of the DER SubjectPublicKeyInfo (NOT the PEM). The service
|
||||
// decodes it once and has both the key and the kid input (kid is defined over
|
||||
// this exact DER).
|
||||
func (s *Signer) PublicKeyBase64() string {
|
||||
return base64.StdEncoding.EncodeToString(s.pubDER)
|
||||
}
|
||||
|
||||
// Mint returns a fresh ES256 config token for one service. Claims are frozen by
|
||||
// contract rev 9: iss=dcntrlized-maestro, aud=sub=<service>, scope="config",
|
||||
// iat/nbf/exp=now..now+60s, and a random 128-bit jti (single-use).
|
||||
func (s *Signer) Mint(service string, now time.Time) (string, error) {
|
||||
jti := make([]byte, 16)
|
||||
if _, err := rand.Read(jti); err != nil {
|
||||
return "", fmt.Errorf("configsign: jti: %w", err)
|
||||
}
|
||||
header := map[string]string{"alg": "ES256", "typ": "JWT", "kid": s.kid}
|
||||
claims := map[string]any{
|
||||
"iss": Issuer,
|
||||
"aud": service,
|
||||
"sub": service,
|
||||
"scope": Scope,
|
||||
"iat": now.Unix(),
|
||||
"nbf": now.Unix(),
|
||||
"exp": now.Add(TokenTTL).Unix(),
|
||||
"jti": hex.EncodeToString(jti),
|
||||
}
|
||||
hb, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cb, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
signingInput := b64(hb) + "." + b64(cb)
|
||||
digest := sha256.Sum256([]byte(signingInput))
|
||||
r, ss, err := ecdsa.Sign(rand.Reader, s.key, digest[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("configsign: sign: %w", err)
|
||||
}
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
ss.FillBytes(sig[32:])
|
||||
return signingInput + "." + b64(sig), nil
|
||||
}
|
||||
|
||||
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
func parsePrivateKey(der []byte) (*ecdsa.PrivateKey, error) {
|
||||
if k, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||
ec, ok := k.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not an ECDSA key")
|
||||
}
|
||||
return ec, nil
|
||||
}
|
||||
return x509.ParseECPrivateKey(der)
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to path via a temp file + rename so a crash can
|
||||
// never leave a half-written private key.
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, mode); err != nil {
|
||||
return fmt.Errorf("configsign: write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Chmod(tmp, mode); err != nil {
|
||||
return fmt.Errorf("configsign: chmod %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("configsign: rename %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify is a test helper: it checks a token the Signer minted against its own
|
||||
// public key (the same computation a service performs). It is not used by
|
||||
// Maestro at runtime — Maestro only mints.
|
||||
func (s *Signer) Verify(token string) error {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("malformed token")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil || len(sig) != 64 {
|
||||
return fmt.Errorf("bad signature encoding")
|
||||
}
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
ss := new(big.Int).SetBytes(sig[32:])
|
||||
if !ecdsa.Verify(&s.key.PublicKey, digest[:], r, ss) {
|
||||
return fmt.Errorf("signature does not verify")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package configsign
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadOrCreatePersistsKeypair(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s1, err := LoadOrCreate(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
keyPath := filepath.Join(root, "maestro", keyFileName)
|
||||
info, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat private key: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("private key mode = %o, want 600", perm)
|
||||
}
|
||||
pubInfo, err := os.Stat(filepath.Join(root, "maestro", pubFileName))
|
||||
if err != nil {
|
||||
t.Fatalf("stat public key: %v", err)
|
||||
}
|
||||
if perm := pubInfo.Mode().Perm(); perm != 0o644 {
|
||||
t.Fatalf("public key mode = %o, want 644", perm)
|
||||
}
|
||||
|
||||
// Reload must reuse the same key (kid stable).
|
||||
s2, err := LoadOrCreate(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate reload: %v", err)
|
||||
}
|
||||
if s1.Kid() != s2.Kid() {
|
||||
t.Fatalf("kid changed across reload: %q vs %q", s1.Kid(), s2.Kid())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKidIsHexOfSPKIHalf(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := LoadOrCreate(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(s.Kid()) {
|
||||
t.Fatalf("kid = %q, want 32 lowercase hex chars", s.Kid())
|
||||
}
|
||||
der, err := base64.StdEncoding.DecodeString(s.PublicKeyBase64())
|
||||
if err != nil {
|
||||
t.Fatalf("PublicKeyBase64 is not base64: %v", err)
|
||||
}
|
||||
if _, err := x509.ParsePKIXPublicKey(der); err != nil {
|
||||
t.Fatalf("public key env value is not DER SPKI: %v", err)
|
||||
}
|
||||
sum := sha256.Sum256(der)
|
||||
if want := hex.EncodeToString(sum[:16]); s.Kid() != want {
|
||||
t.Fatalf("kid = %q, want sha256(spki)[:16] = %q", s.Kid(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintClaimsAndSignature(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := LoadOrCreate(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
token, err := s.Mint("calendar", now)
|
||||
if err != nil {
|
||||
t.Fatalf("Mint: %v", err)
|
||||
}
|
||||
if err := s.Verify(token); err != nil {
|
||||
t.Fatalf("minted token does not verify: %v", err)
|
||||
}
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("token has %d parts, want 3", len(parts))
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil || len(sig) != 64 {
|
||||
t.Fatalf("signature must be raw 64-byte r||s (len=%d err=%v)", len(sig), err)
|
||||
}
|
||||
|
||||
var header map[string]string
|
||||
hb, _ := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err := json.Unmarshal(hb, &header); err != nil {
|
||||
t.Fatalf("header: %v", err)
|
||||
}
|
||||
if header["alg"] != "ES256" || header["typ"] != "JWT" || header["kid"] != s.Kid() {
|
||||
t.Fatalf("header = %v", header)
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err := json.Unmarshal(cb, &claims); err != nil {
|
||||
t.Fatalf("claims: %v", err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"iss": "dcntrlized-maestro",
|
||||
"aud": "calendar",
|
||||
"sub": "calendar",
|
||||
"scope": "config",
|
||||
"iat": float64(now.Unix()),
|
||||
"nbf": float64(now.Unix()),
|
||||
"exp": float64(now.Add(60 * time.Second).Unix()),
|
||||
}
|
||||
for k, v := range want {
|
||||
if claims[k] != v {
|
||||
t.Errorf("claim %s = %v, want %v", k, claims[k], v)
|
||||
}
|
||||
}
|
||||
jti, _ := claims["jti"].(string)
|
||||
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(jti) {
|
||||
t.Fatalf("jti = %q, want 32 lowercase hex chars", jti)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContractGoldenFixture cross-checks Maestro's kid derivation and ES256
|
||||
// interpretation against the worked example frozen in contract rev 9 §17.3.7.
|
||||
// It does not use the Signer (which holds a private key): it verifies the
|
||||
// contract's token with the contract's public key, exactly as a service would.
|
||||
func TestContractGoldenFixture(t *testing.T) {
|
||||
const (
|
||||
pubB64 = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqBhe2nIg0c3qf2C8yIVGs9/qSqLvW8eFUliCT1yKnWmUhX5RdRCOIOo59M7APFzywtwSOxMMivQh9N5rVWXxCQ=="
|
||||
kidHex = "088efbd65ad78ceed9dfe6495367ea6d"
|
||||
token = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjA4OGVmYmQ2NWFkNzhjZWVkOWRmZTY0OTUzNjdlYTZkIn0.eyJpc3MiOiJkY250cmxpemVkLW1hZXN0cm8iLCJhdWQiOiJjYWxlbmRhciIsInN1YiI6ImNhbGVuZGFyIiwic2NvcGUiOiJjb25maWciLCJpYXQiOjE3NzAwMDAwMDAsIm5iZiI6MTc3MDAwMDAwMCwiZXhwIjoxNzcwMDAwMDYwLCJqdGkiOiIwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZiJ9.4F8OmePWBO4lZsR0bOG5HK6cttvtTmKjwhcYMZcY5neIGnnmTlYsGtGJzMmhaC7eM5OEz6d0rPft8ZWNiQvyfQ"
|
||||
)
|
||||
|
||||
der, err := base64.StdEncoding.DecodeString(pubB64)
|
||||
if err != nil {
|
||||
t.Fatalf("decode pubkey: %v", err)
|
||||
}
|
||||
sum := sha256.Sum256(der)
|
||||
if got := hex.EncodeToString(sum[:16]); got != kidHex {
|
||||
t.Fatalf("kid = %s, contract says %s", got, kidHex)
|
||||
}
|
||||
parsed, err := x509.ParsePKIXPublicKey(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse pubkey: %v", err)
|
||||
}
|
||||
pub, ok := parsed.(*ecdsa.PublicKey)
|
||||
if !ok || pub.Curve != elliptic.P256() {
|
||||
t.Fatalf("pubkey is not P-256 ECDSA: %T", parsed)
|
||||
}
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("fixture token has %d segments", len(parts))
|
||||
}
|
||||
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil || len(sig) != 64 {
|
||||
t.Fatalf("fixture signature not raw 64 bytes (len=%d err=%v)", len(sig), err)
|
||||
}
|
||||
if !ecdsa.Verify(pub, digest[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) {
|
||||
t.Fatal("contract fixture token does not verify with the contract public key")
|
||||
}
|
||||
|
||||
var claims map[string]any
|
||||
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err := json.Unmarshal(cb, &claims); err != nil {
|
||||
t.Fatalf("fixture payload: %v", err)
|
||||
}
|
||||
if claims["iss"] != Issuer || claims["aud"] != "calendar" || claims["sub"] != "calendar" || claims["scope"] != Scope {
|
||||
t.Fatalf("fixture claims mismatch: %v", claims)
|
||||
}
|
||||
if exp, iat := claims["exp"].(float64), claims["iat"].(float64); exp-iat != 60 {
|
||||
t.Fatalf("fixture exp-iat = %v, want 60", exp-iat)
|
||||
}
|
||||
if claims["nbf"] != claims["iat"] {
|
||||
t.Fatalf("fixture nbf != iat: %v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintJTIUniqueAndTamperRejected(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, err := LoadOrCreate(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadOrCreate: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
a, err := s.Mint("calendar", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := s.Mint("calendar", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("two mints produced the same token (jti must be random)")
|
||||
}
|
||||
|
||||
// Flip one payload byte → signature must fail.
|
||||
parts := strings.Split(a, ".")
|
||||
parts[1] = strings.Replace(parts[1], "e", "f", 1)
|
||||
if err := s.Verify(strings.Join(parts, ".")); err == nil {
|
||||
t.Fatal("tampered token verified")
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,13 @@
|
||||
// Package containers implements Maestro's runtime write-back delivery for the
|
||||
// Service Config & Secrets theme (D6). When the operator sets or clears a
|
||||
// config value via the Settings panel, Maestro delivers it to the running
|
||||
// service's POST /config endpoint over an ALWAYS-mTLS edge: Maestro's own
|
||||
// identity (CN=maestro) and plain "https" regardless of dev mode, targeted at
|
||||
// the service's dedicated peer listener port (label org.dcntrlized.peer_port,
|
||||
// default 9443), which the service gates to CN=maestro. A set carries
|
||||
// {field, value, type}; a clear carries {field, action:"clear"} — the service
|
||||
// owns the schema and branches on what it receives.
|
||||
// service's POST /config endpoint on the service's single plaintext :8080
|
||||
// listener, authenticated with a short-lived, single-use ES256 config token
|
||||
// minted from Maestro's dedicated config-signing keypair (contract rev 9 §17).
|
||||
// There is no mTLS and no peer port: the fleet-wide internal transport is
|
||||
// plaintext HTTP. A set carries {field, value, type}; a clear carries
|
||||
// {field, action:"clear"} — the service owns the schema and branches on what it
|
||||
// receives.
|
||||
//
|
||||
// Delivery is best-effort: failures are logged (with the field name, never
|
||||
// the value) and do not prevent the panel write from succeeding. Values always
|
||||
@@ -42,32 +43,16 @@ import (
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
)
|
||||
|
||||
// peerPortLabel is the compose label a service declares to advertise the
|
||||
// internal port of its always-mTLS Maestro peer listener.
|
||||
const peerPortLabel = "org.dcntrlized.peer_port"
|
||||
|
||||
// defaultPeerPort is the fleet-wide default for that listener. Services keep/ve
|
||||
// to it unless the label overrides it.
|
||||
const defaultPeerPort = "9443"
|
||||
|
||||
// peerPort resolves the configured {peer listener port} from a container's
|
||||
// compose labels, falling back to the fleet default.
|
||||
func peerPort(labels map[string]string) string {
|
||||
if labels == nil {
|
||||
return defaultPeerPort
|
||||
}
|
||||
if p := labels[peerPortLabel]; p != "" {
|
||||
return p
|
||||
}
|
||||
return defaultPeerPort
|
||||
}
|
||||
// configPort is the single port every service serves its API, /config and web
|
||||
// app on after rev 9. There is no peer listener and no compose label.
|
||||
const configPort = "8080"
|
||||
|
||||
// ConfigDelivery is one key-value pair to deliver to a running service.
|
||||
type ConfigDelivery struct {
|
||||
Field string // manifest config field name
|
||||
Key string // container env var name
|
||||
Type string // "cleartext" | "password"
|
||||
Value string // the operator-entered value (sent over mTLS; never logged)
|
||||
Value string // the operator-entered value (never logged)
|
||||
}
|
||||
|
||||
// configRequest is the JSON body POSTed to the service's /config endpoint.
|
||||
@@ -79,10 +64,9 @@ type configRequest struct {
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
// DeliverConfigValues delivers the given config pairs to the running service
|
||||
// over the mTLS peer edge. If the service is not running or unreachable, the
|
||||
// error is logged but not returned — the values will take effect at next start
|
||||
// via injectConfigSecrets.
|
||||
// DeliverConfigValues delivers the given config pairs to the running service.
|
||||
// If the service is not running or unreachable, the error is logged but not
|
||||
// returned — the values will take effect at next start via injectConfigSecrets.
|
||||
func DeliverConfigValues(ctx context.Context, serviceName string, items []ConfigDelivery) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
@@ -114,9 +98,10 @@ func DeliverConfigClear(ctx context.Context, serviceName, field string) {
|
||||
}
|
||||
|
||||
// postConfig performs one best-effort POST of body to the running service's
|
||||
// /config endpoint. NotFound/unreachable cases are logged at debug (the write
|
||||
// still applies at next start); transport errors carry the field name only,
|
||||
// never a value.
|
||||
// /config endpoint on the single :8080 listener, authenticated with a freshly
|
||||
// minted ES256 config token. NotFound/unreachable cases are logged at debug
|
||||
// (the write still applies at next start); transport errors carry the field
|
||||
// name only, never a value.
|
||||
func postConfig(ctx context.Context, serviceName, field, verb string, body []byte) {
|
||||
dc, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
@@ -137,30 +122,22 @@ func postConfig(ctx context.Context, serviceName, field, verb string, body []byt
|
||||
return
|
||||
}
|
||||
|
||||
// /config is served on the service's dedicated Maestro peer listener
|
||||
// (always mTLS, gated to CN=maestro), NOT the RN-facing flat edge. Maestro
|
||||
// presents its own identity and targets the peer port; dev mode does not
|
||||
// downgrade this edge.
|
||||
port := peerPort(inspectData.Config.Labels)
|
||||
|
||||
transport, err := authmgmt.MaestroTransport()
|
||||
token, err := authmgmt.ConfigToken(serviceName)
|
||||
if err != nil {
|
||||
logger.Warn("Config delivery to %s/%s: cannot build Maestro peer transport: %v", serviceName, field, err)
|
||||
logger.Warn("Config delivery to %s/%s: cannot mint config token: %v", serviceName, field, err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
url := fmt.Sprintf("https://%s:%s/config", network.IPAddress.Address, port)
|
||||
url := fmt.Sprintf("http://%s:%s/config", network.IPAddress.Address, configPort)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, field, err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -174,4 +151,4 @@ func postConfig(ctx context.Context, serviceName, field, verb string, body []byt
|
||||
} else {
|
||||
logger.Debug("Config delivery to %s/%s: service responded %d; will apply at next start", serviceName, field, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package containers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPeerPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels map[string]string
|
||||
want string
|
||||
}{
|
||||
{"nil labels fall back to default", nil, defaultPeerPort},
|
||||
{"empty labels fall back to default", map[string]string{}, defaultPeerPort},
|
||||
{"label present wins", map[string]string{"org.dcntrlized.peer_port": "9000"}, "9000"},
|
||||
{"empty label value falls back to default", map[string]string{"org.dcntrlized.peer_port": ""}, defaultPeerPort},
|
||||
{"unrelated labels fall back to default", map[string]string{"org.dcntrlized.port": "8080"}, defaultPeerPort},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := peerPort(tt.labels); got != tt.want {
|
||||
t.Fatalf("peerPort(%v) = %q, want %q", tt.labels, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
libregistry "git.dcntrlized.org/Dcntrlized/Maestro/lib/registry"
|
||||
@@ -105,16 +104,6 @@ func ensureInfraRunning(ctx context.Context, serviceName, label string) error {
|
||||
if err := StartContainer(ctx, types.RegistryContainer{Name: serviceName}); err != nil {
|
||||
return fmt.Errorf("failed to start %s %s: %w", label, serviceName, err)
|
||||
}
|
||||
|
||||
// For the shared database, reconcile the CONNECT authorization against the
|
||||
// peers Maestro manages (M2 §4): per-role CONNECT grants and, in mTLS mode,
|
||||
// PUBLIC revoked. Best-effort — the database advertises infra-relevant
|
||||
// problems as warnings, never as boot failures.
|
||||
if serviceName == DatabaseServiceName {
|
||||
if err := dbctl.New(dc).SyncAuthorization(ctx); err != nil {
|
||||
logger.Warn("Shared database authorization sync failed: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package dbctl administers the shared database's authorization (M2 §4).
|
||||
//
|
||||
// The database image enables instance-CA mTLS when Maestro injects an identity
|
||||
// (server cert + client-CA + hostssl cert / hostnossl reject rules), but the
|
||||
// auth rules alone only prove *who* authenticates. Authorization lives here:
|
||||
// the stock PUBLIC CONNECT grant is revoked and each installed peer gets a
|
||||
// LOGIN role with CONNECT, so a peer that holds no role (or a revoked one)
|
||||
// cannot even open a session. The admin path uses the postgres superuser over
|
||||
// the in-container unix socket (trust), which deliberately bypasses the mTLS
|
||||
// enforcement — Maestro owns the database and drives its lifecycle.
|
||||
package dbctl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
)
|
||||
|
||||
const (
|
||||
// DBContainerName is the canonical shared database container (image, DNS).
|
||||
DBContainerName = "dcntrlized-db"
|
||||
// DBName is the shared database name inside the instance.
|
||||
DBName = "dcntrlized"
|
||||
// AdminRole is Maestro's own database role (CN=maestro bootstrap in the
|
||||
// image). It is granted like any peer so its existence is guaranteed.
|
||||
AdminRole = "maestro"
|
||||
// identityMountTarget is the container path of the injected leaf cert. Its
|
||||
// presence in the mount list marks the database as mTLS-armed.
|
||||
identityMountTarget = "/dcntrlized/pki/tls.crt"
|
||||
)
|
||||
|
||||
// Client reconciles the shared database's CONNECT authorization state.
|
||||
type Client struct {
|
||||
dc *docker.DockerClient
|
||||
}
|
||||
|
||||
// New wraps a Docker client.
|
||||
func New(dc *docker.DockerClient) *Client {
|
||||
return &Client{dc: dc}
|
||||
}
|
||||
|
||||
// SyncAuthorization ensures every installed peer holds a LOGIN role with
|
||||
// CONNECT on the shared database, and — when the database runs in mTLS mode —
|
||||
// revokes the stock PUBLIC grant. It is safe to call on every start: role
|
||||
// creation and grants are idempotent, and the PUBLIC revoke is a no-op on the
|
||||
// second run. When the database is not running it returns an error; callers
|
||||
// treat it as best-effort.
|
||||
func (c *Client) SyncAuthorization(ctx context.Context) error {
|
||||
id := c.dbContainerID(ctx)
|
||||
if id == "" {
|
||||
return fmt.Errorf("shared database %s is not running", DBContainerName)
|
||||
}
|
||||
|
||||
if err := c.waitReady(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
roles, err := peerRoles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
script := "SET client_min_messages TO warning;\n"
|
||||
have := c.existingRoles(ctx, id)
|
||||
for _, role := range roles {
|
||||
if !have[role] {
|
||||
script += fmt.Sprintf("CREATE ROLE %s LOGIN;\n", quoteIdent(role))
|
||||
}
|
||||
script += fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s;\n", quoteIdent(DBName), quoteIdent(role))
|
||||
// The peer connects and migrates under its own role (its cert CN),
|
||||
// not the shared superuser: it may create tables in public (PG15+
|
||||
// revoked that from PUBLIC) and must reach the tables it already
|
||||
// owns or shares across every user schema (e.g. auth's own schema).
|
||||
script += fmt.Sprintf("GRANT CREATE ON SCHEMA public TO %s;\n", quoteIdent(role))
|
||||
script += grantExistingScript(role)
|
||||
}
|
||||
if c.isArmed(ctx, id) {
|
||||
script += fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM PUBLIC;\n", quoteIdent(DBName))
|
||||
}
|
||||
|
||||
out, code, err := c.execPSQL(ctx, id, DBName, script)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply database authorization: %w", err)
|
||||
}
|
||||
if code != 0 {
|
||||
return fmt.Errorf("apply database authorization failed (exit %d): %s", code, out)
|
||||
}
|
||||
logger.Info("Reconciled shared database authorization for %d peer(s): %s", len(roles), strings.Join(roles, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeForService removes a peer's CONNECT grant when it is uninstalled.
|
||||
// Best-effort: the database may already be gone.
|
||||
func (c *Client) RevokeForService(ctx context.Context, serviceName string) {
|
||||
if serviceName == "" {
|
||||
return
|
||||
}
|
||||
id := c.dbContainerID(ctx)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
script := fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM %s;", quoteIdent(DBName), quoteIdent(serviceName))
|
||||
out, code, err := c.execPSQL(ctx, id, DBName, script)
|
||||
if err != nil || code != 0 {
|
||||
logger.Warn("Failed to revoke database CONNECT for %s: %v (exit %d): %s", serviceName, err, code, out)
|
||||
return
|
||||
}
|
||||
logger.Info("Revoked database CONNECT for %s", serviceName)
|
||||
}
|
||||
|
||||
// peerRoles returns the guaranteed connectors: Maestro plus every installed
|
||||
// service (identified by its install dir name, which is also its cert CN).
|
||||
func peerRoles() ([]string, error) {
|
||||
roles := []string{AdminRole}
|
||||
root, err := dataRoot()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve data root: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return roles, nil
|
||||
}
|
||||
return nil, fmt.Errorf("list data root: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || entry.Name() == DBContainerName {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, entry.Name(), "metadata.json")); err != nil {
|
||||
continue
|
||||
}
|
||||
roles = append(roles, entry.Name())
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
// dataRoot resolves the directory storing installed container metadata, with
|
||||
// the same precedence as lib/containers.DataRoot (kept local to avoid an
|
||||
// import cycle — lib/containers imports dbctl).
|
||||
func dataRoot() (string, error) {
|
||||
if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, ".dcntrlized"), nil
|
||||
}
|
||||
if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
|
||||
return filepath.Join(xdgHome, ".dcntrlized"), nil
|
||||
}
|
||||
if home := os.Getenv("HOME"); home != "" {
|
||||
return filepath.Join(home, ".dcntrlized"), nil
|
||||
}
|
||||
return filepath.Join("/data", ".dcntrlized"), nil
|
||||
}
|
||||
|
||||
// grantExistingScript returns the DO block granting a role USAGE plus ALL
|
||||
// PRIVILEGES on tables and sequences in every user schema. Existing volumes
|
||||
// hold tables created by the postgres superuser (and by other services'
|
||||
// earlier migrations); without these grants a per-service role could connect
|
||||
// but not read or migrate anything.
|
||||
func grantExistingScript(role string) string {
|
||||
return fmt.Sprintf(`DO $dcntrlized_grants$ DECLARE s text; BEGIN
|
||||
FOR s IN SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT LIKE 'pg_%%' AND nspname <> 'information_schema' LOOP
|
||||
EXECUTE format('GRANT USAGE ON SCHEMA %%I TO %%I', s, %s);
|
||||
EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %%I TO %%I', s, %s);
|
||||
EXECUTE format('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %%I TO %%I', s, %s);
|
||||
END LOOP; END $dcntrlized_grants$;
|
||||
`, quoteLiteral(role), quoteLiteral(role), quoteLiteral(role))
|
||||
}
|
||||
|
||||
// quoteLiteral renders a PostgreSQL string literal safe for interpolation.
|
||||
func quoteLiteral(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
|
||||
// quoteIdent renders a PostgreSQL identifier safe for interpolation.
|
||||
func quoteIdent(s string) string {
|
||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
// dbContainerID resolves the running canonical database container, or "".
|
||||
func (c *Client) dbContainerID(ctx context.Context) string {
|
||||
filter := filters.NewArgs()
|
||||
filter.Add("label", "org.dcntrlized.service="+DBContainerName)
|
||||
containers, err := c.dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter})
|
||||
if err != nil {
|
||||
logger.Warn("Failed to list database containers: %v", err)
|
||||
return ""
|
||||
}
|
||||
for _, cc := range containers {
|
||||
if cc.State != "running" {
|
||||
continue
|
||||
}
|
||||
for _, n := range cc.Names {
|
||||
if strings.TrimPrefix(n, "/") == DBContainerName {
|
||||
return cc.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Armed reports whether the running shared database received the injected
|
||||
// instance-CA identity (i.e. it enforces mTLS on its TCP edge). Callers use
|
||||
// this to decide which connection URL peers get: verify-full mTLS DSNs on an
|
||||
// armed database, plaintext defaults otherwise.
|
||||
func (c *Client) Armed(ctx context.Context) bool {
|
||||
id := c.dbContainerID(ctx)
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
return c.isArmed(ctx, id)
|
||||
}
|
||||
|
||||
// isArmed reports whether the database container received the injected
|
||||
// instance-CA identity (i.e. it enforces mTLS on its TCP edge).
|
||||
func (c *Client) isArmed(ctx context.Context, id string) bool {
|
||||
inspect, err := c.dc.ContainerInspect(ctx, id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, m := range inspect.Mounts {
|
||||
if filepath.Clean(m.Destination) == identityMountTarget {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// waitReady polls the database until an admin statement executes or the
|
||||
// deadline is reached, tolerating the first-boot delay after compose up.
|
||||
func (c *Client) waitReady(ctx context.Context, id string) error {
|
||||
const attempts = 30
|
||||
for i := 0; i < attempts; i++ {
|
||||
_, code, err := c.execPSQL(ctx, id, "postgres", "SELECT 1")
|
||||
if err == nil && code == 0 {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("database %s did not become ready in time", DBContainerName)
|
||||
}
|
||||
|
||||
// existingRoles returns the set of roles currently defined in the database.
|
||||
func (c *Client) existingRoles(ctx context.Context, id string) map[string]bool {
|
||||
have := map[string]bool{}
|
||||
out, code, err := c.execPSQL(ctx, id, "postgres", "SELECT rolname FROM pg_catalog.pg_roles")
|
||||
if err != nil || code != 0 {
|
||||
return have
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if name := strings.TrimSpace(line); name != "" {
|
||||
have[name] = true
|
||||
}
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
// execPSQL runs a psql statement over the in-container unix socket as the
|
||||
// postgres superuser (bypassing the mTLS enforcement intentionally — Maestro
|
||||
// administers the database). It returns trimmed stdout and the exit code.
|
||||
// execPSQL runs a psql statement over the in-container unix socket as the
|
||||
// postgres superuser (bypassing the mTLS enforcement intentionally — Maestro
|
||||
// administers the database), targeting the named database. It returns trimmed
|
||||
// stdout and the exit code.
|
||||
func (c *Client) execPSQL(ctx context.Context, id, dbName, sql string) (string, int, error) {
|
||||
created, err := c.dc.ContainerExecCreate(ctx, id, container.ExecOptions{
|
||||
User: "postgres",
|
||||
AttachStdout: true,
|
||||
Cmd: []string{"psql", "-U", "postgres", "-d", dbName, "-v", "ON_ERROR_STOP=1", "-tA", "-c", sql},
|
||||
})
|
||||
if err != nil {
|
||||
return "", -1, fmt.Errorf("create psql exec: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.dc.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
|
||||
if err != nil {
|
||||
return "", -1, fmt.Errorf("attach psql exec: %w", err)
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if _, err := stdcopy.StdCopy(&stdout, &stderr, resp.Reader); err != nil {
|
||||
return "", -1, fmt.Errorf("read psql output: %w", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
inspect, err := c.dc.ContainerExecInspect(ctx, created.ID)
|
||||
if err != nil {
|
||||
return "", -1, fmt.Errorf("inspect psql exec: %w", err)
|
||||
}
|
||||
if !inspect.Running {
|
||||
return strings.TrimSpace(stdout.String()), inspect.ExitCode, nil
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return "", -1, fmt.Errorf("psql exec did not finish in time")
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package dbctl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
)
|
||||
|
||||
// TestSyncAuthorizationIntegration exercises the real exec path against a
|
||||
// running dcntrlized-db container.
|
||||
//
|
||||
// 1. docker run -d --name dcntrlized-db -e POSTGRES_DB=dcntrlized \
|
||||
// -e POSTGRES_USER=postgres -e POSTGRES_HOST_AUTH_METHOD=trust \
|
||||
// -v $PWD/PKI/ca.crt:/dcntrlized/pki/ca.crt:ro \
|
||||
// -v $PWD/PKI/tls.crt:/dcntrlized/pki/tls.crt:ro \
|
||||
// -v $PWD/PKI/tls.key:/dcntrlized/pki/tls.key:ro \
|
||||
// -v dctl-vol:/var/lib/postgresql/data dcntrlized-db
|
||||
// 2. seed the data root with install dirs for the peers
|
||||
// 3. DBCtl_INTEGRATION=1 go test ./lib/db -run TestSyncAuthorizationIntegration -v
|
||||
func TestSyncAuthorizationIntegration(t *testing.T) {
|
||||
if os.Getenv("DBCtl_INTEGRATION") != "1" {
|
||||
t.Skip("set DBCtl_INTEGRATION=1 to run against a live database container")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
|
||||
for _, name := range []string{"keepnote", "dcntrlized-auth"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, name, "metadata.json"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
dc, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer dc.Close()
|
||||
|
||||
c := New(dc)
|
||||
if err := c.SyncAuthorization(context.Background()); err != nil {
|
||||
t.Fatalf("SyncAuthorization: %v", err)
|
||||
}
|
||||
|
||||
id := c.dbContainerID(context.Background())
|
||||
if id == "" {
|
||||
t.Fatal("database container not found")
|
||||
}
|
||||
assert := func(t *testing.T, sql, contains string) {
|
||||
t.Helper()
|
||||
out, code, err := c.execPSQL(context.Background(), id, "postgres", sql)
|
||||
if err != nil || code != 0 {
|
||||
t.Fatalf("check %q failed (exit %d, err %v): %s", sql, code, err, out)
|
||||
}
|
||||
if want := contains; want != "" && out != want {
|
||||
t.Fatalf("%s => %q, want %q", sql, out, want)
|
||||
}
|
||||
}
|
||||
// Roles exist and hold CONNECT.
|
||||
assert(t, `SELECT rolname FROM pg_catalog.pg_roles WHERE rolname IN ('maestro','keepnote','dcntrlized-auth') ORDER BY 1`, "dcntrlized-auth\nkeepnote\nmaestro")
|
||||
assert(t, `SELECT has_database_privilege('keepnote','dcntrlized','CONNECT')`, "t")
|
||||
// Armed database: PUBLIC grant removed.
|
||||
assert(t, `SELECT has_database_privilege('public','dcntrlized','CONNECT')`, "f")
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package dbctl
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQuoteIdent(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"keepnote": `"keepnote"`,
|
||||
"dcntrlized-db": `"dcntrlized-db"`,
|
||||
`we"ird`: `"we""ird"`,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := quoteIdent(in); got != want {
|
||||
t.Errorf("quoteIdent(%q) = %s, want %s", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerRoles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
|
||||
|
||||
// Installed peers (metadata.json present).
|
||||
for _, name := range []string{"keepnote", "dcntrlized-auth", "calendar"} {
|
||||
dir := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// The database itself must never be granted a role.
|
||||
if err := os.MkdirAll(filepath.Join(root, DBContainerName), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, DBContainerName, "metadata.json"), []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A non-install directory (no metadata.json) is skipped.
|
||||
if err := os.MkdirAll(filepath.Join(root, "scratch"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
roles, err := peerRoles()
|
||||
if err != nil {
|
||||
t.Fatalf("peerRoles: %v", err)
|
||||
}
|
||||
sort.Strings(roles)
|
||||
want := []string{"calendar", "dcntrlized-auth", "keepnote", "maestro"}
|
||||
if len(roles) != len(want) {
|
||||
t.Fatalf("peerRoles = %v, want %v", roles, want)
|
||||
}
|
||||
for i := range want {
|
||||
if roles[i] != want[i] {
|
||||
t.Fatalf("peerRoles = %v, want %v", roles, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package devboot provisions the dev fleet the DCNTRLIZED_DEV way: Maestro
|
||||
// issues the identities its docker-compose.dev/debug.yaml peers bind-mount at
|
||||
// /dcntrlized/pki. There is no approval shortcut — a satellite's first mTLS
|
||||
// contact records a pending authorization and is refused (403) until the owner
|
||||
// approves it via /api/peers, exactly the real trust-store flow.
|
||||
package devboot
|
||||
|
||||
import (
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
||||
)
|
||||
|
||||
// Infra are the shared services whose identities the dev composes mount. They
|
||||
// are peers/attesters themselves, not approval candidates (auth is the
|
||||
// trust-store owner, Maestro uses the internal RequireMaestro endpoints).
|
||||
var Infra = []string{"dcntrlized-auth"}
|
||||
|
||||
// Fleet lists the dev-mode satellite identities Maestro issues. Their CNs must
|
||||
// match the service names used in the dev composes' pki mounts.
|
||||
var Fleet = []string{"keepnote", "calendar", "testservice"}
|
||||
|
||||
// EnsureIdentities provisions the instance CA identities the dev composes
|
||||
// bind-mount at /dcntrlized/pki, so compose-managed services come up with a
|
||||
// valid mTLS identity before they ever dial the auth service. Run before the
|
||||
// shared infrastructure is ensured, because dcntrlized-auth's mTLS listener
|
||||
// refuses to start without its identity.
|
||||
func EnsureIdentities() {
|
||||
dataRoot, err := containers.DataRoot()
|
||||
if err != nil {
|
||||
logger.Warn("devboot: resolve data root: %v", err)
|
||||
return
|
||||
}
|
||||
ca, err := pki.EnsureCA(dataRoot)
|
||||
if err != nil {
|
||||
logger.Warn("devboot: ensure instance CA: %v", err)
|
||||
return
|
||||
}
|
||||
for _, name := range append(append([]string{}, Infra...), Fleet...) {
|
||||
if _, err := pki.EnsureServiceIdentity(ca, name); err != nil {
|
||||
logger.Warn("devboot: ensure identity for %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
logger.Info("devboot: ensured dev fleet identities (%d infra + %d satellites)", len(Infra), len(Fleet))
|
||||
}
|
||||
@@ -93,18 +93,14 @@ func ListContainers(dc *DockerClient) ([]string, error) {
|
||||
// fetchManifestWithRetry polls the container's manifest endpoint until it succeeds or max retries are reached.
|
||||
// Could probably be simplified to (ctx, container) with maxRetries and retryDelay hardcoded, low priority TODO
|
||||
func FetchManifestWithRetry(ctx context.Context, host, port string, maxRetries int, retryDelay time.Duration) (*types.Manifest, error) {
|
||||
// The service peer edge is mutual TLS (M2 §2), same as RunAction: Maestro's
|
||||
// identity cert, https scheme, with the plaintext dev escape hatch.
|
||||
// Rev 9: every service peer edge is plaintext HTTP on the single :8080
|
||||
// listener — no client cert, no TLS.
|
||||
url := fmt.Sprintf("%s://%s:%s/_manifest", authmgmt.Scheme(), host, port)
|
||||
var resp *http.Response
|
||||
var err error
|
||||
|
||||
// Create a client that respects context deadlines/cancellation
|
||||
transport, terr := authmgmt.PeerTransport()
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("build peer transport: %w", terr)
|
||||
}
|
||||
client := &http.Client{Timeout: 2 * time.Second, Transport: transport}
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
for range maxRetries {
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
||||
Vendored
+3
-2
@@ -16,8 +16,9 @@
|
||||
// Package env centralizes environment-derived configuration. DCNTRLIZED_DEV is
|
||||
// the single canonical dev-mode flag, injected by every docker-compose.dev.yaml
|
||||
// and docker-compose.debug.yaml. It selects dev-only endpoints and behaviors
|
||||
// (registry URL, plaintext satellite peer edges) — it never downgrades the
|
||||
// transport security of the control-plane edges, which run mTLS in dev too.
|
||||
// (registry URL, debug selectors). After rev 9 the fleet-wide internal
|
||||
// transport is plaintext HTTP on a single :8080 listener in every mode, so dev
|
||||
// mode no longer downgrades anything.
|
||||
package env
|
||||
|
||||
import "os"
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package federation is a defensive reader for the remote-instance trust
|
||||
// store that the multi-registry & instance federation theme will own. The
|
||||
// hub app's public profile (hub-app theme M1) surfaces authorized peers from
|
||||
// it when present; until that theme lands, the file simply does not exist and
|
||||
// every read returns nil.
|
||||
package federation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// RemoteInstance mirrors the planned trust-store row: a peer this instance
|
||||
// trusts, addressed by URL and identified by its CA fingerprint.
|
||||
type RemoteInstance struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
// Path returns the trust-store location for the given data root.
|
||||
func Path(dataRoot string) string {
|
||||
return filepath.Join(dataRoot, "federation", "instances.json")
|
||||
}
|
||||
|
||||
// List reads the authorized remote instances. A missing store yields
|
||||
// (nil, nil): callers distinguish "absent" (nil → JSON null) from
|
||||
// "present but empty" (non-nil, len 0 → JSON []).
|
||||
func List(dataRoot string) ([]RemoteInstance, error) {
|
||||
data, err := os.ReadFile(Path(dataRoot))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Defensive parse: accept both a bare array and the wrapped shape the
|
||||
// federation theme is expected to persist.
|
||||
var direct []RemoteInstance
|
||||
if json.Unmarshal(data, &direct) == nil {
|
||||
return direct, nil
|
||||
}
|
||||
var wrapped struct {
|
||||
Instances []RemoteInstance `json:"instances"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &wrapped); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrapped.Instances, nil
|
||||
}
|
||||
@@ -15,16 +15,23 @@
|
||||
|
||||
// Package instance holds the instance's public identity: a small, self-
|
||||
// contained file at the data root that answers "who am I" for the hub app's
|
||||
// public profile (hub-app theme M1) and, later, federation attestation.
|
||||
// public profile (hub-app theme M1). Rev 9 removed federation, so the
|
||||
// fingerprint here is informational only (it is no longer a trust anchor).
|
||||
package instance
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// keyFileName is the persisted random instance key backing Fingerprint.
|
||||
const keyFileName = "instance.key"
|
||||
|
||||
// Identity is the instance's user-visible self-description. Name falls back to
|
||||
// DCNTRLIZED_INSTANCE_NAME and then the hostname when instance.json is absent;
|
||||
// Title and Description are optional and only ever come from the file.
|
||||
@@ -39,6 +46,55 @@ func Path(dataRoot string) string {
|
||||
return filepath.Join(dataRoot, "instance.json")
|
||||
}
|
||||
|
||||
// Fingerprint returns the instance's stable app-level identity fingerprint in
|
||||
// the same "sha256:ab:cd:…" shape the CA fingerprint used to have. Rev 9
|
||||
// removed the PKI layer, so the value is derived from a persisted 32-byte
|
||||
// random key at <dataRoot>/instance.key (created on first use, chmod 600) —
|
||||
// not from any certificate. It is unauthenticated by design; a consumer can
|
||||
// still compare it to detect a changed peer. Returns "" when the key cannot be
|
||||
// read or created (the public profile then omits the field).
|
||||
func Fingerprint(dataRoot string) string {
|
||||
key, err := loadOrCreateKey(dataRoot)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(key)
|
||||
hexStr := hex.EncodeToString(sum[:])
|
||||
b := make([]byte, 0, len(hexStr)+len(hexStr)/2+7)
|
||||
b = append(b, "sha256:"...)
|
||||
for i := 0; i < len(hexStr); i += 2 {
|
||||
if i > 0 {
|
||||
b = append(b, ':')
|
||||
}
|
||||
b = append(b, hexStr[i:i+2]...)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// loadOrCreateKey reads <dataRoot>/instance.key, generating it atomically on
|
||||
// first use.
|
||||
func loadOrCreateKey(dataRoot string) ([]byte, error) {
|
||||
path := filepath.Join(dataRoot, keyFileName)
|
||||
if data, err := os.ReadFile(path); err == nil && len(data) == 32 {
|
||||
return data, nil
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("generate instance key: %w", err)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, key, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write instance key: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("chmod instance key: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return nil, fmt.Errorf("replace instance key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Load resolves the instance identity: instance.json when present, else
|
||||
// DCNTRLIZED_INSTANCE_NAME, else os.Hostname(). Read-on-demand by design —
|
||||
// no daemon state, so an edited file shows up on the next request.
|
||||
@@ -61,9 +117,8 @@ func Load(dataRoot string) Identity {
|
||||
}
|
||||
|
||||
// Save atomically writes the identity file (tmp + rename), chmod 600 like the
|
||||
// rest of Maestro's file stores. The owner-gated edit endpoint that uses this
|
||||
// lands with the federation theme; the helper exists so both callers share one
|
||||
// write path.
|
||||
// rest of Maestro's file stores. The helper exists so the create and edit
|
||||
// paths share one write path.
|
||||
func Save(dataRoot string, id Identity) error {
|
||||
if id.Name == "" {
|
||||
return fmt.Errorf("instance name must not be empty")
|
||||
@@ -90,8 +145,7 @@ func defaultIdentity() Identity {
|
||||
if name := os.Getenv("DCNTRLIZED_INSTANCE_NAME"); name != "" {
|
||||
return Identity{Name: name}
|
||||
}
|
||||
// The domain the instance announces as is its natural public name;
|
||||
// federation attestation will reuse it.
|
||||
// The domain the instance announces as is its natural public name.
|
||||
if domain := os.Getenv("DCNTRLIZED_INSTANCE_DOMAIN"); domain != "" {
|
||||
return Identity{Name: domain}
|
||||
}
|
||||
|
||||
@@ -77,12 +77,12 @@ func TestDefaultIdentityFallbacks(t *testing.T) {
|
||||
|
||||
func TestLooksLikeContainerID(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"6079761d6e4d": true,
|
||||
"ABCDEF0123456789abcdef0123456789": true,
|
||||
"archlinux": false,
|
||||
"maestro-1": false,
|
||||
"abc": false,
|
||||
"": false,
|
||||
"6079761d6e4d": true,
|
||||
"ABCDEF0123456789abcdef0123456789": true,
|
||||
"archlinux": false,
|
||||
"maestro-1": false,
|
||||
"abc": false,
|
||||
"": false,
|
||||
}
|
||||
for s, want := range cases {
|
||||
if got := looksLikeContainerID(s); got != want {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pki
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RevokedCert is the persisted record of a revoked certificate (CRL source).
|
||||
type RevokedCert struct {
|
||||
Serial string `json:"serial"`
|
||||
Service string `json:"service"`
|
||||
RevokedAt time.Time `json:"revoked_at"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// RevokedSerialsDir returns the directory holding revoked certificates.
|
||||
func RevokedSerialsDir(dataRoot string) string {
|
||||
return filepath.Join(Dir(dataRoot), revokedDir)
|
||||
}
|
||||
|
||||
// Revoke revokes a service's identity certificate: the leaf certificate is
|
||||
// recorded as revoked and the current CRL is regenerated, then the service's
|
||||
// identity files are removed. Peers stop accepting the cert within one CRL
|
||||
// freshness window (security theme M3 §2).
|
||||
func Revoke(dataRoot, serviceName string) error {
|
||||
ca, err := EnsureCA(dataRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load CA for revocation: %w", err)
|
||||
}
|
||||
|
||||
meta, metaErr := readMeta(ServiceDir(dataRoot, serviceName))
|
||||
if metaErr == nil && meta.Serial != "" {
|
||||
serial := new(big.Int)
|
||||
if _, ok := serial.SetString(meta.Serial, 16); ok {
|
||||
if err := recordRevoked(ca, serviceName, serial, "uninstall"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the identity files so a revoked service cannot be restarted with
|
||||
// a valid certificate (it will get a fresh one on reinstall).
|
||||
if err := os.RemoveAll(ServiceDir(dataRoot, serviceName)); err != nil {
|
||||
return fmt.Errorf("remove identity for %s: %w", serviceName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordRevoked persists a revoked certificate and regenerates the CRL.
|
||||
func recordRevoked(ca *CA, serviceName string, serial *big.Int, reason string) error {
|
||||
dir := RevokedSerialsDir(ca.DataRoot)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return fmt.Errorf("create revoked dir: %w", err)
|
||||
}
|
||||
|
||||
revoked := RevokedCert{
|
||||
Serial: serial.Text(16),
|
||||
Service: serviceName,
|
||||
RevokedAt: now(),
|
||||
Reason: reason,
|
||||
}
|
||||
if err := writeFile(filepath.Join(dir, serial.Text(16)+".json"), marshalOrDie(revoked), 0o600); err != nil {
|
||||
return fmt.Errorf("record revoked cert: %w", err)
|
||||
}
|
||||
|
||||
return regenerateCRL(ca)
|
||||
}
|
||||
|
||||
// LoadRevokedSerials returns the serials currently revoked for this instance.
|
||||
func LoadRevokedSerials(dataRoot string) ([]*big.Int, error) {
|
||||
dir := RevokedSerialsDir(dataRoot)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var serials []*big.Int
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(e.Name(), ".json")
|
||||
serial := new(big.Int)
|
||||
if _, ok := serial.SetString(name, 16); ok {
|
||||
serials = append(serials, serial)
|
||||
}
|
||||
}
|
||||
return serials, nil
|
||||
}
|
||||
|
||||
// regenerateCRL writes crl.pem listing every currently-revoked serial. Peers
|
||||
// re-fetch it with a short freshness window (security theme M3).
|
||||
func regenerateCRL(ca *CA) error {
|
||||
serials, err := LoadRevokedSerials(ca.DataRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load revoked serials: %w", err)
|
||||
}
|
||||
|
||||
template := &x509.RevocationList{
|
||||
Number: mustSerial(),
|
||||
ThisUpdate: now(),
|
||||
NextUpdate: now().Add(24 * time.Hour),
|
||||
RevokedCertificates: revokedEntries(serials),
|
||||
}
|
||||
|
||||
der, err := x509.CreateRevocationList(rand.Reader, template, ca.Cert, ca.PrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create CRL: %w", err)
|
||||
}
|
||||
if err := writeFile(CRLPath(ca.DataRoot), pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: der}), 0o644); err != nil {
|
||||
return fmt.Errorf("write CRL: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokedEntries(serials []*big.Int) []pkix.RevokedCertificate {
|
||||
entries := make([]pkix.RevokedCertificate, 0, len(serials))
|
||||
for _, s := range serials {
|
||||
entries = append(entries, pkix.RevokedCertificate{
|
||||
SerialNumber: s,
|
||||
RevocationTime: now(),
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func mustSerial() *big.Int {
|
||||
serial, err := randomSerial()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("pki: failed to generate CRL number: %v", err))
|
||||
}
|
||||
return serial
|
||||
}
|
||||
|
||||
func marshalOrDie(v any) []byte {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("pki: failed to marshal: %v", err))
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pki
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// day is a shorthand so certificate validity windows read cleanly.
|
||||
const day = 24 * time.Hour
|
||||
|
||||
// now returns the current time in UTC (kept as a function so tests can
|
||||
// override it if they ever need deterministic timestamps).
|
||||
func now() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func writeFile(path string, data []byte, perm os.FileMode) error {
|
||||
return os.WriteFile(path, data, perm)
|
||||
}
|
||||
|
||||
// randomSerial returns a cryptographically random 128-bit serial number, as
|
||||
// required for CA-issued certificates.
|
||||
func randomSerial() (*big.Int, error) {
|
||||
limit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serial, err := rand.Int(rand.Reader, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate serial: %w", err)
|
||||
}
|
||||
return serial, nil
|
||||
}
|
||||
|
||||
func pkixName(cn string) pkix.Name {
|
||||
return pkix.Name{CommonName: cn}
|
||||
}
|
||||
|
||||
func encodeKeyPEM(key *ecdsa.PrivateKey) []byte {
|
||||
der, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
|
||||
}
|
||||
|
||||
func parseCertPEM(data []byte) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, errors.New("no PEM block found")
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
func parseKeyPEM(data []byte) (*ecdsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, errors.New("no PEM block found")
|
||||
}
|
||||
if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
if ecdsaKey, ok := key.(*ecdsa.PrivateKey); ok {
|
||||
return ecdsaKey, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("not a valid ECDSA private key")
|
||||
}
|
||||
|
||||
// newLeafKey generates an ECDSA P-256 key for a service identity.
|
||||
func newLeafKey() (*ecdsa.PrivateKey, error) {
|
||||
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
}
|
||||
|
||||
// Fingerprint returns the SHA256 fingerprint of a certificate's public key in
|
||||
// the OpenSSH-style format (sha256:ab:cd:…). It is the value satellites are
|
||||
// identified by in the authorized-keys trust store.
|
||||
func Fingerprint(cert *x509.Certificate) string {
|
||||
return FingerprintDER(cert.RawSubjectPublicKeyInfo)
|
||||
}
|
||||
|
||||
// FingerprintDER returns the SHA256 fingerprint of a DER SubjectPublicKeyInfo.
|
||||
func FingerprintDER(spki []byte) string {
|
||||
sum := sha256.Sum256(spki)
|
||||
hexStr := hex.EncodeToString(sum[:])
|
||||
b := make([]byte, 0, len(hexStr)+len(hexStr)/2+7)
|
||||
b = append(b, "sha256:"...)
|
||||
for i := 0; i < len(hexStr); i += 2 {
|
||||
if i > 0 {
|
||||
b = append(b, ':')
|
||||
}
|
||||
b = append(b, hexStr[i:i+2]...)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// CAFingerprint returns the instance CA's public-key fingerprint by reading
|
||||
// the certificate from disk on demand (hub-app M1's public profile surfaces
|
||||
// it as the instance identity). Empty string when no CA exists yet — dev-mode
|
||||
// static services never provision one.
|
||||
func CAFingerprint(dataRoot string) string {
|
||||
pemBytes, err := os.ReadFile(CertPath(dataRoot))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return ""
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return Fingerprint(cert)
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pki
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
)
|
||||
|
||||
// ServiceMeta records the issuance details of a service identity so Maestro
|
||||
// can make rotation/revocation decisions without parsing the certificate.
|
||||
type ServiceMeta struct {
|
||||
Service string `json:"service"`
|
||||
Serial string `json:"serial"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// Identity is a service's leaf certificate issued by the instance CA. It
|
||||
// proves *who* a peer is (name ↔ key, CA-signed); authorization is a separate
|
||||
// human-gated step owned by the receiving service (security theme D5).
|
||||
type Identity struct {
|
||||
Service string
|
||||
Cert *x509.Certificate
|
||||
Key *ecdsa.PrivateKey
|
||||
CertPath string
|
||||
KeyPath string
|
||||
Meta ServiceMeta
|
||||
}
|
||||
|
||||
// ServiceDir returns the identity directory for a service.
|
||||
func ServiceDir(dataRoot, serviceName string) string {
|
||||
return filepath.Join(Dir(dataRoot), servicesDir, serviceName)
|
||||
}
|
||||
|
||||
// ServiceCertPath returns the leaf certificate path for a service.
|
||||
func ServiceCertPath(dataRoot, serviceName string) string {
|
||||
return filepath.Join(ServiceDir(dataRoot, serviceName), "tls.crt")
|
||||
}
|
||||
|
||||
// ServiceKeyPath returns the leaf private key path for a service.
|
||||
func ServiceKeyPath(dataRoot, serviceName string) string {
|
||||
return filepath.Join(ServiceDir(dataRoot, serviceName), "tls.key")
|
||||
}
|
||||
|
||||
// HasServiceIdentity reports whether the service already holds an identity.
|
||||
func HasServiceIdentity(dataRoot, serviceName string) bool {
|
||||
_, err := os.Stat(ServiceCertPath(dataRoot, serviceName))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// LoadServiceMeta reads a service's identity issuance record without touching
|
||||
// the CA. It returns an error when the identity has not been issued yet.
|
||||
func LoadServiceMeta(dataRoot, serviceName string) (ServiceMeta, error) {
|
||||
return readMeta(ServiceDir(dataRoot, serviceName))
|
||||
}
|
||||
|
||||
// EnsureServiceIdentity loads the service's leaf identity, issuing a fresh one
|
||||
// when missing or expiring within the refresh window. Issuance is idempotent
|
||||
// and safe to call before every start/install.
|
||||
func EnsureServiceIdentity(ca *CA, serviceName string) (*Identity, error) {
|
||||
dataRoot := ca.DataRoot
|
||||
dir := ServiceDir(dataRoot, serviceName)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create identity dir for %s: %w", serviceName, err)
|
||||
}
|
||||
|
||||
certPath := ServiceCertPath(dataRoot, serviceName)
|
||||
keyPath := ServiceKeyPath(dataRoot, serviceName)
|
||||
|
||||
meta, metaErr := readMeta(dir)
|
||||
certData, certErr := os.ReadFile(certPath)
|
||||
keyData, keyErr := os.ReadFile(keyPath)
|
||||
if metaErr == nil && certErr == nil && keyErr == nil {
|
||||
cert, err := parseCertPEM(certData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse identity cert for %s: %w", serviceName, err)
|
||||
}
|
||||
key, err := parseKeyPEM(keyData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse identity key for %s: %w", serviceName, err)
|
||||
}
|
||||
// Refresh when the identity is expiring or already expired so
|
||||
// long-running installs never go dark (M1 §2: short-lived, refreshed).
|
||||
// meta.NotAfter is the issuance record; the cert's own NotAfter would
|
||||
// require a full parse+verify to be trustworthy, and the two can drift.
|
||||
if time.Until(meta.NotAfter) > 30*day {
|
||||
return &Identity{
|
||||
Service: serviceName,
|
||||
Cert: cert,
|
||||
Key: key,
|
||||
CertPath: certPath,
|
||||
KeyPath: keyPath,
|
||||
Meta: meta,
|
||||
}, nil
|
||||
}
|
||||
logRotate(serviceName)
|
||||
}
|
||||
|
||||
return issueIdentity(ca, serviceName)
|
||||
}
|
||||
|
||||
// issueIdentity generates a fresh keypair + CA-signed leaf and persists it.
|
||||
func issueIdentity(ca *CA, serviceName string) (*Identity, error) {
|
||||
key, err := newLeafKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serial, err := randomSerial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notBefore := now().Add(-5 * time.Minute)
|
||||
notAfter := now().Add(DefaultCertValidity * time.Second)
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkixName(serviceName),
|
||||
DNSNames: []string{serviceName},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, ca.Cert, &key.PublicKey, ca.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create identity cert for %s: %w", serviceName, err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse issued cert for %s: %w", serviceName, err)
|
||||
}
|
||||
|
||||
dir := ServiceDir(ca.DataRoot, serviceName)
|
||||
certPath := ServiceCertPath(ca.DataRoot, serviceName)
|
||||
keyPath := ServiceKeyPath(ca.DataRoot, serviceName)
|
||||
|
||||
meta := ServiceMeta{
|
||||
Service: serviceName,
|
||||
Serial: serial.Text(16),
|
||||
IssuedAt: notBefore,
|
||||
NotAfter: notAfter,
|
||||
Fingerprint: Fingerprint(cert),
|
||||
}
|
||||
metaData, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Key first so a crash mid-write never leaves a key without its cert
|
||||
// (the reverse would make the key useless but harmless; this ordering
|
||||
// keeps EnsureServiceIdentity self-healing in both cases).
|
||||
if err := writeFile(keyPath, encodeKeyPEM(key), 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write identity key for %s: %w", serviceName, err)
|
||||
}
|
||||
if err := writeFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
|
||||
return nil, fmt.Errorf("write identity cert for %s: %w", serviceName, err)
|
||||
}
|
||||
if err := writeFile(filepath.Join(dir, "meta.json"), metaData, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("write identity meta for %s: %w", serviceName, err)
|
||||
}
|
||||
|
||||
return &Identity{
|
||||
Service: serviceName,
|
||||
Cert: cert,
|
||||
Key: key,
|
||||
CertPath: certPath,
|
||||
KeyPath: keyPath,
|
||||
Meta: meta,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readMeta(dir string) (ServiceMeta, error) {
|
||||
var meta ServiceMeta
|
||||
data, err := os.ReadFile(filepath.Join(dir, "meta.json"))
|
||||
if err != nil {
|
||||
return meta, err
|
||||
}
|
||||
err = json.Unmarshal(data, &meta)
|
||||
return meta, err
|
||||
}
|
||||
|
||||
// logRotate is a tiny indirection so the issue path can log a rotation without
|
||||
// cluttering call sites.
|
||||
func logRotate(serviceName string) {
|
||||
logger.Info("pki: refreshing expiring identity for %s", serviceName)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package pki is Maestro's instance CA (security theme D1). It provisions the
|
||||
// root CA on first run, issues per-service identity certificates at install,
|
||||
// and revokes them at uninstall (CRL). The CA is the trust anchor for every
|
||||
// peer edge on dcntrlized_net: mTLS handshakes verify leaf certs against it,
|
||||
// and the auth service's key attestation is signed by it.
|
||||
package pki
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// File layout under <data root>/pki:
|
||||
//
|
||||
// ca.crt -- self-signed instance CA certificate (public)
|
||||
// ca.key -- CA private key (chmod 600, never leaves the host)
|
||||
// crl.pem -- current certificate revocation list
|
||||
// services/<name>/tls.crt -- service leaf certificate
|
||||
// services/<name>/tls.key -- service leaf private key
|
||||
// services/<name>/meta.json -- serial/validity/fingerprint record
|
||||
// revoked/<serial>.pem -- revoked certificates (CRL source)
|
||||
const (
|
||||
dirName = "pki"
|
||||
caCertFile = "ca.crt"
|
||||
caKeyFile = "ca.key"
|
||||
crlFile = "crl.pem"
|
||||
servicesDir = "services"
|
||||
revokedDir = "revoked"
|
||||
)
|
||||
|
||||
// DefaultCertValidity is how long issued identity certificates are valid.
|
||||
// Leaves are short-lived and refreshed on install/update (security theme M1).
|
||||
const DefaultCertValidity = 90 * 24 * 3600 // 90 days in seconds
|
||||
|
||||
// CA is the instance trust anchor: a self-signed root certificate plus its
|
||||
// private key. The private key lives only on the host (the maestro data dir).
|
||||
type CA struct {
|
||||
Cert *x509.Certificate
|
||||
PrivateKey *ecdsa.PrivateKey
|
||||
CertPEM []byte
|
||||
KeyPEM []byte
|
||||
DataRoot string
|
||||
}
|
||||
|
||||
// Dir returns the pki directory for the given data root.
|
||||
func Dir(dataRoot string) string {
|
||||
return filepath.Join(dataRoot, dirName)
|
||||
}
|
||||
|
||||
// CertPath returns the path of the CA certificate file.
|
||||
func CertPath(dataRoot string) string {
|
||||
return filepath.Join(Dir(dataRoot), caCertFile)
|
||||
}
|
||||
|
||||
// KeyPath returns the path of the CA private key file.
|
||||
func KeyPath(dataRoot string) string {
|
||||
return filepath.Join(Dir(dataRoot), caKeyFile)
|
||||
}
|
||||
|
||||
// CRLPath returns the path of the current CRL file.
|
||||
func CRLPath(dataRoot string) string {
|
||||
return filepath.Join(Dir(dataRoot), crlFile)
|
||||
}
|
||||
|
||||
// EnsureCA loads the instance CA, generating it on first run. Idempotent:
|
||||
// subsequent calls reuse the existing CA. The CA is provisioned from the same
|
||||
// infra-bootstrap path as the network and the shared DB.
|
||||
func EnsureCA(dataRoot string) (*CA, error) {
|
||||
dir := Dir(dataRoot)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create pki dir: %w", err)
|
||||
}
|
||||
|
||||
caPEM, err := os.ReadFile(CertPath(dataRoot))
|
||||
keyPEM, keyErr := os.ReadFile(KeyPath(dataRoot))
|
||||
if err == nil && keyErr == nil {
|
||||
ca, loadErr := loadCA(caPEM, keyPEM, dataRoot)
|
||||
if loadErr != nil {
|
||||
return nil, loadErr
|
||||
}
|
||||
return ca, nil
|
||||
}
|
||||
if (err == nil) != (keyErr == nil) {
|
||||
return nil, fmt.Errorf("CA state is inconsistent: only one of ca.crt/ca.key exists; restore from backup or delete the pki dir")
|
||||
}
|
||||
|
||||
ca, err := generateCA(dataRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ca, nil
|
||||
}
|
||||
|
||||
// generateCA creates a fresh self-signed root CA and persists it.
|
||||
func generateCA(dataRoot string) (*CA, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate CA key: %w", err)
|
||||
}
|
||||
|
||||
serial, err := randomSerial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkixName("Dcntrlized Instance CA"),
|
||||
NotBefore: now().Add(-5 * time.Minute),
|
||||
NotAfter: now().Add(10 * 365 * day),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLen: 1,
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create CA certificate: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CA certificate: %w", err)
|
||||
}
|
||||
|
||||
ca := &CA{
|
||||
Cert: cert,
|
||||
PrivateKey: key,
|
||||
CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
|
||||
KeyPEM: encodeKeyPEM(key),
|
||||
DataRoot: dataRoot,
|
||||
}
|
||||
|
||||
if err := writeFile(CertPath(dataRoot), ca.CertPEM, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("write CA certificate: %w", err)
|
||||
}
|
||||
if err := writeFile(KeyPath(dataRoot), ca.KeyPEM, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("write CA key: %w", err)
|
||||
}
|
||||
return ca, nil
|
||||
}
|
||||
|
||||
func loadCA(caPEM, keyPEM []byte, dataRoot string) (*CA, error) {
|
||||
cert, err := parseCertPEM(caPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CA certificate: %w", err)
|
||||
}
|
||||
key, err := parseKeyPEM(keyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CA key: %w", err)
|
||||
}
|
||||
return &CA{
|
||||
Cert: cert,
|
||||
PrivateKey: key,
|
||||
CertPEM: caPEM,
|
||||
KeyPEM: keyPEM,
|
||||
DataRoot: dataRoot,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package pki
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnsureCAIdempotent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
ca1, err := EnsureCA(root)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCA: %v", err)
|
||||
}
|
||||
if !ca1.Cert.IsCA {
|
||||
t.Fatal("root CA must have IsCA set")
|
||||
}
|
||||
if fi, err := os.Stat(KeyPath(root)); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("CA key should be chmod 600 (mode %v, err %v)", fi.Mode().Perm(), err)
|
||||
}
|
||||
|
||||
ca2, err := EnsureCA(root)
|
||||
if err != nil {
|
||||
t.Fatalf("second EnsureCA: %v", err)
|
||||
}
|
||||
if ca1.Cert.SerialNumber.Cmp(ca2.Cert.SerialNumber) != 0 {
|
||||
t.Fatal("EnsureCA must reuse the existing CA, not regenerate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceIdentityAndValidation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
ca, err := EnsureCA(root)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCA: %v", err)
|
||||
}
|
||||
|
||||
id, err := EnsureServiceIdentity(ca, "keepnote")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureServiceIdentity: %v", err)
|
||||
}
|
||||
|
||||
// Leaf is signed by the CA, carries the right name, and is a valid
|
||||
// server+client cert.
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca.Cert)
|
||||
if _, err := id.Cert.Verify(x509.VerifyOptions{
|
||||
DNSName: "keepnote",
|
||||
Roots: pool,
|
||||
KeyUsages: []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
x509.ExtKeyUsageClientAuth,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("leaf cert does not verify against CA: %v", err)
|
||||
}
|
||||
if id.Cert.Subject.CommonName != "keepnote" {
|
||||
t.Fatalf("leaf CN = %q, want keepnote", id.Cert.Subject.CommonName)
|
||||
}
|
||||
if id.Meta.Fingerprint == "" {
|
||||
t.Fatal("meta must record a fingerprint")
|
||||
}
|
||||
if time.Until(id.Cert.NotAfter) > DefaultCertValidity*time.Second {
|
||||
t.Fatal("leaf validity exceeds the configured default")
|
||||
}
|
||||
|
||||
// Reload path returns the same identity (no reissue).
|
||||
id2, err := EnsureServiceIdentity(ca, "keepnote")
|
||||
if err != nil {
|
||||
t.Fatalf("second EnsureServiceIdentity: %v", err)
|
||||
}
|
||||
if id2.Meta.Serial != id.Meta.Serial {
|
||||
t.Fatal("EnsureServiceIdentity must reuse an existing identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokePublishesCRLAndRemovesIdentity(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
ca, err := EnsureCA(root)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCA: %v", err)
|
||||
}
|
||||
|
||||
id, err := EnsureServiceIdentity(ca, "keepnote")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureServiceIdentity: %v", err)
|
||||
}
|
||||
|
||||
if err := Revoke(root, "keepnote"); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
|
||||
// The service identity is gone.
|
||||
if HasServiceIdentity(root, "keepnote") {
|
||||
t.Fatal("revoked service must not hold an identity anymore")
|
||||
}
|
||||
|
||||
// The CRL lists the revoked serial.
|
||||
serials, err := LoadRevokedSerials(root)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRevokedSerials: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, s := range serials {
|
||||
if s.Text(16) == id.Meta.Serial {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("revoked serial not present in CRL source")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(CRLPath(root)); err != nil {
|
||||
t.Fatalf("crl.pem not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureServiceIdentityRefreshesExpiring(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
ca, err := EnsureCA(root)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureCA: %v", err)
|
||||
}
|
||||
|
||||
id, err := EnsureServiceIdentity(ca, "keepnote")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureServiceIdentity: %v", err)
|
||||
}
|
||||
|
||||
// Backdate the cert so it is expiring within the refresh window.
|
||||
metaPath := filepath.Join(ServiceDir(root, "keepnote"), "meta.json")
|
||||
meta, err := readMeta(filepath.Join(ServiceDir(root, "keepnote")))
|
||||
if err != nil {
|
||||
t.Fatalf("readMeta: %v", err)
|
||||
}
|
||||
meta.NotAfter = now().Add(10 * 24 * time.Hour)
|
||||
if err := writeFile(metaPath, marshalOrDie(meta), 0o644); err != nil {
|
||||
t.Fatalf("write backdated meta: %v", err)
|
||||
}
|
||||
|
||||
id2, err := EnsureServiceIdentity(ca, "keepnote")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureServiceIdentity after backdate: %v", err)
|
||||
}
|
||||
if id2.Meta.Serial == id.Meta.Serial {
|
||||
t.Fatal("expiring identity must be rotated to a new serial")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user