// 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 . // Package authmgmt is Maestro's client for the shared auth service's // management endpoints (security theme M2 §3). Maestro is only the CA and the // UI here: it lists pending first-contact requests, forwards the owner's // approve/revoke decisions to the auth service, and reads its key // attestation. Enforcement always stays inside the auth service (D5). package authmgmt import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/json" "fmt" "net/http" "os" "path/filepath" "time" "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" // 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. 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. type Attestation struct { PublicKey string `json:"public_key"` Fingerprint string `json:"fingerprint"` Kid string `json:"kid"` 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). func New() (*Client, error) { transport, err := maestroTransport() if err != nil { return nil, err } baseURL := "" if env.IsDev() { baseURL = os.Getenv("DCNTRLIZED_AUTH_URL") } if baseURL == "" { baseURL = os.Getenv("AUTH_SERVICE_URL") } if baseURL == "" { baseURL = AuthServiceURL } return &Client{ baseURL: baseURL, http: &http.Client{ Timeout: 10 * time.Second, Transport: transport, }, }, 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. func Scheme() string { if env.IsDev() { return "http" } return "https" } // 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 } 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 } return &state, nil } // Attestation returns the auth service's public key statement. func (c *Client) Attestation(ctx context.Context) (*Attestation, error) { var att Attestation if err := c.do(ctx, http.MethodGet, "/internal/attestation", nil, &att); err != nil { return nil, err } 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 { payload, _ = json.Marshal(body) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(payload)) if err != nil { return err } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("auth service %s %s: %w", method, path, err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("auth service %s %s: status %d", method, path, resp.StatusCode) } if out != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("decode auth service response: %w", 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) } }