From 5f024c8caaf84cb634644a02cd7e115975149278 Mon Sep 17 00:00:00 2001 From: Conor Patrick Date: Tue, 21 Oct 2025 13:01:56 -0400 Subject: [PATCH 1/3] changes to CLI to help with more manual activation setups --- CLAUDE.md | 222 +++++++++++++++++++++++++++++ cmd/panel/main.go | 192 +++++++++++++++++++++++-- web/components/ActivationPanel.tsx | 34 +++-- web/components/ActivationTab.tsx | 35 ++++- 4 files changed, 462 insertions(+), 21 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f62e99b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,222 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the **Panel** server and VM infrastructure for deploying Treasury nodes. It provides a "1-click" deployment solution that handles initial Treasury installation, pairing with other nodes, and backup/restore procedures through a built-in UI. The system is designed to run on VMs in cloud environments (AWS, GCP) with no SSH access or manual configuration required. + +The project consists of: + +- **Go-based Panel Server**: HTTP API server that manages Treasury lifecycle +- **Next.js Web UI**: Static single-page application for activation and management +- **Bootable Container Image**: VM images built as bootable containers for various cloud platforms + +## Development Commands + +### Go Backend + +```bash +# Run panel server in development mode +just dev + +# Install panel binary +just install + +# Run tests +just test + +# Run linter and formatter +just lint + +# Tidy Go modules (GOWORK=off for this repo) +just tidy +``` + +### Web Frontend (Next.js) + +```bash +cd web + +# Install dependencies +pnpm install + +# Development server (hot reload on http://localhost:3000) +pnpm run dev + +# Build static export +pnpm run build + +# Run tests +pnpm test + +# Run tests in watch mode +pnpm run test:watch +``` + +### Testing Individual Components + +```bash +# Run a specific Go test +GOWORK=off go test -v ./pkg/bak/... + +# Run a single test function +GOWORK=off go test -v -run TestSpecificFunction ./server/panel/... +``` + +### Docker/VM Development + +```bash +# Build dev container image +just build-dev + +# Run first dev VM instance +just run-dev-1 + +# Run second dev VM instance (for testing multi-node setups) +just run-dev-2 + +# Execute into running container +just exec-1 # or just exec-2 + +# Run panel server inside container +just dev-container n="1" +``` + +## Architecture Overview + +### Panel Server (Go) + +The Panel server is a Fiber-based HTTP API server that orchestrates the Treasury deployment lifecycle: + +**Key Components:** + +- `cmd/panel/main.go`: CLI entrypoint with cobra commands (`start`, `activate`, `reset`, etc.) +- `server/server.go`: Fiber app setup and server initialization +- `server/endpoints/`: API endpoint handlers organized by domain (activate, treasury, backup, service, etc.) +- `server/panel/`: Panel state management and persistence +- `pkg/`: Reusable packages for admin client, secrets, paths, genesis, etc. + +**Activation Flow:** + +1. API key activation (`/v1/activate/api-key`) +2. Binary installation (`/v1/activate/binaries`) +3. Network setup via Netbird (`/v1/activate/network`) +4. Backup key configuration (`/v1/activate/backup`) +5. Treasury generation and peer synchronization + +**Service Management:** +The panel manages systemd services (`treasury.service`, `start-treasury.service`) and can start/stop/restart them via the `/v1/services/*` endpoints. + +**Backup System:** +Uses age encryption with BIP39 mnemonic-based keys. Backups are stored in S3-compatible storage and can be restored through the UI. + +### Web Frontend (Next.js + TypeScript) + +The web UI is a static Next.js application that exports to `web/out/` and is served by the Go server at the root path. + +**Key Components:** + +- `pages/index.tsx`: Main entry point with tab-based UI +- `components/`: React components for each major feature + - `ActivationPanel.tsx`: Step-by-step activation wizard + - `BackupRestoreTab.tsx`: Backup management and restore + - `StatusTab.tsx`: Treasury health and service status + - `EncryptionAtRestTab.tsx`: Encryption-at-rest configuration + - `InitialUsersTab.tsx`: User management + - `TreasuryLogsTab.tsx`: Real-time logs viewer + +**API Communication:** +The frontend calls the Panel server API at `NEXT_PUBLIC_API_HOST` (defaults to same host). During development, set this in `.env.local` to point to `http://localhost:7666` while running the Next.js dev server on port 3000. + +**Static Export:** +The app uses `output: 'export'` in `next.config.js` to generate static HTML/JS/CSS files that are served by the Go server. + +### State Management + +**Panel State** (`/etc/panel/panel.json`): + +- API key and node configuration +- Treasury ID and node ID (assigned during activation) +- Backup keys (age public keys) +- Encryption-at-rest secret reference +- Connector and API node flags + +### VM and Container Architecture + +The project builds bootable container images using [bootc](https://docs.fedoraproject.org/en-US/bootc/): + +1. Base image built with platform-specific config (AWS/GCP) +2. Panel binary and web UI embedded in the image +3. Systemd services configured for automatic startup +4. Container converted to VM image using `bootc-image-builder` +5. VM image published to cloud provider marketplaces + +**Build Process:** + +```bash +# Build for AWS +BASE=aws docker buildx bake + +# Build for GCP +BASE=gcp docker buildx bake +``` + +The VM runs the panel server on port 7666 and the user accesses it through a port forward for initial activation. + +### GOWORK Environment + +This repository uses `GOWORK=off` for all Go commands because the Panel is intended to eventually be split into a separate repository from the main Treasury codebase. + +## API Endpoint Structure + +All activation endpoints follow a POST-based flow: + +- `/v1/activate/api-key` - Activates API key and fetches node assignment +- `/v1/activate/binaries` - Downloads Treasury, Signer, and Cord binaries +- `/v1/activate/network` - Joins Netbird network for peer connectivity +- `/v1/activate/backup` - Configures backup keys +- `/v1/activate/otel` - Configures observability collection + +Treasury management endpoints: + +- `POST /v1/treasury` - Generate new Treasury node +- `POST /v1/treasury/complete` - Complete Treasury setup after peers are synced +- `POST /v1/treasury/image` - Use specific Treasury container image +- `DELETE /v1/treasury` - Delete Treasury node (with confirmation) +- `GET /v1/treasury/healthy` - Health check with optional verbose output + +Service control endpoints: + +- `GET /v1/services` - List all systemd services +- `POST /v1/services/{service}/{action}` - Perform action (start/stop/restart/enable/disable) + +Backup endpoints: + +- `POST /v1/backup/snapshot` - Create snapshot backup +- `POST /v1/backup/restore` - Restore from snapshot +- `GET /v1/backup/list` - List available backups + +## Testing Strategy + +**Go Tests:** + +- Unit tests in `*_test.go` files +- Use testify for assertions +- Mock systemd interactions where needed +- Test secret loading with different providers + +**Frontend Tests:** + +- Vitest for component tests +- React Testing Library for component interaction +- `vitest.setup.ts` configures jsdom environment + +## Common Gotchas + +- Always use `GOWORK=off` when running Go commands +- The panel server serves static files from `web/out/` - rebuild the frontend when making UI changes +- The `--treasury-user` flag defaults to `cordial` - this is the Linux user that runs Treasury +- Binary downloads are verified with Sigstore cosign signatures +- Multi-node activation requires all nodes to exchange peer information before completing +- The `start-treasury.service` keeps retrying if peer information isn't ready yet diff --git a/cmd/panel/main.go b/cmd/panel/main.go index 1f80674..dbaaac7 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -85,7 +85,7 @@ func StartCmd() *cobra.Command { return cmd } -func ActivateCmd() *cobra.Command { +func ActivateAllCmd() *cobra.Command { var apiKeyRef string var connector bool // var apiNode bool @@ -94,9 +94,11 @@ func ActivateCmd() *cobra.Command { var version string var noOtel bool + var skipNetwork bool + var cmd = &cobra.Command{ - Use: "activate", - Short: "Activate the panel server", + Use: "all", + Short: "Perform full activation (api-key, backup, binaries, network, treasury)", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -210,12 +212,16 @@ func ActivateCmd() *cobra.Command { fmt.Println("Binaries installed.") // 4. activate the network - fmt.Println("Activating network...") - err = panelClient.ActivateNetwork() - if err != nil { - return err + if !skipNetwork { + fmt.Println("Activating network...") + err = panelClient.ActivateNetwork() + if err != nil { + return err + } + fmt.Println("Network activated.") + } else { + fmt.Println("Network skipped.") } - fmt.Println("Network activated.") treasuryService, err := panelClient.GetService("treasury.service") if err != nil { @@ -270,6 +276,176 @@ func ActivateCmd() *cobra.Command { cmd.Flags().StringSliceVar(&baks, "bak", []string{}, "Backup key(s)") cmd.Flags().StringVar(&version, "version", "latest", "Version of production binaries to install") cmd.Flags().BoolVar(&noOtel, "no-otel", false, "Disable OTEL collection") + cmd.Flags().BoolVar(&skipNetwork, "skip-network", false, "Skip network setup") + return cmd +} + +func ActivateApiKeyCmd() *cobra.Command { + var apiKeyRef string + var connector bool + var remote string + + var cmd = &cobra.Command{ + Use: "api-key", + Short: "Activate the API key", + SilenceUsage: true, + + RunE: func(cmd *cobra.Command, args []string) error { + var apiKey string + var err error + + if apiKeyRef == "" { + var input string + for input == "" { + fmt.Print("Enter Activation API key: ") + fmt.Scanln(&input) + input = strings.TrimSpace(input) + apiKey = input + } + } else { + secretMaybe := secret.Secret(apiKeyRef) + if _, ok := secretMaybe.Type(); !ok { + // treat as literal + apiKey = apiKeyRef + } else { + apiKey, err = secretMaybe.Load() + } + if err != nil { + return fmt.Errorf("failed to load API key: %v", err) + } + if apiKey == "" { + return fmt.Errorf("API key reference resolved to an empty value") + } + } + + // activate the API key + fmt.Println("Activating API key...") + var connectorInput *bool + if cmd.Flags().Lookup("connector").Changed { + // only pass if specified on CLI, so panel will otherwise default to the admin API. + connectorInput = &connector + } + err = panelClient.ActivateApiKey(apiKey, connectorInput) + if err != nil { + return err + } + fmt.Println("API key activated.") + + return nil + }, + } + + cmd.Flags().StringVar(&apiKeyRef, "api-key", "", "API key secret reference") + cmd.Flags().StringVar(&remote, "url", "http://localhost:7666", "URL of the panel server") + cmd.Flags().BoolVar(&connector, "connector", false, "Enable connector") + return cmd +} + +func ActivateBinariesCmd() *cobra.Command { + var remote string + var version string + + var cmd = &cobra.Command{ + Use: "binaries", + Short: "Download and install Treasury binaries", + SilenceUsage: true, + + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("Installing production binaries...") + err := panelClient.ActivateBinaries(client.ActivateBinariesOptions{ + Version: version, + }) + if err != nil { + return err + } + fmt.Println("Binaries installed.") + + return nil + }, + } + + cmd.Flags().StringVar(&remote, "url", "http://localhost:7666", "URL of the panel server") + cmd.Flags().StringVar(&version, "version", "latest", "Version of production binaries to install") + return cmd +} + +func ActivateBakCmd() *cobra.Command { + var remote string + var baks []string + + var cmd = &cobra.Command{ + Use: "bak", + Short: "Configure backup keys", + SilenceUsage: true, + + RunE: func(cmd *cobra.Command, args []string) error { + panelInfo, err := panelClient.GetPanel() + if err != nil { + return err + } + + if len(panelInfo.Baks) == 0 { + if len(baks) <= 0 { + sk := bak.GenerateEncryptionKey() + recipient := sk.Recipient() + + fmt.Println("# Generating new backup key...") + fmt.Println("# You must save this somewhere safe") + fmt.Println("------- SECRET BACKUP PHRASE -------") + fmt.Println(strings.Join(sk.Words(), " ")) + fmt.Println("------------------------------------") + fmt.Println("Public key:", recipient.String()) + fmt.Println() + fmt.Print("Confirm (y/n): ") + var confirm string + fmt.Scanln(&confirm) + if strings.ToLower(confirm) != "y" { + return fmt.Errorf("cancelled") + } + fmt.Println() + baks = append(baks, recipient.String()) + } + fmt.Println("Activating backup...") + bakObjs := make([]panel.Bak, len(baks)) + for i := range baks { + bakObjs[i] = panel.Bak{ + Key: baks[i], + } + } + err = panelClient.ActivateBackup(bakObjs) + if err != nil { + return err + } + fmt.Println("Backup activated.") + } else { + fmt.Println("Backup already activated.") + baks := []string{} + for _, bak := range panelInfo.Baks { + baks = append(baks, bak.Key) + } + fmt.Println("Backup keys: ", strings.Join(baks, ", ")) + fmt.Println("To reset, run: `panel reset --force` and re-run activation.") + } + + return nil + }, + } + + cmd.Flags().StringVar(&remote, "url", "http://localhost:7666", "URL of the panel server") + cmd.Flags().StringSliceVar(&baks, "bak", []string{}, "Backup key(s)") + return cmd +} + +func ActivateCmd() *cobra.Command { + var cmd = &cobra.Command{ + Use: "activate", + Short: "Activation commands for the panel server", + } + + cmd.AddCommand(ActivateAllCmd()) + cmd.AddCommand(ActivateApiKeyCmd()) + cmd.AddCommand(ActivateBinariesCmd()) + cmd.AddCommand(ActivateBakCmd()) return cmd } diff --git a/web/components/ActivationPanel.tsx b/web/components/ActivationPanel.tsx index 65b9eff..bcd86d1 100644 --- a/web/components/ActivationPanel.tsx +++ b/web/components/ActivationPanel.tsx @@ -187,7 +187,8 @@ export default function ActivationPanel() { otelEnabled: boolean, binaryVersion: string, useDemoPolicy: boolean, - hasOpenImportForms: boolean + hasOpenImportForms: boolean, + skipNetwork: boolean ) => { // Check for open import forms first if (hasOpenImportForms) { @@ -229,7 +230,8 @@ export default function ActivationPanel() { apiKey, generatedBackupKeys, otelEnabled, - binaryVersion + binaryVersion, + skipNetwork ); }; @@ -241,7 +243,8 @@ export default function ActivationPanel() { nickname: string; }[], otelEnabled: boolean, - binaryVersion: string + binaryVersion: string, + skipNetwork: boolean ) => { setAutoRunning(true); setLoading(true); @@ -271,15 +274,24 @@ export default function ActivationPanel() { await new Promise((resolve) => setTimeout(resolve, 500)); - // Step 3: Network Setup - setCurrentAction("Setting up network connection..."); - await panelApiClient.setupNetwork(); - setStatus({ - type: "success", - message: "Network configured successfully", - }); + // Step 3: Network Setup (skip if requested) + if (!skipNetwork) { + setCurrentAction("Setting up network connection..."); + await panelApiClient.setupNetwork(); + setStatus({ + type: "success", + message: "Network configured successfully", + }); - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); + } else { + setStatus({ + type: "success", + message: "Network setup skipped", + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + } const backupKeysLocked = panelInfo?.state !== "inactive"; diff --git a/web/components/ActivationTab.tsx b/web/components/ActivationTab.tsx index a4026f9..d66bf11 100644 --- a/web/components/ActivationTab.tsx +++ b/web/components/ActivationTab.tsx @@ -26,7 +26,8 @@ interface Props { otelEnabled: boolean, binaryVersion: string, useDemoPolicy: boolean, - hasOpenImportForms: boolean + hasOpenImportForms: boolean, + skipNetwork: boolean ) => Promise; } @@ -52,6 +53,7 @@ export default function ActivationTab({ const [showEncryptionAtRest, setShowEncryptionAtRest] = useState(false); const [showInitialUsers, setShowInitialUsers] = useState(true); const [showApiKey, setShowApiKey] = useState(false); + const [skipNetwork, setSkipNetwork] = useState(false); const handleActivation = async () => { await handleApiKeyActivation( @@ -61,7 +63,8 @@ export default function ActivationTab({ otelEnabled, binaryVersion, useDemoPolicy, - hasOpenImportForms + hasOpenImportForms, + skipNetwork ); }; @@ -267,6 +270,34 @@ export default function ActivationTab({ + +
+
+ setSkipNetwork(e.target.checked)} + disabled={autoRunning} + style={{ marginRight: "8px" }} + /> + +
+
)} From 40230961aedfd7eb309e9da5a3dd418caa131b9a Mon Sep 17 00:00:00 2001 From: Conor Patrick Date: Tue, 21 Oct 2025 14:42:31 -0400 Subject: [PATCH 2/3] add wireguard-tools --- container.vm.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/container.vm.Dockerfile b/container.vm.Dockerfile index e65a62c..5b40dd3 100644 --- a/container.vm.Dockerfile +++ b/container.vm.Dockerfile @@ -42,7 +42,7 @@ RUN chown -R otelcol-contrib /var/home/otelcol-contrib # packages we like (note: no neovim as it pulls in like 500MB) run dnf -y install epel-release -RUN dnf -y install zsh git htop tmux fd-find bat ripgrep wget jq util-linux-user +RUN dnf -y install zsh git htop tmux fd-find bat ripgrep wget jq util-linux-user wireguard-tools RUN dnf clean all # use /var/bin for dynamically downloaded prod binaries (e.g. /usr/bin is immutable) From 14a6bd50735999a32ec0a3d2a70415aafba09adc Mon Sep 17 00:00:00 2001 From: Conor Patrick Date: Thu, 6 Nov 2025 09:38:06 -0500 Subject: [PATCH 3/3] include wireguard interface in firewall rules --- infra/usr/units/treasury-firewall.sh | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/infra/usr/units/treasury-firewall.sh b/infra/usr/units/treasury-firewall.sh index 894a662..e207459 100755 --- a/infra/usr/units/treasury-firewall.sh +++ b/infra/usr/units/treasury-firewall.sh @@ -9,6 +9,7 @@ PORTS="26656,26657,1317,7867,7666" iptables -D DOCKER-USER -i lo -p tcp --match multiport --dport $PORTS -j ACCEPT || true iptables -D DOCKER-USER -i wt0 -p tcp --match multiport --dports $PORTS -j ACCEPT || true iptables -D DOCKER-USER -i docker0 -p tcp --match multiport --dports $PORTS -j ACCEPT || true +iptables -D DOCKER-USER -i wg0 -p tcp --match multiport --dports $PORTS -j ACCEPT || true iptables -D DOCKER-USER -p tcp --match multiport --dport $PORTS -j DROP || true @@ -23,6 +24,8 @@ if [[ $1 = "add" ]]; then iptables -I DOCKER-USER -i wt0 -p tcp --match multiport --dports $PORTS -j ACCEPT # permit from docker0 interface iptables -I DOCKER-USER -i docker0 -p tcp --match multiport --dports $PORTS -j ACCEPT + # permit from wireguard interface + iptables -I DOCKER-USER -i wg0 -p tcp --match multiport --dports $PORTS -j ACCEPT # deny all else iptables -A DOCKER-USER -p tcp --match multiport --dport $PORTS -j DROP @@ -31,9 +34,3 @@ else echo "Removed treasury firewall rules" fi - - - - - -