295 lines
9.7 KiB
Go
295 lines
9.7 KiB
Go
// 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 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)
|
|
}
|
|
}
|