Skip to content
Merged
111 changes: 103 additions & 8 deletions cmd/control/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ type Config struct {
ValkeyPassword string
ValkeyDB int

// rc11 #77: derived-projection reconciler for system actions.
// Interval is the period between full SyncAllUsersSystemActions
// sweeps (safety net for the post-commit listener); 0 disables
// the periodic goroutine entirely. Timeout is the per-sweep
// context deadline so a hung query can't pile up missed ticks.
SystemActionReconcileInterval time.Duration
SystemActionReconcileTimeout time.Duration
}

func main() {
Expand Down Expand Up @@ -345,11 +352,46 @@ func main() {
logger.Error("failed to reconcile system roles", "error", err)
}

// Sync system actions for all users at startup (idempotent)
// rc11 #77: derived-projection wiring for system actions.
//
// 1) One-shot startup sweep — guarantees idempotent convergence
// on every boot, deploy, or upgrade. Logged at Info because it
// runs once.
// 2) Post-commit event listener — fires SyncUserSystemActions
// (or SyncAllUsersSystemActions for fan-out events) on every
// permission-shaping event, so handler tests don't need to
// know about system actions.
// 3) Periodic reconciler — durability safety net for the listener,
// catches any event whose effect on system actions the
// listener doesn't yet know about. Default 1m.
if svc.SystemActions() != nil {
// (1) Startup sweep — keeps the existing Info line so
// operators see the one-shot convergence in boot logs.
if err := svc.SystemActions().SyncAllUsersSystemActions(ctx); err != nil {
logger.Error("failed to sync system actions at startup", "error", err)
} else {
logger.Info("system actions synced for all users (startup)")
}

// (2) Listener — registered post-commit on the store. Logged
// errors are swallowed; the periodic reconciler is the
// durability safety net. Reuse the same per-sweep timeout
// as the reconciler so a wedged DB / signer can't leak a
// goroutine indefinitely (#77 review round 2).
st.RegisterEventListener(api.SystemActionListener(
svc.SystemActions(),
logger.With("component", "system_action_listener"),
cfg.SystemActionReconcileTimeout,
))

// (3) Periodic reconciler — interval and per-sweep timeout
// from config (defaults set in parseFlags).
svc.SystemActions().StartReconciliation(ctx,
cfg.SystemActionReconcileInterval,
cfg.SystemActionReconcileTimeout)
logger.Info("system-action reconciliation started",
"interval", cfg.SystemActionReconcileInterval,
"sweep_timeout", cfg.SystemActionReconcileTimeout)
}
// Configure trusted proxies for X-Forwarded-For header validation
if len(cfg.TrustedProxies) > 0 {
Expand Down Expand Up @@ -470,19 +512,42 @@ func main() {

// Index audit events on insertion — the hook fires after every AppendEvent
// and enqueues the persisted row directly (no DB lookup in the search worker).
st.OnEventAppended = func(ctx context.Context, ev store.PersistedEvent) {
// Registered via RegisterEventListener so it shares the listener-slice
// mutex + panic-recovery wrapper with every other consumer.
//
// The EnqueueReindex call itself is dispatched in a goroutine so a slow
// or unreachable Valkey cannot stall AppendEvent — fireListeners
// dispatches synchronously, so a blocking listener body would extend
// every state-changing RPC's tail latency by the Valkey RTT. The work
// is best-effort (already only logs Warn on failure), so detaching is
// safe; the goroutine has its own recover so a panic inside the
// taskqueue client can't crash the server. Round-5 review fix.
st.RegisterEventListener(func(ctx context.Context, ev store.PersistedEvent) {
id := ulid.ULID(ev.ID).String()
if err := searchIdx.EnqueueReindex(ctx, search.ScopeAuditEvent, id, &taskqueue.SearchEntityData{
data := &taskqueue.SearchEntityData{
EventType: ev.EventType,
StreamType: ev.StreamType,
ActorType: ev.ActorType,
ActorID: ev.ActorID,
StreamID: ev.StreamID,
OccurredAt: ev.OccurredAt.Unix(),
}); err != nil {
logger.Warn("failed to enqueue audit event reindex", "id", id, "error", err)
}
}
// Detach from the AppendEvent ctx — the RPC may already have
// returned by the time the enqueue runs; cancellation would
// drop best-effort work that the search worker can otherwise
// still pick up. Background ctx is correct here because the
// taskqueue client has its own per-call timeouts.
go func() {
defer func() {
if r := recover(); r != nil {
logger.Error("audit-index listener: panicked", "id", id, "panic", r)
}
}()
if err := searchIdx.EnqueueReindex(context.Background(), search.ScopeAuditEvent, id, data); err != nil {
logger.Warn("failed to enqueue audit event reindex", "id", id, "error", err)
}
}()
})

// Ensure indexes exist (idempotent, needed for FT.SEARCH queries).
if err := searchIdx.EnsureIndexes(ctx); err != nil {
Expand Down Expand Up @@ -584,8 +649,10 @@ func main() {
path, handler := pmv1connect.NewControlServiceHandler(svc, interceptors)
mux.Handle(path, handler)

// Mount SCIM v2 handler
scimHandler := scim.NewHandler(st, logger)
// Mount SCIM v2 handler. Passes svc.SystemActions() so the SCIM
// delete path can clean up pm-tty-* / USER actions when the
// last identity link is removed (rc11 #77).
scimHandler := scim.NewHandler(st, logger, svc.SystemActions())
mux.Handle("/scim/v2/", scimHandler)

// Wrap with CORS and security headers middleware
Expand Down Expand Up @@ -732,6 +799,11 @@ func parseFlags() *Config {
flag.StringVar(&cfg.GatewayURL, "gateway-url", "", "Gateway URL returned to agents during registration")
flag.StringVar(&cfg.TerminalGatewayURL, "terminal-gateway-url", "", "Public WebSocket URL of the gateway terminal endpoint (e.g. wss://gw.example.com/terminal). When empty, ControlService.StartTerminal returns CodeUnavailable.")
flag.DurationVar(&cfg.DynamicGroupEvalInterval, "dynamic-group-eval-interval", time.Hour, "Interval for evaluating dynamic groups (min 30m, max 8h, 0 to disable)")
// rc11 #77 — system-action reconciliation defaults: 1m interval keeps drift bounded for an
// operator-visible UX path (role grant → terminal works), 5m sweep timeout is plenty for
// even a 10k-user fleet because the sync is read-heavy and short-circuits on no-op users.
flag.DurationVar(&cfg.SystemActionReconcileInterval, "system-action-reconcile-interval", time.Minute, "Period between full SyncAllUsersSystemActions sweeps; 0 disables periodic reconciliation")
flag.DurationVar(&cfg.SystemActionReconcileTimeout, "system-action-reconcile-timeout", 5*time.Minute, "Per-sweep context deadline for the periodic reconciler")
flag.StringVar(&cfg.CATrustBundlePath, "ca-trust-bundle", "", "PEM file with trusted CA certificates for verification (supports CA rotation)")
flag.BoolVar(&cfg.TLSEnabled, "tls", false, "Enable TLS on public listener")
flag.StringVar(&cfg.TLSCert, "tls-cert", "", "TLS certificate for public listener")
Expand Down Expand Up @@ -763,6 +835,8 @@ func parseFlags() *Config {
envString(&cfg.TerminalGatewayURL, "CONTROL_TERMINAL_GATEWAY_URL")
envCSV(&cfg.CORSOrigins, "CONTROL_CORS_ORIGINS")
envDuration(&cfg.DynamicGroupEvalInterval, "CONTROL_DYNAMIC_GROUP_EVAL_INTERVAL")
envDuration(&cfg.SystemActionReconcileInterval, "CONTROL_SYSTEM_ACTION_RECONCILE_INTERVAL")
envDuration(&cfg.SystemActionReconcileTimeout, "CONTROL_SYSTEM_ACTION_RECONCILE_TIMEOUT")

// SSO / Identity Provider configuration
cfg.PasswordAuthEnabled = true // default enabled
Expand All @@ -789,6 +863,27 @@ func parseFlags() *Config {
}
}

// Clamp system-action reconcile flags. Mirrors the DynamicGroup
// pattern above. A 0 sweep timeout would make
// context.WithTimeout return an already-cancelled context every
// tick, silently breaking the durability safety net; a negative
// interval would panic time.NewTicker. Round-3 review of rc11
// #77 caught the timeout footgun specifically; round-5 review
// added the floor/ceiling on the interval so a misconfigured
// 1ms tick can't flood the DB with sweep attempts.
if cfg.SystemActionReconcileInterval < 0 {
cfg.SystemActionReconcileInterval = 0 // treat as disabled, matching StartReconciliation
} else if cfg.SystemActionReconcileInterval > 0 {
if cfg.SystemActionReconcileInterval < 10*time.Second {
cfg.SystemActionReconcileInterval = 10 * time.Second
} else if cfg.SystemActionReconcileInterval > 8*time.Hour {
cfg.SystemActionReconcileInterval = 8 * time.Hour
}
}
if cfg.SystemActionReconcileTimeout <= 0 {
cfg.SystemActionReconcileTimeout = 5 * time.Minute
}

if cfg.JWTSecret == "" {
fmt.Fprintln(os.Stderr, "FATAL: CONTROL_JWT_SECRET (or -jwt-secret) is required")
os.Exit(1)
Expand Down
13 changes: 9 additions & 4 deletions cmd/gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,15 @@ func main() {
slog.SetDefault(logger)

// Config-shape checks that don't fit the simple "required env
// var empty" pattern below (TTY/MTLS host collision, etc.).
// Failing here keeps them visible at startup rather than at the
// first affected request.
if err := cfg.Validate(); err != nil {
// var empty" pattern below (TTY/MTLS host collision, partial
// terminal config, etc.). Failing here keeps fatal issues
// visible at startup; warnings surface partial misconfigurations
// that would otherwise produce silent runtime failures.
warnings, err := cfg.Validate()
for _, w := range warnings {
logger.Warn("gateway configuration warning", "warning", w)
}
if err != nil {
logger.Error("invalid gateway configuration", "error", err)
os.Exit(1)
}
Expand Down
16 changes: 16 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,22 @@ LOG_LEVEL=info
# Min: 30m, Max: 8h, 0 to disable. Default: 1h
# DYNAMIC_GROUP_EVAL_INTERVAL=1h

# --- System Action Reconciliation (rc11 #77) --------------------------------
# Period between full system-action reconciliation sweeps. The control
# server runs an event-driven listener for sub-second convergence after
# permission-shaping mutations; this periodic sweep is the durability
# safety net for any event the listener doesn't yet classify, and for
# the rare crash-between-commit-and-listener case. 1m default keeps
# drift bounded for the operator-visible "grant role → terminal works"
# UX. Set to 0 to disable the safety net entirely (NOT recommended).
# CONTROL_SYSTEM_ACTION_RECONCILE_INTERVAL=1m

# Per-sweep context deadline. A hung query gets cancelled after this
# rather than piling up missed ticks behind it. 5m is plenty for a
# 10k-user fleet because SyncAllUsersSystemActions is read-heavy and
# short-circuits on no-op users.
# CONTROL_SYSTEM_ACTION_RECONCILE_TIMEOUT=5m

# --- Network ----------------------------------------------------------------
# Comma-separated trusted proxy IPs/CIDRs for correct client IP parsing.
# Set this when running behind a reverse proxy (Traefik is added automatically).
Expand Down
81 changes: 81 additions & 0 deletions deploy/QUICKSTART.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Power Manage Server — Quickstart

One-line install on a fresh Linux host with Docker + the compose plugin already installed:

```bash
curl -fsSL https://raw.githubusercontent.com/manchtools/power-manage-server/main/deploy/install.sh | sudo bash
```

The installer:
1. Verifies `docker` + `docker compose` are present (does **not** install them — you own that step).
2. Downloads the `deploy/` tree from the latest pre-release tag.
3. Copies `.env.example` to `.env` if missing.
4. Runs `setup.sh` in **guided mode** — interactive prompts for domains, ACME email, and admin credentials; auto-generates strong defaults for every secret (Postgres / indexer / Valkey passwords, JWT secret, encryption key).
5. Pulls the Power Manage container images.
6. Brings the stack up via `docker compose up -d`.
7. Prints the URLs and next steps.

## Pinning a specific version

The installer defaults to `latest-rc` (the curated pre-release tag). For a stable release or a specific RC:

```bash
curl -fsSL .../install.sh | sudo RELEASE_TAG=v2026.05 bash
```

> **Note:** the env var goes between `sudo` and `bash`, **not** before `curl`. By default `sudo` resets the environment (`env_reset`), so `RELEASE_TAG=… curl … | sudo bash` would only set the variable for the local `curl` process and the installer running under `sudo` would never see it. Putting it after `sudo` passes it through.

## Non-interactive install

CI / Ansible / preconfigured `.env` setups can skip the guided prompts:

```bash
curl -fsSL .../install.sh | sudo NO_PROMPT=1 bash
```

The installer expects `.env` (or `.env.example` to copy from) at `INSTALL_DIR` and runs `setup.sh --no-prompt` — strict env validation only, no prompts.

## Custom install directory

```bash
curl -fsSL .../install.sh | sudo INSTALL_DIR=/srv/pm bash
```

## Re-running the installer

`install.sh` is idempotent. Re-running on an existing install:
- Preserves `.env` (your secrets stay intact).
- Pulls the chosen `RELEASE_TAG` images.
- Restarts the stack.

Use this for upgrades:

```bash
sudo RELEASE_TAG=v2026.06 bash /opt/power-manage/install.sh # if previously installed
# or fetch a fresh installer
curl -fsSL .../install.sh | sudo RELEASE_TAG=v2026.06 bash
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Manual install (if you'd rather not run a curl-pipe-bash)

```bash
git clone https://github.com/manchtools/power-manage-server.git
cd power-manage-server/deploy
cp .env.example .env
./setup.sh # guided env + cert generation
docker compose up -d
```

## What the installer does NOT do

- **Install Docker.** Use [the official convenience script](https://docs.docker.com/engine/install/) or your distro's package manager first.
- **Configure DNS.** You need A/AAAA records pointing your `CONTROL_DOMAIN`, `GATEWAY_DOMAIN`, and (if terminals are enabled) `GATEWAY_TTY_DOMAIN` at this host.
- **Open firewall ports.** Traefik binds `:80` (LE http-01 challenge + redirect-to-https) and `:443` (everything else) on the host. Open those before the first start so Let's Encrypt can issue certificates.
- **Migrate from a pre-rc11 deploy.** This installer is for fresh installs and same-release upgrades. Migrating across breaking releases follows the per-release migration runbook.

## After install

1. Wait ~30 s for Let's Encrypt to issue certs on first run.
2. Log in to `https://<CONTROL_DOMAIN>` with the bootstrap admin credentials the installer printed.
3. Create real user accounts (UI, SSO, or SCIM) — the bootstrap admin is intentionally not for daily use; see [`.env.example`](./.env.example) for details.
4. Generate a registration token and enroll your first agent.
Loading
Loading