diff --git a/.env.example b/.env.example index 70b8bbdd..84ed8843 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,13 @@ DATABASE_URL=sqlite:///serverkit.db # CORS Origins - your domain(s), comma-separated CORS_ORIGINS=http://localhost,https://your-domain.com +# Public URL that agents should dial back to. Set this whenever the panel +# sits behind a reverse proxy / tunnel / custom domain — the connection +# string the panel issues to agents will embed this URL instead of the +# request's Host header (which would otherwise be the internal IP/port). +# Leave commented out for plain localhost development. +# SERVERKIT_PUBLIC_URL=https://panel.your-domain.com + # Ports (for Docker) PORT=80 SSL_PORT=443 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..ebbf0f13 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +* text=auto eol=lf + +*.sh text eol=lf +*.ps1 text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary diff --git a/.github/workflows/agent-release.yml b/.github/workflows/agent-release.yml index e8881ea6..55efbfab 100644 --- a/.github/workflows/agent-release.yml +++ b/.github/workflows/agent-release.yml @@ -12,7 +12,7 @@ on: type: string env: - GO_VERSION: '1.21' + GO_VERSION: '1.23' jobs: build: @@ -31,6 +31,9 @@ jobs: - goos: windows goarch: amd64 suffix: '.exe' + - goos: windows + goarch: arm64 + suffix: '.exe' steps: - name: Checkout code @@ -60,7 +63,15 @@ jobs: CGO_ENABLED: 0 run: | cd agent - go build -ldflags="-s -w -X main.Version=${{ steps.version.outputs.version }} -X github.com/serverkit/agent/internal/agent.Version=${{ steps.version.outputs.version }}" \ + # Windows builds use -H windowsgui so Start-menu launches don't + # flash a console. attachParentConsole() in cmd/agent restores + # stdio when invoked from PowerShell/cmd for CLI subcommands. + if [ "${{ matrix.goos }}" = "windows" ]; then + EXTRA_LDFLAGS="-H=windowsgui" + else + EXTRA_LDFLAGS="" + fi + go build -ldflags="-s -w $EXTRA_LDFLAGS -X main.Version=${{ steps.version.outputs.version }} -X github.com/serverkit/agent/internal/agent.Version=${{ steps.version.outputs.version }}" \ -o ../dist/serverkit-agent-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.suffix }} \ ./cmd/agent @@ -190,12 +201,20 @@ jobs: path: packages/*.rpm retention-days: 5 - # Build Windows MSI + # Build Windows MSI installers (one per architecture) build-msi: - name: Build MSI Installer + name: Build MSI Installer (${{ matrix.arch }}) runs-on: windows-latest needs: build + strategy: + matrix: + include: + - goarch: amd64 + arch: x64 + - goarch: arm64 + arch: arm64 + steps: - name: Checkout code uses: actions/checkout@v4 @@ -213,30 +232,30 @@ jobs: - name: Install WiX Toolset run: | - dotnet tool install --global wix + dotnet tool install --global wix --version 5.0.2 - name: Download binary uses: actions/download-artifact@v4 with: - name: serverkit-agent-windows-amd64 + name: serverkit-agent-windows-${{ matrix.goarch }} path: dist/ - name: Extract binary shell: bash run: | cd dist - unzip serverkit-agent-${{ steps.version.outputs.version }}-windows-amd64.zip + unzip serverkit-agent-${{ steps.version.outputs.version }}-windows-${{ matrix.goarch }}.zip - name: Build MSI shell: pwsh run: | cd agent/packaging/msi - .\build.ps1 -Version "${{ steps.version.outputs.version }}" -BinaryPath "..\..\..\dist\serverkit-agent-windows-amd64.exe" -OutputDir "..\..\..\packages" + .\build.ps1 -Version "${{ steps.version.outputs.version }}" -Arch "${{ matrix.arch }}" -BinaryPath "..\..\..\dist\serverkit-agent-windows-${{ matrix.goarch }}.exe" -OutputDir "..\..\..\packages" - name: Upload MSI uses: actions/upload-artifact@v4 with: - name: serverkit-agent-msi + name: serverkit-agent-msi-${{ matrix.arch }} path: packages/*.msi retention-days: 5 @@ -352,6 +371,7 @@ jobs: | Linux | x64 (amd64) | `serverkit-agent-${{ steps.version.outputs.version }}-linux-amd64.tar.gz` | | Linux | ARM64 | `serverkit-agent-${{ steps.version.outputs.version }}-linux-arm64.tar.gz` | | Windows | x64 (amd64) | `serverkit-agent-${{ steps.version.outputs.version }}-windows-amd64.zip` | + | Windows | ARM64 | `serverkit-agent-${{ steps.version.outputs.version }}-windows-arm64.zip` | #### Linux Packages | Format | Architecture | Download | @@ -365,8 +385,9 @@ jobs: | Format | Architecture | Download | |--------|--------------|----------| | MSI | x64 | `serverkit-agent-${{ steps.version.outputs.version }}-x64.msi` | + | MSI | ARM64 | `serverkit-agent-${{ steps.version.outputs.version }}-arm64.msi` | - > **New:** Run `serverkit-agent tray` to launch the **System Tray Application** which provides a visual indicator that the agent is running, along with quick access to status, logs, and service controls. The MSI installer auto-starts the tray on Windows login. + > **New:** The Windows MSI now opens a native pairing wizard right after install — no browser, no WebView2 runtime needed. The Start-menu shortcut and login autostart both run the unified desktop app: pairing wizard if not yet configured, otherwise the system tray. #### Docker ```bash @@ -458,6 +479,7 @@ jobs: echo "| RPM | x86_64 | ✅ Built |" >> $GITHUB_STEP_SUMMARY echo "| RPM | aarch64 | ✅ Built |" >> $GITHUB_STEP_SUMMARY echo "| MSI | x64 | ✅ Built |" >> $GITHUB_STEP_SUMMARY + echo "| MSI | ARM64 | ✅ Built |" >> $GITHUB_STEP_SUMMARY echo "| Docker | multi-arch | ✅ Built |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Release" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 29f74047..fcc34551 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ /backend/*.pyd /backend/.env /backend/.venv +/backend/.venv-wsl/ /backend/pip-log.txt /backend/pip-delete-this-directory.txt /backend/dev-data/ @@ -40,6 +41,7 @@ /agent/*.test /agent/*.out /agent/bin/ +/dist/ # Environment / Secrets .env @@ -53,6 +55,7 @@ *.csr /backend/.env /frontend/.env.local +/frontend/.env.development.local # Docker .docker-compose.untracked.yml @@ -95,4 +98,19 @@ new_templates/ nul SECURITY_AUDIT.md APP_IMPROVEMENTS.md +UI_REPORT.md *.png + +# MSI / build artifacts that should not be committed. +/agent/packaging/msi/output/ +/agent/packaging/msi/*.wixpdb + +# Brand assets that the agent embeds at build time are generated by +# agent/packaging/icons/generate_icons.py from the SVG, but must be checked +# in so CI builds don't have to install cairo/Pillow. +!agent/internal/setupui/serverkit.ico +!agent/internal/setupui/serverkit_header.png +docs/PLAN_AGENT_FLEET.md +/docs/plans +/.agents +/.extension-platform-plan/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..96643fba --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,122 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +ServerKit is a server control panel for managing web applications, databases, Docker containers, and security on VPS/dedicated servers. Flask backend (Python 3.11+), React frontend (Vite + SCSS), SQLite/PostgreSQL database, real-time updates via Socket.IO. + +## Development Commands + +```bash +# Backend (launcher default port 47927, hot-reload) +cd backend && python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +python run.py + +# Frontend (launcher default port 41921, Vite HMR) +cd frontend && npm install && npm run dev + +# Both at once (Linux/WSL) +./dev.sh + +# Frontend lint +cd frontend && npm run lint + +# Frontend production build +cd frontend && npm run build + +# Backend tests +cd backend && pytest +cd backend && pytest --cov=app + +# Docker +docker compose -f docker-compose.dev.yml up --build +``` + +Default dev credentials: `admin` / `admin` + +## Architecture + +### Backend (`backend/`) + +Flask app factory in `app/__init__.py` using `create_app()`. Three-layer architecture: + +- **`app/api/`** — Flask Blueprints, one file per feature (36 files). All routes prefixed `/api/v1/`. JWT-protected via `@jwt_required()`. +- **`app/services/`** — Business logic (48 files). Services are stateless modules called by API routes. Heavy lifting (shell commands, Docker API, file operations) happens here. +- **`app/models/`** — SQLAlchemy ORM models (15 files). Tables auto-created on startup via `db.create_all()`. + +Other backend components: +- `app/sockets.py` — Socket.IO event handlers for real-time metrics, logs, terminal +- `app/agent_gateway.py` — Multi-server agent communication +- `app/middleware/security.py` — Security headers middleware +- `config.py` — Environment-based config (development/production/testing) +- `run.py` — Entry point + +### Frontend (`frontend/src/`) + +React 18 SPA with client-side routing: + +- **`pages/`** — Route-level components (~29 files). Each maps to a route in `App.jsx`. +- **`components/`** — Reusable UI components shared across pages. +- **`contexts/`** — React Context providers: `AuthContext` (JWT auth + token refresh), `ThemeContext`, `ToastContext`, `ResourceTierContext` (feature gating). +- **`services/api.js`** — Centralized `ApiService` class handling all HTTP requests, token management, and auto-refresh. +- **`hooks/`** — Custom React hooks for reusable logic. +- **`styles/`** — SCSS stylesheets with design system variables. Main entry is `main.scss`. Page-specific styles in `styles/pages/`. +- **`layouts/`** — `DashboardLayout` wraps authenticated pages (sidebar + header). + +Route guards: `PrivateRoute` (auth check), `PublicRoute` (redirect if logged in), `SetupRoute` (redirect to `/setup` if not configured). + +### Request Flow + +Browser → Nginx (`:80`/`:443`) → proxy_pass to Docker containers (`:8001-8999`) for managed apps, or to Flask (`:5000`) for the panel API. The 404 handler in Flask serves `index.html` for SPA client-side routing; API routes return JSON errors. + +### Production Build + +The Dockerfile is multi-stage: Node 20 builds frontend, Python 3.11 serves everything via Gunicorn with GeventWebSocket workers. Built frontend is served from Flask's static folder. + +## Platform & Distro Awareness + +ServerKit deploys on Linux (bare metal, VPS, or Docker). Development may happen on Windows/macOS. + +- **Service layer is Linux-only** — nginx, systemctl, apt/dnf, PHP-FPM, etc. are inherently Linux. No need to abstract these for Windows. +- **Platform-agnostic code** (config management, storage, API layer) should guard Unix-only calls like `os.chmod` with `if os.name != 'nt'` so the dev server can run locally on any OS. +- **Distro differences matter** — use `backend/app/utils/system.py` helpers (`get_package_manager`, `is_package_installed`, `install_package`) instead of calling `apt`/`dpkg`/`dnf` directly. Not all targets are Debian-based. + +## Code Style + +### Python +- PEP 8, type hints where helpful +- Service functions are standalone (no classes unless stateful) +- Consistent JSON error responses: `{'error': 'message'}, status_code` + +### React/JavaScript +- Functional components with hooks only +- PascalCase for components (`Sidebar.jsx`), camelCase for everything else +- SCSS for styling — use existing design system variables (`$bg-card`, `$primary-color`, `$spacing-md`, etc.) and BEM-like naming (`.block__element--modifier`) +- Context API for global state; props drilling is fine for 2-3 levels +- No inline styles; no Tailwind/CSS-in-JS + +### Diffs & Commits +- One logical change per commit +- Minimal, focused diffs — don't silently refactor surrounding code +- Branch naming: `feature/`, `fix/`, `docs/`, `refactor/` prefixes + +## Adding a New Feature (Full Stack) + +1. **Model**: Add SQLAlchemy model in `backend/app/models/` +2. **Service**: Add business logic in `backend/app/services/` +3. **API**: Create Blueprint in `backend/app/api/`, register it in `app/__init__.py` with `url_prefix='/api/v1/'` +4. **Frontend API**: Add methods to `ApiService` in `frontend/src/services/api.js` +5. **Page**: Create page component in `frontend/src/pages/`, add route in `App.jsx` +6. **Styles**: Add SCSS file in `frontend/src/styles/pages/`, import in `main.scss` + +## Key Environment Variables + +| Variable | Purpose | +|----------|---------| +| `SECRET_KEY` | Flask session signing | +| `JWT_SECRET_KEY` | JWT token signing | +| `DATABASE_URL` | DB connection string (`sqlite:///...` or PostgreSQL) | +| `CORS_ORIGINS` | Comma-separated allowed origins | +| `FLASK_ENV` | `development` or `production` | diff --git a/CLAUDE.md b/CLAUDE.md index 436bb494..88447dd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,17 +4,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -ServerKit is a server control panel for managing web applications, databases, Docker containers, and security on VPS/dedicated servers. Flask backend (Python 3.11+), React frontend (Vite + LESS), SQLite/PostgreSQL database, real-time updates via Socket.IO. +ServerKit is a server control panel for managing web applications, databases, Docker containers, and security on VPS/dedicated servers. Flask backend (Python 3.11+), React frontend (Vite + SCSS), SQLite/PostgreSQL database, real-time updates via Socket.IO. ## Development Commands ```bash -# Backend (port 5000, hot-reload) +# Backend (launcher default port 47927, hot-reload) cd backend && python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate pip install -r requirements.txt python run.py -# Frontend (port 5173, Vite HMR) +# Frontend (launcher default port 41921, Vite HMR) cd frontend && npm install && npm run dev # Both at once (Linux/WSL) @@ -62,7 +62,7 @@ React 18 SPA with client-side routing: - **`contexts/`** — React Context providers: `AuthContext` (JWT auth + token refresh), `ThemeContext`, `ToastContext`, `ResourceTierContext` (feature gating). - **`services/api.js`** — Centralized `ApiService` class handling all HTTP requests, token management, and auto-refresh. - **`hooks/`** — Custom React hooks for reusable logic. -- **`styles/`** — LESS stylesheets with design system variables. Main entry is `main.less`. Page-specific styles in `styles/pages/`. +- **`styles/`** — SCSS stylesheets with design system variables. Main entry is `main.scss`. Page-specific styles in `styles/pages/`. - **`layouts/`** — `DashboardLayout` wraps authenticated pages (sidebar + header). Route guards: `PrivateRoute` (auth check), `PublicRoute` (redirect if logged in), `SetupRoute` (redirect to `/setup` if not configured). @@ -93,7 +93,7 @@ ServerKit deploys on Linux (bare metal, VPS, or Docker). Development may happen ### React/JavaScript - Functional components with hooks only - PascalCase for components (`Sidebar.jsx`), camelCase for everything else -- LESS for styling — use existing design system variables (`@card-bg`, `@primary-color`, `@spacing-md`, etc.) and BEM-like naming (`.block__element--modifier`) +- SCSS for styling — use existing design system variables (`$bg-card`, `$primary-color`, `$spacing-md`, etc.) and BEM-like naming (`.block__element--modifier`) - Context API for global state; props drilling is fine for 2-3 levels - No inline styles; no Tailwind/CSS-in-JS @@ -109,7 +109,7 @@ ServerKit deploys on Linux (bare metal, VPS, or Docker). Development may happen 3. **API**: Create Blueprint in `backend/app/api/`, register it in `app/__init__.py` with `url_prefix='/api/v1/'` 4. **Frontend API**: Add methods to `ApiService` in `frontend/src/services/api.js` 5. **Page**: Create page component in `frontend/src/pages/`, add route in `App.jsx` -6. **Styles**: Add LESS file in `frontend/src/styles/pages/`, import in `main.less` +6. **Styles**: Add SCSS file in `frontend/src/styles/pages/`, import in `main.scss` ## Key Environment Variables diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf83ea52..ae23d22a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ chmod +x ./scripts/dev/*.sh ./dev.sh ``` -Open http://localhost:5173 — login: `admin` / `admin` +Open http://localhost:41921 — login: `admin` / `admin` > **Troubleshooting:** If you get `bad interpreter` error, fix line endings: > ```bash @@ -115,7 +115,7 @@ ServerKit/ │ │ ├── components/ # Reusable components │ │ ├── pages/ # Page components │ │ ├── services/ # API client -│ │ └── styles/ # LESS stylesheets +│ │ └── styles/ # SCSS stylesheets │ ├── package.json │ └── vite.config.js │ @@ -137,7 +137,7 @@ ServerKit/ - `frontend/src/pages/` - Page components - `frontend/src/components/` - Shared components - `frontend/src/services/api.js` - API client -- `frontend/src/styles/` - LESS stylesheets +- `frontend/src/styles/` - SCSS stylesheets --- @@ -201,7 +201,7 @@ def get_system_stats() -> dict: - Use functional components with hooks - Use meaningful component and variable names - Keep components focused and small -- Use LESS for styling (not inline styles) +- Use SCSS for styling (not inline styles) ```jsx const ServerStats = ({ serverId }) => { @@ -217,24 +217,24 @@ const ServerStats = ({ serverId }) => { }; ``` -### LESS/CSS (Styles) +### SCSS/CSS (Styles) - Use the existing design system variables - Follow BEM-like naming conventions - Keep specificity low - Use the component/page file structure -```less +```scss .notification-card { - background: @card-bg; - border-radius: @border-radius-md; + background: $bg-card; + border-radius: $radius-md; &__header { - padding: @spacing-md; + padding: $spacing-md; } &--expanded { - border-color: @primary-color; + border-color: $primary-color; } } ``` diff --git a/Dockerfile b/Dockerfile index b3d3866b..f9204160 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ # # Build: docker build -t serverkit . # Run: docker run -d -p 5000:5000 --env-file .env serverkit +# Override the internal backend port with SERVERKIT_BACKEND_PORT if needed. # ============================================ # Stage 1: Build Frontend @@ -36,7 +37,7 @@ FROM python:3.11-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ FLASK_ENV=production \ - PORT=5000 + SERVERKIT_BACKEND_PORT=5000 # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -92,17 +93,10 @@ EXPOSE 5000 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:5000/api/v1/system/health || exit 1 + CMD curl -f "http://localhost:${SERVERKIT_BACKEND_PORT:-5000}/api/v1/system/health" || exit 1 # Working directory for the backend WORKDIR /app/backend # Default command - use gunicorn for production -CMD ["gunicorn", \ - "--worker-class", "geventwebsocket.gunicorn.workers.GeventWebSocketWorker", \ - "--workers", "1", \ - "--bind", "0.0.0.0:5000", \ - "--timeout", "120", \ - "--access-logfile", "-", \ - "--error-logfile", "-", \ - "run:app"] +CMD ["sh", "-c", "exec gunicorn --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --workers 1 --bind 0.0.0.0:${SERVERKIT_BACKEND_PORT:-5000} --timeout 120 --access-logfile - --error-logfile - run:app"] diff --git a/README.md b/README.md index b58842ae..b2c2ed4c 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ English | [Español](docs/README.es.md) | [中文版](docs/README.zh-CN.md) | [P **Agent-Based Architecture** — Go agent with HMAC-SHA256 authentication and real-time WebSocket gateway -**Fleet Management** — Agent lifecycle control with version rollouts, approval queue, network discovery, and command queue +**Fleet Management** — Agent inventory, connection status, approval queue, rollouts, discovery, and command queue **Fleet Monitor** — Cross-server heatmaps, metric comparison charts, alert thresholds, anomaly detection, and capacity forecasting @@ -108,7 +108,7 @@ English | [Español](docs/README.es.md) | [中文版](docs/README.zh-CN.md) | [P **Server Templates** — Configuration templates with compliance tracking, drift detection, and auto-remediation -**Remote Docker** — Manage containers, images, volumes, networks, and Compose projects across all servers +**Remote Docker** — Agent-backed Docker operations for connected servers; remote app/site deployment is still evolving **API Key Rotation** — Secure credential rotation with acknowledgment handshake @@ -280,7 +280,7 @@ See the [Installation Guide](docs/INSTALLATION.md) for step-by-step instructions - [x] Monitoring & alerts — Metrics, webhooks, uptime tracking - [x] Security — 2FA, ClamAV, file integrity, Fail2ban, Lynis - [x] Firewall — UFW/firewalld integration -- [x] Multi-server management — Go agent, centralized dashboard +- [x] Multi-server monitoring — Go agent, centralized dashboard - [x] Git deployment — Webhooks, auto-deploy, rollback, zero-downtime - [x] Backup & restore — S3, Backblaze B2, scheduled backups - [x] Email server — Postfix, Dovecot, DKIM/SPF/DMARC, Roundcube @@ -290,6 +290,7 @@ See the [Installation Guide](docs/INSTALLATION.md) for step-by-step instructions - [x] Database migrations — Flask-Migrate/Alembic, versioned schema - [x] Agent fleet management — Version rollouts, approval queue, discovery, command queue - [x] Cross-server monitoring — Fleet heatmaps, comparison charts, anomaly detection, capacity forecasting +- [ ] Remote app/site deployment through connected agents - [x] Agent plugin system — Extensible agent with capabilities, permissions, per-server install - [x] Server templates & config sync — Drift detection, compliance dashboards, auto-remediation - [x] Multi-tenancy — Workspaces with quotas, member management, isolation diff --git a/VERSION b/VERSION index acd81d7f..400084b1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.13 +1.6.7 diff --git a/agent/.gitignore b/agent/.gitignore index a739712f..0cfd3ca6 100644 --- a/agent/.gitignore +++ b/agent/.gitignore @@ -24,3 +24,4 @@ serverkit-agent.exe # OS files .DS_Store Thumbs.db +internal/agentui/dist/ diff --git a/agent/Dockerfile b/agent/Dockerfile index ecb6a4fb..90418ae8 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -4,7 +4,7 @@ # ============================================ # Stage 1: Build # ============================================ -FROM golang:1.21-alpine AS builder +FROM golang:1.23-alpine AS builder # Install build dependencies RUN apk add --no-cache git ca-certificates tzdata diff --git a/agent/Makefile b/agent/Makefile index 7926298f..94a74d76 100644 --- a/agent/Makefile +++ b/agent/Makefile @@ -9,10 +9,17 @@ LDFLAGS := -s -w \ -X main.BuildTime=$(BUILD_TIME) \ -X main.GitCommit=$(GIT_COMMIT) -# Agent-specific LDFLAGS (includes agent package version var) +# Agent-specific LDFLAGS (includes agent package version var). On Windows +# we MUST select the GUI subsystem; otherwise Windows attaches a console to +# every launch (the desktop console then renders behind a stray black +# cmd.exe-looking window). attachParentConsole() in console_windows.go +# still re-hooks stdio for CLI subcommands like `status` when invoked from +# a real terminal. AGENT_LDFLAGS := $(LDFLAGS) \ -X github.com/serverkit/agent/internal/agent.Version=$(VERSION) +WINDOWS_LDFLAGS := $(AGENT_LDFLAGS) -H=windowsgui + BINARY_NAME := serverkit-agent DIST_DIR := dist @@ -45,9 +52,9 @@ build-all: @echo "Building for Linux arm64..." CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(AGENT_LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-$(VERSION)-linux-arm64 ./cmd/agent @echo "Building for Windows amd64..." - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(AGENT_LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-$(VERSION)-windows-amd64.exe ./cmd/agent + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(WINDOWS_LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-$(VERSION)-windows-amd64.exe ./cmd/agent @echo "Building for Windows arm64..." - CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$(AGENT_LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-$(VERSION)-windows-arm64.exe ./cmd/agent + CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$(WINDOWS_LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-$(VERSION)-windows-arm64.exe ./cmd/agent @echo "Building for macOS amd64..." CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(AGENT_LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-$(VERSION)-darwin-amd64 ./cmd/agent @echo "Building for macOS arm64..." @@ -63,7 +70,7 @@ build-linux: # Build Windows only build-windows: @mkdir -p $(DIST_DIR) - CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(AGENT_LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/agent + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(WINDOWS_LDFLAGS)" -o $(DIST_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/agent # Run tests test: diff --git a/agent/cmd/agent/app.manifest b/agent/cmd/agent/app.manifest new file mode 100644 index 00000000..428de2cb --- /dev/null +++ b/agent/cmd/agent/app.manifest @@ -0,0 +1,46 @@ + + + + ServerKit Agent + + + + + + + + + + + + + + + + + + + + + + + + + + + + true/PM + PerMonitorV2,PerMonitor + + + diff --git a/agent/cmd/agent/console_other.go b/agent/cmd/agent/console_other.go new file mode 100644 index 00000000..96a507e2 --- /dev/null +++ b/agent/cmd/agent/console_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package main + +// attachParentConsole is a no-op outside Windows. The Windows build needs it +// to keep CLI output working when the binary is built with the windowsgui +// subsystem; on POSIX that subsystem distinction does not exist. +func attachParentConsole() {} diff --git a/agent/cmd/agent/console_windows.go b/agent/cmd/agent/console_windows.go new file mode 100644 index 00000000..4e09c3c7 --- /dev/null +++ b/agent/cmd/agent/console_windows.go @@ -0,0 +1,34 @@ +//go:build windows + +package main + +import ( + "os" + "syscall" +) + +// attachParentConsole reattaches stdin/stdout/stderr to the parent console +// (e.g. PowerShell or cmd.exe) when the binary was built with the +// "windowsgui" subsystem. With that subsystem set, double-clicking a +// shortcut no longer flashes a console — but CLI invocations like +// `serverkit-agent register` would also lose their output. This call +// restores the terminal experience when one is available, and is a +// silent no-op when launched from Explorer/Start menu. +func attachParentConsole() { + const attachParentProcess = ^uint32(0) // -1, ATTACH_PARENT_PROCESS + kernel32 := syscall.NewLazyDLL("kernel32.dll") + proc := kernel32.NewProc("AttachConsole") + r, _, _ := proc.Call(uintptr(attachParentProcess)) + if r == 0 { + return // no parent console; we're a GUI process + } + if h, err := syscall.Open("CONOUT$", os.O_WRONLY, 0); err == nil { + os.Stdout = os.NewFile(uintptr(h), "stdout") + } + if h, err := syscall.Open("CONOUT$", os.O_WRONLY, 0); err == nil { + os.Stderr = os.NewFile(uintptr(h), "stderr") + } + if h, err := syscall.Open("CONIN$", os.O_RDONLY, 0); err == nil { + os.Stdin = os.NewFile(uintptr(h), "stdin") + } +} diff --git a/agent/cmd/agent/desktop_debug.go b/agent/cmd/agent/desktop_debug.go new file mode 100644 index 00000000..060587bd --- /dev/null +++ b/agent/cmd/agent/desktop_debug.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// desktopLog is a small file-backed log used by runDesktop / runSetup so a +// failed launch is never invisible. We deliberately live alongside the agent's +// other state in %LOCALAPPDATA%\ServerKit\Agent rather than next to the exe +// (Program Files needs admin to write into). +type desktopLog struct { + mu sync.Mutex + f *os.File + p string +} + +func (d *desktopLog) Path() string { return d.p } + +// File exposes the underlying append-only handle so callers can redirect +// other writers (stdlib log, slog handlers) into the same file. Returns +// nil if the desktop log couldn't be opened — callers must guard. +func (d *desktopLog) File() *os.File { + if d == nil { + return nil + } + return d.f +} + +func (d *desktopLog) Logf(format string, args ...interface{}) { + if d == nil || d.f == nil { + return + } + d.mu.Lock() + defer d.mu.Unlock() + line := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02 15:04:05.000"), fmt.Sprintf(format, args...)) + _, _ = d.f.WriteString(line) + _ = d.f.Sync() +} + +func (d *desktopLog) Close() { + if d == nil || d.f == nil { + return + } + _ = d.f.Close() +} + +func openDesktopLog() *desktopLog { + candidates := []string{} + if v := os.Getenv("LOCALAPPDATA"); v != "" { + candidates = append(candidates, filepath.Join(v, "ServerKit", "Agent")) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, "AppData", "Local", "ServerKit", "Agent")) + } + candidates = append(candidates, os.TempDir()) + + for _, dir := range candidates { + if err := os.MkdirAll(dir, 0o755); err != nil { + continue + } + p := filepath.Join(dir, "desktop.log") + f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + continue + } + return &desktopLog{f: f, p: p} + } + return &desktopLog{} +} diff --git a/agent/cmd/agent/instance_other.go b/agent/cmd/agent/instance_other.go new file mode 100644 index 00000000..2aeb20d0 --- /dev/null +++ b/agent/cmd/agent/instance_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package main + +// acquireSingleInstance is a Windows-specific concern; on POSIX the user +// runs at most one tray per session anyway and we don't enforce it. +func acquireSingleInstance() (alreadyRunning bool, release func()) { + return false, func() {} +} + +// acquireServiceInstance is a no-op on non-Windows; SCM doesn't exist +// and POSIX deployments rely on systemd to enforce single-instance. +func acquireServiceInstance() (alreadyRunning bool, release func()) { + return false, func() {} +} diff --git a/agent/cmd/agent/instance_windows.go b/agent/cmd/agent/instance_windows.go new file mode 100644 index 00000000..a61b107f --- /dev/null +++ b/agent/cmd/agent/instance_windows.go @@ -0,0 +1,60 @@ +//go:build windows + +package main + +import ( + "syscall" + "unsafe" +) + +const errAlreadyExists = 183 + +// acquireSingleInstance opens (or creates) a per-user named mutex. If the +// mutex already existed, another instance of the desktop app is already +// running for this user and we should bow out. Otherwise we hold the mutex +// for the lifetime of the process. +// +// Per-user (Local\) namespace: each Windows user gets their own ServerKit +// tray, which matches the per-user Run-key autostart. +func acquireSingleInstance() (alreadyRunning bool, release func()) { + return acquireMutex(`Local\ServerKitAgentDesktop`) +} + +// acquireServiceInstance is the agent-service equivalent of the desktop +// mutex above. Lives in the Global\ namespace because the service runs as +// SYSTEM and a per-user mutex wouldn't collide with a user-launched +// `serverkit-agent start`. With this in place, a second invocation of +// the start subcommand exits immediately with a clear message instead +// of racing the SCM-launched service for port 19780 and tripping a +// 30s "service did not respond" timeout. +func acquireServiceInstance() (alreadyRunning bool, release func()) { + return acquireMutex(`Global\ServerKitAgentService`) +} + +func acquireMutex(name string) (alreadyRunning bool, release func()) { + kernel32 := syscall.NewLazyDLL("kernel32.dll") + createMutexW := kernel32.NewProc("CreateMutexW") + closeHandle := kernel32.NewProc("CloseHandle") + + wname, err := syscall.UTF16PtrFromString(name) + if err != nil { + return false, func() {} + } + + handle, _, lastErr := createMutexW.Call( + 0, // lpMutexAttributes = NULL + 0, // bInitialOwner = FALSE + uintptr(unsafe.Pointer(wname)), + ) + + if handle == 0 { + return false, func() {} + } + + if errno, ok := lastErr.(syscall.Errno); ok && uintptr(errno) == errAlreadyExists { + closeHandle.Call(handle) + return true, func() {} + } + + return false, func() { closeHandle.Call(handle) } +} diff --git a/agent/cmd/agent/main.go b/agent/cmd/agent/main.go index bca3c36a..d14ed27d 100644 --- a/agent/cmd/agent/main.go +++ b/agent/cmd/agent/main.go @@ -3,16 +3,24 @@ package main import ( "context" "fmt" + stdlog "log" + "net" "os" + "os/exec" "os/signal" "path/filepath" "syscall" "github.com/serverkit/agent/internal/agent" + "github.com/serverkit/agent/internal/agentui" "github.com/serverkit/agent/internal/config" + "github.com/serverkit/agent/internal/connstring" "github.com/serverkit/agent/internal/logger" + "github.com/serverkit/agent/internal/setupui" + pollclient "github.com/serverkit/agent/internal/transport/poll" "github.com/serverkit/agent/internal/tray" "github.com/serverkit/agent/internal/updater" + wsclient "github.com/serverkit/agent/internal/ws" "github.com/spf13/cobra" ) @@ -22,31 +30,59 @@ var ( GitCommit = "unknown" ) +// Mirror Version into every package that surfaces it in user-visible +// strings. ldflags set main.Version, but the agent / ws / poll packages +// each have their own Version var — they were silently drifting, which +// is why the panel UI displayed "ServerKit-Agent-Poll/dev" even when +// `serverkit-agent version` correctly printed the build tag. +func init() { + if Version != "dev" { + agent.Version = Version + wsclient.Version = Version + pollclient.Version = Version + } +} + var ( cfgFile string debugMode bool + repair bool ) func main() { + attachParentConsole() + rootCmd := &cobra.Command{ Use: "serverkit-agent", Short: "ServerKit Agent - Remote server management agent", Long: `ServerKit Agent connects your server to a ServerKit control plane, -enabling remote Docker management, monitoring, and more.`, +enabling remote Docker management, monitoring, and more. + +When run without a subcommand on Windows, opens the desktop application +(pairing wizard if not configured, otherwise system tray).`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDesktop() + }, + SilenceUsage: true, + SilenceErrors: true, } // Global flags rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path") rootCmd.PersistentFlags().BoolVarP(&debugMode, "debug", "d", false, "enable debug logging") + rootCmd.PersistentFlags().BoolVar(&repair, "repair", false, "force the pairing wizard even if a config already exists") // Add commands rootCmd.AddCommand(startCmd()) rootCmd.AddCommand(registerCmd()) + rootCmd.AddCommand(pairCmd()) + rootCmd.AddCommand(setupCmd()) rootCmd.AddCommand(statusCmd()) rootCmd.AddCommand(versionCmd()) rootCmd.AddCommand(configCmd()) rootCmd.AddCommand(updateCmd()) rootCmd.AddCommand(trayCmd()) + rootCmd.AddCommand(consoleCmd()) if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) @@ -68,20 +104,34 @@ func registerCmd() *cobra.Command { var token string var serverURL string var name string + var connStr string cmd := &cobra.Command{ Use: "register", Short: "Register this agent with a ServerKit instance", RunE: func(cmd *cobra.Command, args []string) error { + // --connection-string is the modern entry path: a single + // pasteable blob from the panel that already contains the + // URL and token. When present, it overrides the legacy + // flags so users don't have to copy three things. + if connStr != "" { + decoded, err := connstring.Decode(connStr) + if err != nil { + return fmt.Errorf("invalid connection string: %w", err) + } + return runRegister(decoded.Token, decoded.URL, name) + } + if token == "" || serverURL == "" { + return fmt.Errorf("provide either --connection-string or both --token and --server") + } return runRegister(token, serverURL, name) }, } - cmd.Flags().StringVarP(&token, "token", "t", "", "registration token (required)") - cmd.Flags().StringVarP(&serverURL, "server", "s", "", "ServerKit server URL (required)") + cmd.Flags().StringVarP(&connStr, "connection-string", "c", "", "panel connection string (single value, replaces --token + --server)") + cmd.Flags().StringVarP(&token, "token", "t", "", "registration token (use with --server)") + cmd.Flags().StringVarP(&serverURL, "server", "s", "", "ServerKit server URL (use with --token)") cmd.Flags().StringVarP(&name, "name", "n", "", "display name for this server") - cmd.MarkFlagRequired("token") - cmd.MarkFlagRequired("server") return cmd } @@ -227,6 +277,26 @@ func runUpdate(force, checkOnly bool) error { } func runAgent() error { + // SCM detection: when launched as a Windows Service, the binary must + // implement the Service Control dispatcher protocol or SCM kills it + // with error 1053 within 30s. Older versions of the agent skipped + // this entirely — every "Start-Service ServerKitAgent" was silently + // failing while manual `serverkit-agent start` worked, which had + // users running the agent in foreground console windows as a + // workaround. Now: if SCM started us, route through svc.Run; if + // the user ran it from CLI, run directly with signal handling. + if isWindowsService() { + return runAsService(agentMainLoop) + } + return agentMainLoop(nil) +} + +// agentMainLoop is the actual run logic, factored out so both the +// SCM-driven path (svc.Run dispatching to serviceHandler.Execute) and +// the CLI path can share it. Pass nil for ctx to use a fresh +// signal-handled context (CLI mode); pass the SCM-supplied ctx for +// service mode. +func agentMainLoop(parentCtx context.Context) error { // Load configuration cfg, err := config.Load(cfgFile) if err != nil { @@ -243,15 +313,78 @@ func runAgent() error { log.Info("Starting ServerKit Agent", "version", Version, "config", config.DefaultConfigPath(), + "mode", map[bool]string{true: "service", false: "cli"}[isWindowsService()], ) + // Loud warning if credentials failed to decrypt. Without surfacing + // this, the agent would proceed with empty APIKey/APISecret and + // every panel /connect would 400 "Missing required fields" — which + // is exactly the silent failure mode that hid this bug for years. + if cfg.Auth.LoadError != "" && cfg.Agent.ID != "" { + log.Error("Failed to load credentials — agent cannot authenticate. "+ + "Re-pair the agent (Actions → Open wizard) to regenerate the key file.", + "error", cfg.Auth.LoadError, + "key_file", cfg.Auth.KeyFile, + ) + } + + // Single-instance enforcement for the service. Multiple "start" + // invocations would fight for IPC port 19780 and the SCM-launched + // service would lose to a manually-started one (or vice versa) with + // a 30s timeout in either direction. Mutex is in the Global\ + // namespace because the service runs as SYSTEM and a per-user lock + // wouldn't catch a user-context "serverkit-agent start" race. + if alreadyRunning, release := acquireServiceInstance(); alreadyRunning { + log.Error("Another ServerKit agent service is already running. " + + "If this is unexpected, end leftover serverkit-agent.exe processes via " + + "Task Manager (or run `taskkill /F /IM serverkit-agent.exe /T`) and try again.") + return fmt.Errorf("another agent service is already running (mutex held)") + } else { + defer release() + } + + // Probe the IPC port up-front. If it's held by something else (a + // stale agent, another tool, anything), fail fast with a clear + // message instead of letting the IPC server later time out and + // trip SCM's 1053 "service did not respond" error. + if cfg.IPC.Enabled { + probe, perr := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.IPC.Port)) + if perr != nil { + log.Error("IPC port is already in use — likely a leftover serverkit-agent.exe process", + "port", cfg.IPC.Port, "error", perr) + return fmt.Errorf("IPC port %d is in use; "+ + "end leftover agent processes (Task Manager → 'serverkit-agent.exe') and try again", + cfg.IPC.Port) + } + // Release the probe — the real IPC server will rebind in a moment. + // There's a tiny race window where another process could grab the + // port between probe-close and real-bind, but that's acceptable + // for what's effectively a "is anyone else here?" check. + probe.Close() + } + // Check if registered if cfg.Agent.ID == "" { return fmt.Errorf("agent not registered. Run 'serverkit-agent register' first") } - // Create and start agent - ctx, cancel := context.WithCancel(context.Background()) + // Build the run context. Service mode supplies its own (cancelled + // when SCM sends Stop). CLI mode wires SIGINT/SIGTERM as the + // cancellation signal. + var ctx context.Context + var cancel context.CancelFunc + if parentCtx != nil { + ctx, cancel = context.WithCancel(parentCtx) + } else { + ctx, cancel = context.WithCancel(context.Background()) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + log.Info("Received shutdown signal", "signal", sig.String()) + cancel() + }() + } defer cancel() ag, err := agent.New(cfg, log) @@ -263,16 +396,6 @@ func runAgent() error { updateChecker := updater.NewChecker(cfg, log, Version) go updateChecker.Start(ctx) - // Handle graceful shutdown - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - - go func() { - sig := <-sigCh - log.Info("Received shutdown signal", "signal", sig.String()) - cancel() - }() - // Start agent if err := ag.Run(ctx); err != nil && err != context.Canceled { return fmt.Errorf("agent error: %w", err) @@ -370,6 +493,230 @@ func showStatus() error { return nil } +func setupCmd() *cobra.Command { + return &cobra.Command{ + Use: "setup", + Short: "Open the pairing wizard", + Long: `Opens the desktop console at the pairing wizard. Enter the panel +URL and a server name; the agent generates a code and passphrase to type +into the panel UI to claim this server. + +For headless servers, use 'serverkit-agent pair' instead.`, + RunE: func(cmd *cobra.Command, args []string) error { + // The wizard now lives inside the React console; setup is a + // thin alias that opens it. The PairGate component routes the + // user straight to /pair when no config is present. + if legacyWizard { + return runSetup() + } + return runConsole() + }, + } +} + +// consoleCmd opens the WebView2-based agent console window. Sibling to the +// `setup` (pairing wizard) command — they coexist while the wizard is being +// migrated to the same React app. Once the wizard lives inside the console, +// `setup` becomes a thin alias that opens the console at the /pair route. +func consoleCmd() *cobra.Command { + return &cobra.Command{ + Use: "console", + Short: "Open the agent console window (status, logs, actions)", + Long: `Opens the desktop console for this agent. Shows pairing status, +connection state, recent activity, raw logs, and service controls.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runConsole() + }, + } +} + +func runConsole() error { + dbg := openDesktopLog() + defer dbg.Close() + dbg.Logf("--- runConsole start, version=%s pid=%d ---", Version, os.Getpid()) + + // go-webview2 uses the stdlib log package internally for fatal errors + // during chromium init. Without this redirect, "Error calling + // Webview2Loader: ..." goes to a hidden stdout and we'd never see it + // on a blank-window report. Path is the same desktop.log so all the + // diagnostic breadcrumbs end up in one file. + if f := dbg.File(); f != nil { + stdlog.SetOutput(f) + stdlog.SetFlags(stdlog.LstdFlags | stdlog.Lmicroseconds) + stdlog.SetPrefix("[stdlib] ") + } + + defer func() { + if r := recover(); r != nil { + msg := fmt.Sprintf("ServerKit Agent console crashed:\n\n%v\n\nLog: %s", r, dbg.Path()) + dbg.Logf("PANIC: %v", r) + showMessageBox("ServerKit Agent", msg, mbIconError) + } + }() + + // Point the slog logger at the same desktop.log so internal log.Info + // calls in agentui land alongside the dbg.Logf trail. Without this, any + // diagnostic logging from the package goes to a hidden stdout (the + // console binary uses the windowsgui subsystem) and we end up debugging + // blank windows blind. + log := logger.New(config.LoggingConfig{ + Level: "info", + File: dbg.Path(), + MaxSize: 10, + MaxBackups: 2, + MaxAge: 7, + }) + + configPath := cfgFile + if configPath == "" { + configPath = config.DefaultConfigPath() + } + dbg.Logf("configPath=%s", configPath) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + dbg.Logf("calling agentui.Run …") + err := agentui.Run(ctx, log, configPath) + dbg.Logf("agentui.Run returned: %v", err) + if err != nil { + showMessageBox("ServerKit Agent", + fmt.Sprintf("Couldn't open the agent console.\n\n%v\n\nLog: %s", err, dbg.Path()), + mbIconError) + } + return err +} + +// runSetup is retained as a fallback into the legacy walk wizard. The +// current setup command points at runConsole, but if WebView2 is somehow +// unavailable on a target machine the user can still pair via this path +// by setting SERVERKIT_AGENT_LEGACY_WIZARD=1. +func runSetup() error { + dbg := openDesktopLog() + defer dbg.Close() + dbg.Logf("--- runSetup (legacy) start, version=%s pid=%d ---", Version, os.Getpid()) + + log := logger.New(config.LoggingConfig{Level: "info"}) + + configPath := cfgFile + if configPath == "" { + configPath = config.DefaultConfigPath() + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + return setupui.Run(ctx, log, configPath) +} + +// init wires the legacy fallback when explicitly requested, replacing the +// console-based setup runner with the walk wizard. This is purely an escape +// hatch — primary path is the React console. +func init() { + if os.Getenv("SERVERKIT_AGENT_LEGACY_WIZARD") == "1" { + legacyWizard = true + } +} + +var legacyWizard bool + +// runDesktop is what `serverkit-agent` (no args) does on a desktop install. +// First-run: shows the pairing wizard. Subsequent runs (config exists): goes +// straight to tray. Either way the tray runs after pairing completes. +// runDesktop is what `serverkit-agent` (no args) does on a desktop install. +// Architecture: the tray always runs in this process and is the primary +// entry point — the user-visible app. If the agent is unconfigured (or the +// caller passed --repair) we spawn the pairing wizard as a *separate* +// detached process so the tray is visible immediately and remains the +// recovery surface even if the wizard fails to render. +func runDesktop() error { + dbg := openDesktopLog() + defer dbg.Close() + dbg.Logf("--- runDesktop start, version=%s pid=%d ---", Version, os.Getpid()) + + // Single-instance: if a tray is already running for this user, just + // kick the wizard if needed and exit. Avoids the two-trays-in-one-bar + // problem when the autostart and a Start-menu click race. + if alreadyRunning, release := acquireSingleInstance(); alreadyRunning { + dbg.Logf("another instance is already running; not starting a second tray") + if repair { + spawnSetupWizard(dbg) + } + return nil + } else { + defer release() + } + + cfg, cfgErr := config.Load(cfgFile) + dbg.Logf("config.Load err=%v", cfgErr) + if cfg != nil { + dbg.Logf("agent.id=%q server.url=%q", cfg.Agent.ID, cfg.Server.URL) + } + + needsSetup := cfg == nil || cfg.Agent.ID == "" || repair + dbg.Logf("needsSetup=%v (repair=%v)", needsSetup, repair) + + if needsSetup { + spawnSetupWizard(dbg) + } + + dbg.Logf("→ launching tray (needsSetup=%v)", needsSetup) + return runTrayWithOpts(cfg, needsSetup) +} + +func cfgName(cfg *config.Config) string { + if cfg == nil { + return "" + } + return cfg.Agent.Name +} + +// spawnSetupWizard launches `serverkit-agent setup` as a detached child so +// the wizard runs in its own process and the parent (the tray) is unaffected +// by what happens to the wizard's window. Used both at desktop boot when +// pairing is needed and from the tray's "Open setup wizard…" menu. +func spawnSetupWizard(dbg *desktopLog) { + exe, err := os.Executable() + if err != nil { + if dbg != nil { + dbg.Logf("spawn: os.Executable err=%v", err) + } + return + } + cmd := exec.Command(exe, "setup") + cmd.SysProcAttr = detachedProcessAttrs() + if err := cmd.Start(); err != nil { + if dbg != nil { + dbg.Logf("spawn: cmd.Start err=%v", err) + } + return + } + if dbg != nil { + dbg.Logf("spawn: wizard pid=%d started", cmd.Process.Pid) + } +} + +// reopenSetupWizard is the OnOpenSetup callback the tray hands to its menu. +// Same as spawnSetupWizard but without a debug log (the tray runs in the +// long-lived parent process where re-opening logs would be noisy). +func reopenSetupWizard() { + spawnSetupWizard(nil) +} + func trayCmd() *cobra.Command { return &cobra.Command{ Use: "tray", @@ -387,14 +734,18 @@ This is typically auto-started on Windows login when installed via MSI.`, } func runTray() error { - // Load agent config to get server URL and IPC settings cfg, err := config.Load(cfgFile) if err != nil { - // Continue without config - tray can still show status + cfg = config.Default() + } + return runTrayWithOpts(cfg, cfg.Agent.ID == "") +} + +func runTrayWithOpts(cfg *config.Config, needsSetup bool) error { + if cfg == nil { cfg = config.Default() } - // Create and run tray application app := tray.NewApp(tray.AppConfig{ Version: Version, IPCAddress: cfg.IPC.Address, @@ -402,9 +753,10 @@ func runTray() error { ServerURL: cfg.Server.URL, DashboardURL: getDashboardURL(cfg.Server.URL), LogFile: cfg.Logging.File, + NeedsSetup: needsSetup, + OnOpenSetup: reopenSetupWizard, }) - // Handle signals for graceful shutdown sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) @@ -413,7 +765,6 @@ func runTray() error { app.Quit() }() - // Run the tray app (blocking) app.Run() return nil } diff --git a/agent/cmd/agent/msgbox_other.go b/agent/cmd/agent/msgbox_other.go new file mode 100644 index 00000000..a72c6aa1 --- /dev/null +++ b/agent/cmd/agent/msgbox_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package main + +const ( + mbIconError = 0 + mbIconInfo = 0 +) + +func showMessageBox(_ string, _ string, _ uint32) {} diff --git a/agent/cmd/agent/msgbox_windows.go b/agent/cmd/agent/msgbox_windows.go new file mode 100644 index 00000000..9167b5b8 --- /dev/null +++ b/agent/cmd/agent/msgbox_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package main + +import ( + "syscall" + "unsafe" +) + +const ( + mbIconError = 0x00000010 + mbIconInfo = 0x00000040 +) + +// showMessageBox is a tiny user32!MessageBoxW wrapper for early-stage failures +// where we either don't have walk loaded yet or we want to display before the +// main window exists. Modal; blocks until the user clicks OK. +func showMessageBox(title, body string, icon uint32) { + user32 := syscall.NewLazyDLL("user32.dll") + mb := user32.NewProc("MessageBoxW") + t, _ := syscall.UTF16PtrFromString(title) + b, _ := syscall.UTF16PtrFromString(body) + _, _, _ = mb.Call(0, uintptr(unsafe.Pointer(b)), uintptr(unsafe.Pointer(t)), uintptr(icon)) +} diff --git a/agent/cmd/agent/pair.go b/agent/cmd/agent/pair.go new file mode 100644 index 00000000..608fb98a --- /dev/null +++ b/agent/cmd/agent/pair.go @@ -0,0 +1,258 @@ +package main + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/serverkit/agent/internal/config" + "github.com/serverkit/agent/internal/logger" + "github.com/serverkit/agent/internal/metrics" + "github.com/serverkit/agent/internal/pairing" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// pairCmd implements `serverkit-agent pair` — RustDesk-style short-code pairing. +// +// Flags: +// +// --server, -s Panel base URL (e.g. https://panel.example.com) +// --passphrase Passphrase to set on this agent (will prompt if absent) +// --headless No interactive prompts; read passphrase from $SERVERKIT_AGENT_PASSPHRASE +// --set-passphrase Write/overwrite passphrase only; don't begin pairing +func pairCmd() *cobra.Command { + var ( + serverURL string + passphrase string + headless bool + setPassphrase bool + ) + + cmd := &cobra.Command{ + Use: "pair", + Short: "Pair this agent with a ServerKit panel using a short code", + Long: `Pair this agent with a ServerKit panel. + +Generates an Ed25519 keypair (cached at-rest), enrolls with the panel, and +displays a rotating 6-character pair code. An operator enters that code + +the passphrase set here in the panel UI to claim this server. Once claimed, +credentials are saved and the agent is ready to start. + +This is the recommended way to add a server. Run 'serverkit-agent register' +only if you have a long pre-shared registration token.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runPair(serverURL, passphrase, headless, setPassphrase) + }, + } + + cmd.Flags().StringVarP(&serverURL, "server", "s", "", "ServerKit panel URL (required, unless previously paired)") + cmd.Flags().StringVar(&passphrase, "passphrase", "", "passphrase (will prompt if empty)") + cmd.Flags().BoolVar(&headless, "headless", false, "no interactive prompts; read $SERVERKIT_AGENT_PASSPHRASE") + cmd.Flags().BoolVar(&setPassphrase, "set-passphrase", false, "store passphrase only; don't pair yet") + + return cmd +} + +func runPair(serverURL, passphrase string, headless, setPassphraseOnly bool) error { + log := logger.New(config.LoggingConfig{Level: "info"}) + + // Resolve passphrase + if passphrase == "" && headless { + passphrase = os.Getenv("SERVERKIT_AGENT_PASSPHRASE") + if passphrase == "" { + return fmt.Errorf("--headless requires SERVERKIT_AGENT_PASSPHRASE env var") + } + } + if passphrase == "" { + var err error + passphrase, err = promptPassphrase() + if err != nil { + return err + } + } + if len(passphrase) < 4 { + return fmt.Errorf("passphrase must be at least 4 characters") + } + + // Persist passphrase reference (for later re-pair flows). We never write + // the plaintext to disk; we only store a marker so the tray can show + // "passphrase set". The bcrypt'd canonical form lives on the panel. + if err := writePassphraseMarker(); err != nil { + log.Warn("could not write passphrase marker", "error", err) + } + + if setPassphraseOnly { + fmt.Println("Passphrase stored. Run 'serverkit-agent pair --server ' when ready to pair.") + return nil + } + + if serverURL == "" { + return fmt.Errorf("--server is required (e.g. --server https://panel.example.com)") + } + + // Load or create Ed25519 pairing keypair + kp, err := pairing.LoadOrCreate(pairing.DefaultKeyPath()) + if err != nil { + return fmt.Errorf("load keypair: %w", err) + } + + // Collect system info + collector := metrics.NewCollector(config.MetricsConfig{}, log) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + sysInfo, _ := collector.GetSystemInfo(ctx) + sysMap := map[string]interface{}{} + if sysInfo != nil { + sysMap = map[string]interface{}{ + "hostname": sysInfo.Hostname, + "os": sysInfo.OS, + "platform": sysInfo.Platform, + "platform_version": sysInfo.PlatformVersion, + "architecture": sysInfo.Architecture, + "cpu_cores": sysInfo.CPUCores, + "total_memory": sysInfo.TotalMemory, + "total_disk": sysInfo.TotalDisk, + "agent_version": Version, + } + } else { + hostname, _ := os.Hostname() + sysMap["hostname"] = hostname + sysMap["os"] = runtime.GOOS + sysMap["architecture"] = runtime.GOARCH + sysMap["agent_version"] = Version + } + + // Enroll + client := pairing.NewClient(serverURL, log) + enrollCtx, enrollCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer enrollCancel() + + enrollResp, err := client.Enroll(enrollCtx, pairing.EnrollRequest{ + Pubkey: kp.PublicKeyHex(), + Passphrase: passphrase, + MachineID: config.MachineID(), + SystemInfo: sysMap, + }) + if err != nil { + return fmt.Errorf("enroll failed: %w", err) + } + + printPairBanner(enrollResp.PairCodeFormatted, enrollResp.PubkeyFingerprint) + + // Wait for claim (with periodic code rotation) + claimCtx, claimCancel := context.WithCancel(context.Background()) + defer claimCancel() + + creds, err := client.WaitForClaim(claimCtx, func(code, formatted, expiresAt string) { + printPairBanner(formatted, enrollResp.PubkeyFingerprint) + }) + if err != nil { + return fmt.Errorf("waiting for claim: %w", err) + } + + // Save credentials and config + cfg, err := config.Load(cfgFile) + if err != nil { + cfg = config.Default() + } + + wsURL := strings.TrimSuffix(serverURL, "/") + wsURL = strings.Replace(wsURL, "https://", "wss://", 1) + wsURL = strings.Replace(wsURL, "http://", "ws://", 1) + cfg.Server.URL = wsURL + "/agent" + cfg.Agent.ID = creds.AgentID + cfg.Agent.Name = creds.Name + cfg.Auth.APIKey = creds.APIKey + cfg.Auth.APISecret = creds.APISecret + + configPath := cfgFile + if configPath == "" { + configPath = config.DefaultConfigPath() + } + if cfgFile != "" { + cfg.Auth.KeyFile = filepath.Join(filepath.Dir(configPath), "agent.key") + } + if err := cfg.Save(configPath); err != nil { + return fmt.Errorf("save config: %w", err) + } + if err := cfg.SaveCredentials(); err != nil { + return fmt.Errorf("save credentials: %w", err) + } + + fmt.Println() + fmt.Println("✓ Pairing successful!") + fmt.Printf(" Server name: %s\n", creds.Name) + fmt.Printf(" Agent ID: %s\n", creds.AgentID) + fmt.Printf(" Fingerprint: %s\n", enrollResp.PubkeyFingerprint) + fmt.Println() + fmt.Println("Start the agent with: serverkit-agent start") + return nil +} + +func printPairBanner(code, fingerprint string) { + fmt.Println() + fmt.Println("┌──────────────────────────────────────────────┐") + fmt.Println("│ ServerKit Agent — Pairing │") + fmt.Println("├──────────────────────────────────────────────┤") + fmt.Printf("│ Pair code: %-30s│\n", code) + fmt.Printf("│ Fingerprint: %-30s│\n", fingerprint) + fmt.Println("├──────────────────────────────────────────────┤") + fmt.Println("│ 1. Open ServerKit panel → Add Server │") + fmt.Println("│ 2. Enter the pair code + your passphrase │") + fmt.Println("│ 3. Verify the fingerprint matches │") + fmt.Println("└──────────────────────────────────────────────┘") + fmt.Println("Waiting for claim...") +} + +func promptPassphrase() (string, error) { + if !term.IsTerminal(int(syscall.Stdin)) { + // Fallback: read line from stdin (e.g., piped postinst input) + reader := bufio.NewReader(os.Stdin) + fmt.Print("Passphrase: ") + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil + } + fmt.Print("Set a passphrase for this agent (4+ chars, you'll need it to pair): ") + pass, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "", err + } + fmt.Print("Confirm passphrase: ") + pass2, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "", err + } + if string(pass) != string(pass2) { + return "", fmt.Errorf("passphrases do not match") + } + return string(pass), nil +} + +// writePassphraseMarker writes a non-secret marker file used by the tray to +// indicate that a passphrase has been configured. It does NOT contain the +// passphrase itself. +func writePassphraseMarker() error { + dir := filepath.Dir(pairing.DefaultKeyPath()) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + marker := filepath.Join(dir, "passphrase.set") + // Random non-secret token so the file timestamps differ across resets. + buf := make([]byte, 16) + _, _ = rand.Read(buf) + return os.WriteFile(marker, []byte(base64.RawURLEncoding.EncodeToString(buf)), 0600) +} diff --git a/agent/cmd/agent/rsrc_windows_amd64.syso b/agent/cmd/agent/rsrc_windows_amd64.syso new file mode 100644 index 00000000..4fa39879 Binary files /dev/null and b/agent/cmd/agent/rsrc_windows_amd64.syso differ diff --git a/agent/cmd/agent/rsrc_windows_arm64.syso b/agent/cmd/agent/rsrc_windows_arm64.syso new file mode 100644 index 00000000..e6663a50 Binary files /dev/null and b/agent/cmd/agent/rsrc_windows_arm64.syso differ diff --git a/agent/cmd/agent/service_other.go b/agent/cmd/agent/service_other.go new file mode 100644 index 00000000..7fad4d1c --- /dev/null +++ b/agent/cmd/agent/service_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package main + +import "context" + +// runAsService is a no-op stub on POSIX — systemd handles process +// supervision, so there's nothing to dispatch. +func runAsService(runFn func(ctx context.Context) error) error { + return runFn(context.Background()) +} + +func isWindowsService() bool { return false } diff --git a/agent/cmd/agent/service_windows.go b/agent/cmd/agent/service_windows.go new file mode 100644 index 00000000..1ad8f853 --- /dev/null +++ b/agent/cmd/agent/service_windows.go @@ -0,0 +1,100 @@ +//go:build windows + +package main + +import ( + "context" + "fmt" + + "golang.org/x/sys/windows/svc" +) + +// serviceHandler implements the Windows Service Control dispatcher +// contract. SCM launches the binary, calls Execute on a serviceHandler, +// expects status callbacks via the supplied channel within 30s. Without +// this dispatcher the binary runs normally but never signals +// SERVICE_RUNNING and SCM kills it with error 1053. Agents on Windows +// have been silently failing as services since the 1.0 line because of +// this — the whole "Start-Service ServerKitAgent" path was broken. +type serviceHandler struct { + // runFn is the actual agent loop. Returns when ctx is cancelled or + // the agent terminates on its own. We pass it a cancellable ctx and + // flip cancel when SCM tells us to stop. + runFn func(ctx context.Context) error +} + +// Execute is invoked by svc.Run after the SCM connection is established. +// We immediately tell SCM we're starting, then SCM we're running, then +// run the agent loop in a goroutine while we listen for stop/shutdown +// requests on the supplied channel. The svc package owns the timing of +// the SCM handshake so all we need to do is push the right StateRunning +// / StateStopped statuses at the right moments. +func (h *serviceHandler) Execute(args []string, r <-chan svc.ChangeRequest, status chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) { + const accepted = svc.AcceptStop | svc.AcceptShutdown + status <- svc.Status{State: svc.StartPending} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + runDone := make(chan error, 1) + go func() { + runDone <- h.runFn(ctx) + }() + + status <- svc.Status{State: svc.Running, Accepts: accepted} + + for { + select { + case c := <-r: + switch c.Cmd { + case svc.Interrogate: + // SCM polling for liveness — re-echo our status so it + // knows we're still alive. Required by the contract. + status <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + status <- svc.Status{State: svc.StopPending} + cancel() + // Wait for the agent loop to exit cleanly. svc.Run + // internally allows up to 30s after StopPending + // before SCM force-kills, which matches the existing + // shutdown timing in agent.Run. + <-runDone + status <- svc.Status{State: svc.Stopped} + return false, 0 + default: + // Unknown control code — ignore. SCM doesn't send + // pause/continue without us advertising support. + } + case err := <-runDone: + // Agent loop exited on its own (config error, fatal panic + // caught upstream, etc.). Tell SCM we're done so the + // service shows as stopped instead of "running but dead". + if err != nil && err != context.Canceled { + status <- svc.Status{State: svc.Stopped, Win32ExitCode: 1} + return false, 1 + } + status <- svc.Status{State: svc.Stopped} + return false, 0 + } + } +} + +// runAsService is the SCM entry point. Called from runAgent when we +// detect we're in a service context. Blocks until SCM tells us to stop. +func runAsService(runFn func(ctx context.Context) error) error { + if err := svc.Run("ServerKitAgent", &serviceHandler{runFn: runFn}); err != nil { + return fmt.Errorf("service dispatcher failed: %w", err) + } + return nil +} + +// isWindowsService reports whether the current process was launched by +// the SCM. The svc package detects this by inspecting the process tree +// and the Windows session — there's no other reliable signal. +func isWindowsService() bool { + in, err := svc.IsWindowsService() + if err != nil { + return false + } + return in +} diff --git a/agent/cmd/agent/spawn_other.go b/agent/cmd/agent/spawn_other.go new file mode 100644 index 00000000..3302450b --- /dev/null +++ b/agent/cmd/agent/spawn_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package main + +import "syscall" + +func detachedProcessAttrs() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setpgid: true} +} diff --git a/agent/cmd/agent/spawn_windows.go b/agent/cmd/agent/spawn_windows.go new file mode 100644 index 00000000..ddc4c6e8 --- /dev/null +++ b/agent/cmd/agent/spawn_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package main + +import "syscall" + +const ( + // CreateNoWindow: hide the new console window if any. + createNoWindow = 0x08000000 + // DetachedProcess: child process is not bound to the parent's console. + detachedProcess = 0x00000008 +) + +// detachedProcessAttrs returns SysProcAttr that detaches the spawned process +// from the parent without smothering its UI. Used when the tray spawns a +// fresh `serverkit-agent setup` to relaunch the wizard. +// +// HideWindow MUST be false here. STARTUPINFO.wShowWindow propagates to the +// child's first top-level window via SW_SHOWDEFAULT, so HideWindow:true +// (which sets SW_HIDE) was leaving the wizard window invisible until the +// user manually called ShowWindow from outside — exactly the "Start Menu +// blank, CLI works" inconsistency users reported in 1.6.2. The agent .exe +// is built as a GUI subsystem so there's no console window to hide; the +// flag was redundant from the start. +func detachedProcessAttrs() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + HideWindow: false, + CreationFlags: createNoWindow | detachedProcess, + } +} diff --git a/agent/go.mod b/agent/go.mod index 77af4156..48563e98 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -1,14 +1,16 @@ module github.com/serverkit/agent -go 1.21 +go 1.23.0 require ( fyne.io/systray v1.11.0 github.com/creack/pty v1.1.21 github.com/docker/docker v24.0.7+incompatible github.com/gorilla/websocket v1.5.1 + github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 github.com/shirou/gopsutil/v3 v3.24.1 github.com/spf13/cobra v1.8.0 + golang.org/x/term v0.27.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -23,7 +25,10 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/lufia/plan9stats v0.0.0-20231016141302-07b5767bb0ed // indirect + github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -37,8 +42,9 @@ require ( github.com/yusufpapurcu/wmi v1.2.3 // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.19.0 // indirect - golang.org/x/sys v0.16.0 // indirect + golang.org/x/sys v0.28.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.16.1 // indirect + gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect gotest.tools/v3 v3.5.1 // indirect ) diff --git a/agent/go.sum b/agent/go.sum index beaeb7c6..c771112f 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -35,11 +35,19 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/ github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo= +github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20231016141302-07b5767bb0ed h1:036IscGBfJsFIgJQzlui7nK1Ncm0tp2ktmPj8xO4N/0= github.com/lufia/plan9stats v0.0.0-20231016141302-07b5767bb0ed/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= @@ -104,13 +112,19 @@ golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= @@ -125,6 +139,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go index 3f3fabef..f292b6c2 100644 --- a/agent/internal/agent/agent.go +++ b/agent/internal/agent/agent.go @@ -9,18 +9,28 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "net" "os" + "path/filepath" "sync" "time" "github.com/serverkit/agent/internal/auth" + "github.com/serverkit/agent/internal/capabilities" + "github.com/serverkit/agent/internal/cloudflared" "github.com/serverkit/agent/internal/config" + "github.com/serverkit/agent/internal/cron" "github.com/serverkit/agent/internal/docker" + "github.com/serverkit/agent/internal/events" + "github.com/serverkit/agent/internal/gui" "github.com/serverkit/agent/internal/ipc" + "github.com/serverkit/agent/internal/jobs" "github.com/serverkit/agent/internal/logger" "github.com/serverkit/agent/internal/metrics" "github.com/serverkit/agent/internal/terminal" + "github.com/serverkit/agent/internal/transport" + "github.com/serverkit/agent/internal/transport/poll" "github.com/serverkit/agent/internal/updater" "github.com/serverkit/agent/internal/ws" "github.com/serverkit/agent/pkg/protocol" @@ -31,11 +41,26 @@ type Agent struct { cfg *config.Config log *logger.Logger auth *auth.Authenticator - ws *ws.Client - docker *docker.Client - metrics *metrics.Collector - terminal *terminal.Manager - ipc *ipc.Server + // ws is the active transport — either the WS client directly or the + // fallback Manager that wraps WS+poll. Named "ws" for compatibility + // with the dozens of existing call sites in this file. + ws transport.Transport + docker *docker.Client + metrics *metrics.Collector + terminal *terminal.Manager + cron cron.Manager + cloudflared cloudflared.Manager + ipc *ipc.Server + sampler *metricSampler + events *events.Store + gui *gui.SDK + jobs *jobs.Registry + + // pkgLock serializes package-manager mutations (install / remove / + // upgrade / update_cache) inside a single agent so two concurrent + // requests don't race on dpkg/rpm locks. Reads (list_installed, + // search, info) don't take the lock. + pkgLock sync.Mutex // Active subscriptions subscriptions map[string]context.CancelFunc @@ -44,6 +69,25 @@ type Agent struct { // Command handlers handlers map[string]CommandHandler + // Capabilities probed at startup, refreshable via + // agent:recapabilities. Sent to the panel each time the transport + // reconnects (so a panel restart picks them up without waiting for + // the agent to also restart). capMu guards reads/writes during a + // re-probe so we don't ship a half-mutated payload. + capabilities protocol.CapabilitiesMessage + capMu sync.Mutex + reprobing bool + + // sudoMode is the agent's privilege-escalation mode (root / + // passwordless / unavailable). Probed once at startup; if the user + // adds sudoers config later, agent:recapabilities re-probes. + sudoMode SudoMode + + // systemdJSON tracks whether `systemctl --output=json` is supported + // on this host (systemd >= 244). Probed lazily on first use. + systemdJSON bool + systemdJSONOnce sync.Once + // Lifecycle tracking startTime time.Time restartCh chan struct{} @@ -59,8 +103,13 @@ func New(cfg *config.Config, log *logger.Logger) (*Agent, error) { // Create authenticator authenticator := auth.New(cfg.Agent.ID, cfg.Auth.APIKey, cfg.Auth.APISecret) - // Create WebSocket client + // Build the transport: a Manager wrapping the WS client (primary) and + // a polling client (fallback for tunnels that mangle WS frames). The + // Manager presents the same Transport surface as the bare WS client + // did, so the rest of the agent doesn't care which is active. wsClient := ws.NewClient(cfg.Server, authenticator, log) + pollClient := poll.NewClient(cfg.Server, authenticator, log) + transportClient := transport.NewManager(wsClient, pollClient, log) // Create Docker client if enabled var dockerClient *docker.Client @@ -86,25 +135,68 @@ func New(cfg *config.Config, log *logger.Logger) (*Agent, error) { log.Info("Terminal/PTY support enabled") } + // Persist the event store next to the existing config so it survives + // service restarts. Falls back to in-memory only if we can't infer a + // data directory (shouldn't happen on a normal install). + dataDir := "" + if cfg.Logging.File != "" { + dataDir = filepath.Dir(cfg.Logging.File) + } + eventStore := events.NewStore(200, events.DefaultPath(dataDir)) + agent := &Agent{ cfg: cfg, log: log, auth: authenticator, - ws: wsClient, + ws: transportClient, docker: dockerClient, metrics: metricsCollector, terminal: termManager, + cron: cron.New(), + cloudflared: cloudflared.New(), + sampler: newMetricSampler(300), // 5 min @ 1 Hz + events: eventStore, + gui: gui.New(log), + jobs: jobs.NewRegistry(64), subscriptions: make(map[string]context.CancelFunc), handlers: make(map[string]CommandHandler), startTime: time.Now(), - restartCh: make(chan struct{}), + // Buffered so an IPC restart fired before the agent's main + // select{} reaches the receive doesn't hang the IPC handler. + // At most one restart can be in-flight at a time anyway — + // Restart() uses a non-blocking send and returns "already in + // progress" if the slot is full. + restartCh: make(chan struct{}, 1), + } + + // Probe capabilities up-front so connectionWatcher can ship them on + // every reconnect without re-probing. Docker availability is taken + // from whether NewClient succeeded above; the capability layer + // doesn't redo that work. + probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second) + agent.capabilities = capabilities.Probe( + probeCtx, + log, + dockerClient != nil, + cfg.Features.FileAccess, + cfg.Security.AllowedPaths, + ) + agent.sudoMode = probeSudoMode(probeCtx) + agent.capabilities.Sudo = string(agent.sudoMode) + agent.capabilities.RuntimeManagers = map[string]string{ + "python": pyenvManagerKind(), } + agent.capabilities.ProbedAt = time.Now().UnixMilli() + probeCancel() + log.Info("Sudo probe complete", "mode", agent.sudoMode) // Register command handlers agent.registerHandlers() - // Set WebSocket message handler - wsClient.SetHandler(agent.handleMessage) + // Install the message handler on the transport (manager fans this + // out to both backends so a fallback switch doesn't drop the + // binding). + transportClient.SetHandler(agent.handleMessage) // Create IPC server if enabled if cfg.IPC.Enabled { @@ -120,24 +212,30 @@ func (a *Agent) registerHandlers() { if a.docker != nil { a.handlers[protocol.ActionDockerContainerList] = a.handleDockerContainerList a.handlers[protocol.ActionDockerContainerInspect] = a.handleDockerContainerInspect + a.handlers[protocol.ActionDockerContainerCreate] = a.handleDockerContainerCreate a.handlers[protocol.ActionDockerContainerStart] = a.handleDockerContainerStart a.handlers[protocol.ActionDockerContainerStop] = a.handleDockerContainerStop a.handlers[protocol.ActionDockerContainerRestart] = a.handleDockerContainerRestart a.handlers[protocol.ActionDockerContainerRemove] = a.handleDockerContainerRemove a.handlers[protocol.ActionDockerContainerStats] = a.handleDockerContainerStats a.handlers[protocol.ActionDockerContainerLogs] = a.handleDockerContainerLogs + a.handlers[protocol.ActionDockerContainerExec] = a.handleDockerContainerExec // Docker image commands a.handlers[protocol.ActionDockerImageList] = a.handleDockerImageList a.handlers[protocol.ActionDockerImagePull] = a.handleDockerImagePull a.handlers[protocol.ActionDockerImageRemove] = a.handleDockerImageRemove + a.handlers[protocol.ActionDockerImageBuild] = a.handleDockerImageBuild // Docker volume commands a.handlers[protocol.ActionDockerVolumeList] = a.handleDockerVolumeList + a.handlers[protocol.ActionDockerVolumeCreate] = a.handleDockerVolumeCreate a.handlers[protocol.ActionDockerVolumeRemove] = a.handleDockerVolumeRemove // Docker network commands a.handlers[protocol.ActionDockerNetworkList] = a.handleDockerNetworkList + a.handlers[protocol.ActionDockerNetworkCreate] = a.handleDockerNetworkCreate + a.handlers[protocol.ActionDockerNetworkRemove] = a.handleDockerNetworkRemove // Docker compose commands a.handlers[protocol.ActionDockerComposeList] = a.handleDockerComposeList @@ -171,8 +269,84 @@ func (a *Agent) registerHandlers() { a.handlers[protocol.ActionTerminalClose] = a.handleTerminalClose } + // Cron commands — Linux-only, but registered unconditionally so a + // stub returns a clear error on Windows/macOS instead of "unknown + // action". + a.handlers[protocol.ActionCronStatus] = a.handleCronStatus + a.handlers[protocol.ActionCronList] = a.handleCronList + a.handlers[protocol.ActionCronAdd] = a.handleCronAdd + a.handlers[protocol.ActionCronRemove] = a.handleCronRemove + a.handlers[protocol.ActionCronToggle] = a.handleCronToggle + + // Cloudflared commands — same Linux-only/stub pattern. Auth state + // (cert.pem present) is exposed via :status, not via the + // capabilities probe, so the panel can distinguish "binary + // installed but not logged in" from "not installed at all." + a.handlers[protocol.ActionCloudflaredStatus] = a.handleCloudflaredStatus + a.handlers[protocol.ActionCloudflaredLogin] = a.handleCloudflaredLogin + a.handlers[protocol.ActionCloudflaredTunnelList] = a.handleCloudflaredTunnelList + a.handlers[protocol.ActionCloudflaredTunnelCreate] = a.handleCloudflaredTunnelCreate + a.handlers[protocol.ActionCloudflaredTunnelRoute] = a.handleCloudflaredTunnelRoute + a.handlers[protocol.ActionCloudflaredTunnelDelete] = a.handleCloudflaredTunnelDelete + + // Phase 4 primitives — packages, systemd, exec. Registered + // unconditionally so non-Linux agents return a clear error rather + // than "unknown action"; the capability probe gates the panel's + // target picker so users don't normally hit this on Windows. + a.handlers[protocol.ActionPackagesInstall] = a.handlePackagesInstall + a.handlers[protocol.ActionPackagesRemove] = a.handlePackagesRemove + a.handlers[protocol.ActionPackagesListInstalled] = a.handlePackagesListInstalled + a.handlers[protocol.ActionPackagesUpdateCache] = a.handlePackagesUpdateCache + a.handlers[protocol.ActionPackagesInstallAsync] = a.handlePackagesInstallAsync + a.handlers[protocol.ActionPackagesUpgrade] = a.handlePackagesUpgrade + a.handlers[protocol.ActionPackagesSearch] = a.handlePackagesSearch + a.handlers[protocol.ActionPackagesInfo] = a.handlePackagesInfo + a.handlers[protocol.ActionSystemdStatus] = a.handleSystemdStatus + a.handlers[protocol.ActionSystemdStart] = a.handleSystemdStart + a.handlers[protocol.ActionSystemdStop] = a.handleSystemdStop + a.handlers[protocol.ActionSystemdRestart] = a.handleSystemdRestart + a.handlers[protocol.ActionSystemdEnable] = a.handleSystemdEnable + a.handlers[protocol.ActionSystemdDisable] = a.handleSystemdDisable + a.handlers[protocol.ActionSystemdDaemonReload] = a.handleSystemdDaemonReload + a.handlers[protocol.ActionSystemdListUnits] = a.handleSystemdListUnits + a.handlers[protocol.ActionSystemdLogs] = a.handleSystemdLogs + a.handlers[protocol.ActionSystemdLogsFollow] = a.handleSystemdLogsFollow + + // system:exec is gated on Features.Exec rather than registered + // unconditionally — the previous version installed the handler + // regardless of the feature flag, which made the flag misleading + // (Exec=false still let the panel run any command). The handler + // itself enforces this too, but failing fast at registration means + // the panel sees a clean "unknown action" instead of a runtime + // error and can disable the feature in its UI. + if a.cfg.Features.Exec { + a.handlers[protocol.ActionSystemExec] = a.handleSystemExec + } + // Agent commands a.handlers[protocol.ActionAgentUpdate] = a.handleAgentUpdate + a.handlers[protocol.ActionAgentRecapabilities] = a.handleAgentRecapabilities + + // Runtime version managers (Phase 5). pyenv on Linux, + // pyenv-win on Windows. Always registered so pages that probe + // runtimes:list can tell "manager not installed" from "unknown + // action" — bootstrap then becomes a one-click action. + a.handlers[protocol.ActionRuntimesList] = a.handleRuntimesList + a.handlers[protocol.ActionRuntimesPyenvBootstrap] = a.handleRuntimesPyenvBootstrap + a.handlers[protocol.ActionRuntimesPythonInstalled] = a.handleRuntimesPythonInstalled + a.handlers[protocol.ActionRuntimesPythonAvailable] = a.handleRuntimesPythonAvailable + a.handlers[protocol.ActionRuntimesPythonInstall] = a.handleRuntimesPythonInstall + a.handlers[protocol.ActionRuntimesPythonUninstall] = a.handleRuntimesPythonUninstall + a.handlers[protocol.ActionRuntimesPythonSetGlobal] = a.handleRuntimesPythonSetGlobal + a.handlers[protocol.ActionRuntimesPythonSetLocal] = a.handleRuntimesPythonSetLocal + a.handlers[protocol.ActionRuntimesPythonCurrent] = a.handleRuntimesPythonCurrent + + // GUI SDK — primitives that panel extensions (serverkit-gui, etc.) + // compose into desktop-streaming or synthetic-UI features. Always + // registered; the SDK reports "none" capability on hosts that can't + // capture, so plugins can probe instead of failing. + a.handlers[protocol.ActionGUICapabilities] = a.gui.HandleCapabilities + a.handlers[protocol.ActionGUIScreenshot] = a.gui.HandleScreenshot } // Run starts the agent @@ -180,8 +354,21 @@ func (a *Agent) Run(ctx context.Context) error { a.log.Info("Starting agent", "agent_id", a.cfg.Agent.ID, "version", Version, - "features", fmt.Sprintf("docker=%v metrics=%v ipc=%v", a.cfg.Features.Docker, a.cfg.Features.Metrics, a.cfg.IPC.Enabled), + "features", fmt.Sprintf("docker=%v metrics=%v ipc=%v exec=%v file_access=%v", a.cfg.Features.Docker, a.cfg.Features.Metrics, a.cfg.IPC.Enabled, a.cfg.Features.Exec, a.cfg.Features.FileAccess), ) + // Surface insecure-TLS at WARN every startup so a misconfigured + // deployment script can't silently disable certificate verification + // across every TLS dial in the agent (we have four independent + // tls.Configs respecting this env var). The user explicitly opted + // in via env var, so don't refuse to start — just make it loud. + if os.Getenv("SERVERKIT_INSECURE_TLS") == "true" { + a.log.Warn("SERVERKIT_INSECURE_TLS=true: TLS certificate verification is DISABLED for all panel connections; only use this for local development") + a.events.Append(events.KindInfo, events.SeverityWarn, + "Insecure TLS mode enabled (SERVERKIT_INSECURE_TLS)", nil) + } + a.events.Append(events.KindServiceStart, events.SeverityInfo, + "Agent service started", + map[string]interface{}{"version": Version}) // Verify Docker connection if enabled if a.docker != nil { @@ -213,11 +400,25 @@ func (a *Agent) Run(ctx context.Context) error { // Start discovery responder go a.discoveryLoop(ctx) + // Start the desktop-console metrics sampler (1 Hz ring buffer, 5 min) + if a.sampler != nil { + go a.samplerLoop(ctx) + } + + // Watch WS connection state and emit Activity events on transitions. + // Polling at 1 Hz is fine — the activity tab is a human-readable + // timeline, not a real-time monitor. + go a.connectionWatcher(ctx) + // Wait for context cancellation or restart request select { case <-ctx.Done(): + a.events.Append(events.KindServiceStop, events.SeverityInfo, + "Agent stopping (signal)", nil) case <-a.restartCh: a.log.Info("Restart requested") + a.events.Append(events.KindRestartRequested, events.SeverityInfo, + "Restart requested via IPC", nil) } // Cleanup @@ -255,6 +456,21 @@ func (a *Agent) discoveryLoop(ctx context.Context) { conn.SetReadDeadline(time.Now().Add(1 * time.Second)) n, remoteAddr, err := conn.ReadFromUDP(buf) if err != nil { + // A read deadline timeout is the normal case (no + // inbound discovery this second) — fall through and + // wait again. A real socket error (closed FD, ICMP + // unreachable storms) used to busy-loop here at + // 100% CPU; sleep briefly so the loop doesn't spin + // while still being responsive to ctx cancellation. + if ne, ok := err.(net.Error); ok && ne.Timeout() { + continue + } + a.log.Debug("UDP discovery read error", "error", err) + select { + case <-ctx.Done(): + return + case <-time.After(250 * time.Millisecond): + } continue } @@ -328,6 +544,183 @@ func abs(x int64) int64 { return x } +// connectionWatcher polls the WS connection flag and emits Activity events +// on each transition. Avoids dragging events.Store into the ws package or +// adding a new callback API for one consumer. +// +// Also doubles as the "send capabilities on connect" hook: each time the +// transport flips from down→up, we re-ship the capability map so the +// panel's record stays current after a panel restart. +// +// On top of the transition trigger, we also re-send capabilities on a +// 60-second cadence whenever the transport is up. The transition path +// is fragile when the WS-then-poll fallback dance briefly raises +// IsConnected() against a backend that's about to be replaced — the +// message can land on a transport whose Send() silently drops it +// (or buffers it forever). Periodic re-sends mean a single missed +// transition doesn't leave the panel with an empty capability map for +// the lifetime of the agent process; the worst case is a 60-second +// gap before the next refresh. +func (a *Agent) connectionWatcher(ctx context.Context) { + transitionTicker := time.NewTicker(1 * time.Second) + defer transitionTicker.Stop() + // Aggressive cadence for the first minute so a cold-boot agent + // populates the panel within ~5s of the first successful transport + // connect. Aaron's law: the WS-then-poll fallback dance can land + // the transition's send on a backend that's about to be replaced, + // and the panel ends up with an empty capability map until the + // next steady-state tick fires. + resendTicker := time.NewTicker(5 * time.Second) + defer resendTicker.Stop() + resendTicks := 0 + + prev := false + first := true + for { + select { + case <-ctx.Done(): + return + case <-resendTicker.C: + // Idempotent re-send. The panel's update_capabilities is a + // pure overwrite, so re-shipping the same payload costs us + // nothing; the panel just rewrites the same row. + if a.ws.IsConnected() { + a.sendCapabilities() + a.sendSystemInfo(ctx) + } + resendTicks++ + // After ~60 seconds (12 ticks at 5s), back the cadence off + // to once a minute. The first minute is the only window + // where transport churn is likely; long-lived connections + // don't need the heavier pulse. + if resendTicks == 12 { + resendTicker.Reset(60 * time.Second) + } + case <-transitionTicker.C: + now := a.ws.IsConnected() + if now == prev && !first { + continue + } + first = false + if now { + a.events.Append(events.KindWSConnected, events.SeverityInfo, + "Connected to panel", + map[string]interface{}{"url": a.cfg.Server.URL}) + a.sendCapabilities() + a.sendSystemInfo(ctx) + } else if prev { + // Only log disconnect after we'd previously been up — avoids + // a spurious "lost connection" on cold-start before the + // first connect ever succeeds. + a.events.Append(events.KindWSDisconnected, events.SeverityWarn, + "Connection lost", nil) + } + prev = now + } + } +} + +// sendSystemInfo collects the host's system info (CPU, memory, disk, +// hostname, OS, kernel) and pushes it to the panel as a typed +// SystemInfoMessage so it lands in update_system_info → DB. The agent +// previously only answered system:info synchronously, which meant the +// panel never persisted the values — Overview would show "N/A" for +// CPU/memory/disk whenever the page was loaded against a server whose +// info hadn't been queried yet (or if the query timed out). +// +// Also surfaces the agent's local IPv4 (first non-loopback) so the +// panel can show *which* host is reporting in, separate from the +// public IP it sees on the inbound connection. +func (a *Agent) sendSystemInfo(ctx context.Context) { + if a.metrics == nil { + return + } + infoCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + info, err := a.metrics.GetSystemInfo(infoCtx) + if err != nil || info == nil { + a.log.Debug("system info collect failed", "error", err) + return + } + // Build the protocol shape — different field names than the metrics + // package's SystemInfo (CPUCores vs CPUThreads, etc.). Send the + // most useful subset and let the panel's update_system_info pick + // the keys it cares about. + payload := protocol.SystemInfoMessage{ + Message: protocol.NewMessage(protocol.TypeSystemInfo, auth.GenerateNonce()), + Info: protocol.SystemInfo{ + Hostname: info.Hostname, + OS: info.OS, + OSVersion: info.PlatformVersion, + Platform: info.Platform, + Architecture: info.Architecture, + CPUModel: info.CPUModel, + CPUCores: info.CPUThreads, // surface threads as "cores" — matches the panel's existing column + TotalMemory: info.TotalMemory, + TotalDisk: info.TotalDisk, + DockerVersion: a.dockerVersion(), + AgentVersion: Version, + }, + } + if err := a.ws.Send(payload); err != nil { + a.log.Debug("system info send failed", "error", err) + } +} + +// dockerVersion returns the running Docker daemon version, or "" if +// docker isn't reachable (which the capability probe already +// surfaces). Best-effort — we don't want a dockerd hiccup to block +// system_info delivery. +func (a *Agent) dockerVersion() string { + if a.docker == nil { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + v, err := a.docker.Version(ctx) + if err != nil { + return "" + } + return v +} + +// sendCapabilities ships the cached capability probe to the panel. +// Failures are logged and ignored — capabilities are best-effort +// metadata; missing ones just mean an agent doesn't show up in feature +// pickers until the next reconnect. +func (a *Agent) sendCapabilities() { + a.capMu.Lock() + caps := a.capabilities + a.capMu.Unlock() + msg := protocol.CapabilitiesMessage{ + Message: protocol.NewMessage(protocol.TypeCapabilities, auth.GenerateNonce()), + Capabilities: caps.Capabilities, + Platform: caps.Platform, + Distro: caps.Distro, + DistroVersion: caps.DistroVersion, + Runtimes: caps.Runtimes, + AllowedPaths: caps.AllowedPaths, + Sudo: caps.Sudo, + RuntimeManagers: caps.RuntimeManagers, + SystemdJSON: caps.SystemdJSON, + ProbedAt: caps.ProbedAt, + } + a.log.Info("Sending capabilities to panel", + "sudo", caps.Sudo, + "cap_count", len(caps.Capabilities), + "runtime_count", len(caps.Runtimes), + ) + if err := a.ws.Send(msg); err != nil { + a.log.Warn("Failed to send capabilities", "error", err) + return + } + a.log.Debug("Sent capabilities to panel", + "platform", caps.Platform, + "distro", caps.Distro, + "sudo", caps.Sudo, + ) +} + // heartbeatLoop sends periodic heartbeats func (a *Agent) heartbeatLoop(ctx context.Context) { ticker := time.NewTicker(a.cfg.Server.PingInterval) @@ -414,9 +807,11 @@ func (a *Agent) handleCommand(data []byte) { return } - // Execute command with enforced maximum timeout + // Execute command with enforced maximum timeout. The ceiling is + // configurable via Security.MaxExecTimeout; resolveMaxExecTimeout + // returns the operator's value or the default safety net. start := time.Now() - maxTimeout := 5 * time.Minute + maxTimeout := a.resolveMaxExecTimeout() cmdTimeout := time.Duration(cmd.Timeout) * time.Millisecond if cmdTimeout <= 0 || cmdTimeout > maxTimeout { @@ -458,6 +853,20 @@ func (a *Agent) handleSubscribe(data []byte) { a.log.Info("Subscribing to channel", "channel", sub.Channel) + // Replay any buffered events for job channels so a panel that + // subscribed mid-flight (or just after the job finished) still sees + // the full progress history. Replay before installing the + // subscription record so a brand-new "live" event can't get + // interleaved before the replay. + if job := a.jobs.LookupByChannel(sub.Channel); job != nil { + for _, ev := range job.Replay() { + if err := a.ws.SendStream(sub.Channel, ev); err != nil { + a.log.Warn("Failed to replay job event", "channel", sub.Channel, "error", err) + break + } + } + } + // Create cancellable context for this subscription ctx, cancel := context.WithCancel(context.Background()) @@ -529,8 +938,16 @@ func (a *Agent) streamMetrics(ctx context.Context, channel string) { } } -// cleanup performs cleanup on shutdown -// handleCredentialUpdate handles credential rotation from server +// handleCredentialUpdate handles credential rotation from server. +// +// Before applying the rotation we verify an HMAC over the new +// credentials computed with the agent's current secret. The session- +// level WS auth alone is not sufficient: the panel is a large surface +// and a session token leak (or a panel-side bug that lets an +// authenticated user cross server boundaries) would otherwise let an +// attacker rotate any agent's credentials to ones they control. The +// extra check means an attacker has to also know the agent's current +// secret — which is exactly what we're trying to protect. func (a *Agent) handleCredentialUpdate(data []byte) { var msg protocol.CredentialUpdateMessage if err := json.Unmarshal(data, &msg); err != nil { @@ -540,6 +957,21 @@ func (a *Agent) handleCredentialUpdate(data []byte) { a.log.Info("Received credential update request", "rotation_id", msg.RotationID) + if err := a.verifyCredentialRotation(msg); err != nil { + a.log.Warn("Rejecting credential rotation", "rotation_id", msg.RotationID, "error", err) + a.events.Append(events.KindAuthFailed, events.SeverityWarn, + "Credential rotation rejected: "+err.Error(), + map[string]interface{}{"rotation_id": msg.RotationID}) + ack := protocol.CredentialUpdateAck{ + Message: protocol.NewMessage(protocol.TypeCredentialUpdateAck, auth.GenerateNonce()), + RotationID: msg.RotationID, + Success: false, + Error: err.Error(), + } + a.ws.Send(ack) + return + } + // Update authenticator with new credentials a.auth.UpdateCredentials(msg.APIKey, msg.APISecret) @@ -562,6 +994,35 @@ func (a *Agent) handleCredentialUpdate(data []byte) { a.ws.Send(ack) } +// verifyCredentialRotation checks the HMAC the panel attaches to a +// rotation message against the agent's current secret. Returns nil +// only when the signature is present, well-formed, and matches. +// +// The panel must compute hex(HMAC-SHA256( +// "rotation_id:agent_id:new_api_key:new_api_secret", +// current_api_secret)). +func (a *Agent) verifyCredentialRotation(msg protocol.CredentialUpdateMessage) error { + if msg.RotationID == "" || msg.APIKey == "" || msg.APISecret == "" { + return fmt.Errorf("rotation message missing required fields") + } + if msg.HMACSig == "" { + return fmt.Errorf("rotation message missing hmac signature") + } + currentSecret := a.auth.GetAPISecret() + if currentSecret == "" { + return fmt.Errorf("agent has no current secret; refusing to rotate") + } + payload := fmt.Sprintf("%s:%s:%s:%s", + msg.RotationID, a.cfg.Agent.ID, msg.APIKey, msg.APISecret) + mac := hmac.New(sha256.New, []byte(currentSecret)) + mac.Write([]byte(payload)) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(msg.HMACSig), []byte(expected)) { + return fmt.Errorf("hmac signature mismatch") + } + return nil +} + // saveCredentials saves new credentials to the key file func (a *Agent) saveCredentials(apiKey, apiSecret string) error { // Update config with new credentials @@ -700,13 +1161,20 @@ func (a *Agent) handleDockerContainerLogs(ctx context.Context, params json.RawMe } defer reader.Close() - // Read logs into buffer - buf := make([]byte, 1024*1024) // 1MB max - n, _ := reader.Read(buf) - logs := string(buf[:n]) + // Drain the stream up to a 1 MB cap. The previous implementation + // did a single Read() and dropped the error — Docker's stream + // framing means one Read returns whatever happens to be in the + // first chunk (often a few KB), so the panel's "logs" command + // got a tiny prefix of the requested tail. io.ReadAll over a + // LimitReader gives the panel the full payload it asked for. + const maxLogBytes = 1024 * 1024 + data, err := io.ReadAll(io.LimitReader(reader, maxLogBytes)) + if err != nil { + return nil, fmt.Errorf("read docker logs: %w", err) + } return map[string]interface{}{ - "logs": logs, + "logs": string(data), }, nil } @@ -775,6 +1243,16 @@ func (a *Agent) handleDockerNetworkList(ctx context.Context, params json.RawMess return a.docker.ListNetworks(ctx) } +func (a *Agent) handleDockerNetworkRemove(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + ID string `json:"id"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + return map[string]bool{"success": true}, a.docker.RemoveNetwork(ctx, p.ID) +} + // System command handlers func (a *Agent) handleSystemMetrics(ctx context.Context, params json.RawMessage) (interface{}, error) { @@ -789,100 +1267,7 @@ func (a *Agent) handleSystemProcesses(ctx context.Context, params json.RawMessag return a.metrics.ListProcesses(ctx) } -// File command handlers - -func (a *Agent) handleFileRead(ctx context.Context, params json.RawMessage) (interface{}, error) { - var p struct { - Path string `json:"path"` - } - if err := json.Unmarshal(params, &p); err != nil { - return nil, fmt.Errorf("invalid params: %w", err) - } - if p.Path == "" { - return nil, fmt.Errorf("path is required") - } - - data, err := os.ReadFile(p.Path) - if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) - } - - return map[string]interface{}{ - "path": p.Path, - "content": base64.StdEncoding.EncodeToString(data), - "size": len(data), - }, nil -} - -func (a *Agent) handleFileWrite(ctx context.Context, params json.RawMessage) (interface{}, error) { - var p struct { - Path string `json:"path"` - Content string `json:"content"` // base64 encoded - Mode uint32 `json:"mode"` - } - if err := json.Unmarshal(params, &p); err != nil { - return nil, fmt.Errorf("invalid params: %w", err) - } - if p.Path == "" { - return nil, fmt.Errorf("path is required") - } - - data, err := base64.StdEncoding.DecodeString(p.Content) - if err != nil { - return nil, fmt.Errorf("invalid base64 content: %w", err) - } - - mode := os.FileMode(0644) - if p.Mode != 0 { - mode = os.FileMode(p.Mode) - } - - if err := os.WriteFile(p.Path, data, mode); err != nil { - return nil, fmt.Errorf("failed to write file: %w", err) - } - - return map[string]interface{}{ - "success": true, - "path": p.Path, - "size": len(data), - }, nil -} - -func (a *Agent) handleFileList(ctx context.Context, params json.RawMessage) (interface{}, error) { - var p struct { - Path string `json:"path"` - } - if err := json.Unmarshal(params, &p); err != nil { - return nil, fmt.Errorf("invalid params: %w", err) - } - if p.Path == "" { - p.Path = "/" - } - - entries, err := os.ReadDir(p.Path) - if err != nil { - return nil, fmt.Errorf("failed to list directory: %w", err) - } - - var files []map[string]interface{} - for _, entry := range entries { - info, err := entry.Info() - if err != nil { - continue - } - files = append(files, map[string]interface{}{ - "name": entry.Name(), - "is_dir": entry.IsDir(), - "size": info.Size(), - "modified": info.ModTime().UnixMilli(), - }) - } - - return map[string]interface{}{ - "path": p.Path, - "files": files, - }, nil -} +// File command handlers live in file_handlers.go. // Docker Compose command handlers @@ -897,6 +1282,9 @@ func (a *Agent) handleDockerComposePs(ctx context.Context, params json.RawMessag if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } + if err := a.validateFileAccess(p.ProjectPath); err != nil { + return nil, err + } return a.docker.ComposePsProject(ctx, p.ProjectPath) } @@ -909,6 +1297,9 @@ func (a *Agent) handleDockerComposeUp(ctx context.Context, params json.RawMessag if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } + if err := a.validateFileAccess(p.ProjectPath); err != nil { + return nil, err + } // Default to detached mode if !p.Detach { @@ -939,6 +1330,9 @@ func (a *Agent) handleDockerComposeDown(ctx context.Context, params json.RawMess if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } + if err := a.validateFileAccess(p.ProjectPath); err != nil { + return nil, err + } output, err := a.docker.ComposeDown(ctx, p.ProjectPath, p.Volumes, p.RemoveOrphans) if err != nil { @@ -964,6 +1358,9 @@ func (a *Agent) handleDockerComposeLogs(ctx context.Context, params json.RawMess if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } + if err := a.validateFileAccess(p.ProjectPath); err != nil { + return nil, err + } // Default tail to 100 if p.Tail == 0 { @@ -988,6 +1385,9 @@ func (a *Agent) handleDockerComposeRestart(ctx context.Context, params json.RawM if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } + if err := a.validateFileAccess(p.ProjectPath); err != nil { + return nil, err + } output, err := a.docker.ComposeRestart(ctx, p.ProjectPath, p.Service) if err != nil { @@ -1012,6 +1412,9 @@ func (a *Agent) handleDockerComposePull(ctx context.Context, params json.RawMess if err := json.Unmarshal(params, &p); err != nil { return nil, fmt.Errorf("invalid params: %w", err) } + if err := a.validateFileAccess(p.ProjectPath); err != nil { + return nil, err + } output, err := a.docker.ComposePull(ctx, p.ProjectPath, p.Service) if err != nil { @@ -1193,6 +1596,7 @@ func (a *Agent) GetStatus() ipc.AgentStatus { ServerURL: a.cfg.Server.URL, Uptime: int64(time.Since(a.startTime).Seconds()), Version: Version, + Transport: string(a.ws.Mode()), } // Collect current metrics if available @@ -1256,6 +1660,40 @@ func (a *Agent) GetDetailedMetrics() *ipc.DetailedMetrics { } } +// GetMetricsHistory returns the recent CPU/memory ring buffer for the +// desktop console's sparkline charts. Returns an empty slice (not nil) when +// metrics are disabled or the sampler hasn't run yet, so the JSON shape +// stays stable. +func (a *Agent) GetMetricsHistory() []ipc.MetricSample { + if a.sampler == nil { + return []ipc.MetricSample{} + } + return a.sampler.snapshot() +} + +// GetEvents returns recent activity events newer than `since` (unix ms). +// Pass 0 to get all events currently held in the ring buffer. +func (a *Agent) GetEvents(since int64) []events.Event { + if a.events == nil { + return []events.Event{} + } + return a.events.Snapshot(since) +} + +// ClearLogs rotates agent.log so the desktop console can start with a +// clean tail. Existing content moves to an automatic backup file. Records +// the action in the activity log so the user has an audit trail. +func (a *Agent) ClearLogs() error { + if err := a.log.Rotate(); err != nil { + return err + } + if a.events != nil { + a.events.Append(events.KindInfo, events.SeverityInfo, + "Logs cleared from console", nil) + } + return nil +} + // GetConnectionInfo returns WebSocket connection information for the IPC API func (a *Agent) GetConnectionInfo() ipc.ConnectionInfo { info := ipc.ConnectionInfo{ diff --git a/agent/internal/agent/cloudflared_handlers.go b/agent/internal/agent/cloudflared_handlers.go new file mode 100644 index 00000000..624781a0 --- /dev/null +++ b/agent/internal/agent/cloudflared_handlers.go @@ -0,0 +1,140 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/serverkit/agent/internal/cloudflared" + "github.com/serverkit/agent/internal/jobs" +) + +// Handlers for cloudflared:* actions. Same shape as cron handlers: +// thin parse + dispatch. Validation lives in the cloudflared package. + +func (a *Agent) handleCloudflaredStatus(ctx context.Context, _ json.RawMessage) (interface{}, error) { + return a.cloudflared.Status(ctx) +} + +func (a *Agent) handleCloudflaredTunnelList(ctx context.Context, _ json.RawMessage) (interface{}, error) { + tunnels, err := a.cloudflared.List(ctx) + if err != nil { + return nil, err + } + if tunnels == nil { + tunnels = []cloudflared.Tunnel{} + } + return map[string]interface{}{"tunnels": tunnels}, nil +} + +func (a *Agent) handleCloudflaredTunnelCreate(ctx context.Context, params json.RawMessage) (interface{}, error) { + var req cloudflared.CreateRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + tunnel, err := a.cloudflared.Create(ctx, req) + if err != nil { + return nil, err + } + return tunnel, nil +} + +func (a *Agent) handleCloudflaredTunnelRoute(ctx context.Context, params json.RawMessage) (interface{}, error) { + var req cloudflared.RouteRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := a.cloudflared.Route(ctx, req); err != nil { + return nil, err + } + return map[string]bool{"success": true}, nil +} + +// handleCloudflaredLogin starts `cloudflared tunnel login` and streams +// progress on a job channel. Returns {job_id, channel} immediately so +// the panel can subscribe and surface the auth URL the moment +// cloudflared prints it. The flow: +// +// 1. Agent spawns `cloudflared tunnel login` with stdout/stderr piped +// 2. First line containing https://dash.cloudflare.com/argotunnel?... +// is reported as a 'status' event with auth_url +// 3. Panel renders a clickable button; user opens URL → authorises +// 4. cloudflared writes cert.pem and exits +// 5. Agent emits 'done' with the cert path; panel can refresh +// capabilities to flip the Authenticated badge. +// +// 15-minute hard ceiling (cloudflared blocks waiting for the OAuth +// callback) — long enough for a coffee break, short enough that a +// forgotten browser tab gets reaped. +func (a *Agent) handleCloudflaredLogin(ctx context.Context, _ json.RawMessage) (interface{}, error) { + // Use a background context for the subscription so the agent's + // 5-minute command timeout doesn't kill the login flow. + loginCtx := context.Background() + ch, err := a.cloudflared.Login(loginCtx) + if err != nil { + return nil, fmt.Errorf("start login: %w", err) + } + job := a.jobs.New(64) + go a.runCloudflaredLoginJob(job, ch) + return map[string]interface{}{ + "job_id": job.ID, + "channel": job.Channel, + }, nil +} + +func (a *Agent) runCloudflaredLoginJob(job *jobs.Job, events <-chan cloudflared.LoginEvent) { + exit := 0 + emit := func(ev jobs.Event) { _ = job.Push(a.ws, ev) } + emit(jobs.Event{Phase: jobs.PhaseStart, Message: "starting cloudflared tunnel login"}) + + for ev := range events { + switch { + case ev.AuthURL != "": + emit(jobs.Event{ + Phase: jobs.PhaseStatus, + Message: "Open this URL in your browser to authorise the agent", + Extra: map[string]interface{}{"auth_url": ev.AuthURL}, + }) + if ev.Line != "" { + emit(jobs.Event{Phase: jobs.PhaseLog, Lines: []string{ev.Line}}) + } + case ev.Done: + if ev.Error != "" { + exit = 1 + emit(jobs.Event{ + Phase: jobs.PhaseDone, + ExitCode: &exit, + Error: ev.Error, + }) + } else { + emit(jobs.Event{ + Phase: jobs.PhaseDone, + ExitCode: &exit, + Message: "authenticated", + Extra: map[string]interface{}{"cert_path": ev.CertPath}, + }) + } + case ev.Line != "": + emit(jobs.Event{Phase: jobs.PhaseLog, Lines: []string{ev.Line}}) + } + } + // Channel closed without explicit Done — emit a defensive + // terminator so the panel modal doesn't hang indefinitely. + if !job.HasTerminated() { + exit = 1 + emit(jobs.Event{Phase: jobs.PhaseDone, ExitCode: &exit, Error: "login stream ended unexpectedly"}) + } +} + +func (a *Agent) handleCloudflaredTunnelDelete(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Ref string `json:"ref"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := a.cloudflared.Delete(ctx, p.Ref); err != nil { + return nil, err + } + return map[string]bool{"success": true}, nil +} diff --git a/agent/internal/agent/cron_handlers.go b/agent/internal/agent/cron_handlers.go new file mode 100644 index 00000000..6cafbe77 --- /dev/null +++ b/agent/internal/agent/cron_handlers.go @@ -0,0 +1,79 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/serverkit/agent/internal/cron" +) + +// Cron handlers wrap the cron.Manager interface for the panel's +// command-routing path. Validation happens inside the cron package, +// so handlers here are thin: parse params, dispatch, return. +// +// Linux-only operations on non-Linux agents return the manager's +// "unsupported" error, which the panel surfaces as a normal failure +// rather than a transport bug. + +func (a *Agent) handleCronStatus(ctx context.Context, _ json.RawMessage) (interface{}, error) { + return a.cron.Status(ctx) +} + +func (a *Agent) handleCronList(ctx context.Context, _ json.RawMessage) (interface{}, error) { + entries, err := a.cron.List(ctx) + if err != nil { + return nil, err + } + // Stable shape even on empty: {"jobs": []} so the UI can + // unconditionally read .jobs. + if entries == nil { + entries = []cron.Entry{} + } + return map[string]interface{}{"jobs": entries}, nil +} + +func (a *Agent) handleCronAdd(ctx context.Context, params json.RawMessage) (interface{}, error) { + var req cron.AddRequest + if err := json.Unmarshal(params, &req); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + entry, err := a.cron.Add(ctx, req) + if err != nil { + return nil, err + } + return entry, nil +} + +func (a *Agent) handleCronRemove(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + ID string `json:"id"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if p.ID == "" { + return nil, fmt.Errorf("id is required") + } + if err := a.cron.Remove(ctx, p.ID); err != nil { + return nil, err + } + return map[string]bool{"success": true}, nil +} + +func (a *Agent) handleCronToggle(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if p.ID == "" { + return nil, fmt.Errorf("id is required") + } + if err := a.cron.Toggle(ctx, p.ID, p.Enabled); err != nil { + return nil, err + } + return map[string]bool{"success": true, "enabled": p.Enabled}, nil +} diff --git a/agent/internal/agent/docker_extra_handlers.go b/agent/internal/agent/docker_extra_handlers.go new file mode 100644 index 00000000..002193d3 --- /dev/null +++ b/agent/internal/agent/docker_extra_handlers.go @@ -0,0 +1,270 @@ +package agent + +// Phase 1e — fill in the docker actions that were declared in the +// protocol but never wired to handlers: container:create, +// container:exec, image:build, volume:create, network:create. The +// existing handlers (start/stop/list/inspect/logs/stats, image +// list/pull/remove, compose) live in agent.go alongside their +// containers; we put the new ones in their own file to keep the diff +// reviewable. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/serverkit/agent/internal/docker" + "github.com/serverkit/agent/internal/jobs" +) + +// validateImageName rejects shell metacharacters in user-supplied image +// references. Docker is generally tolerant about what it accepts as a +// tag, but we don't want a "registry.example.com/img;rm -rf /" landing +// in argv even if exec.Command is safe — it's a clearer error to +// reject up front. +func validateImageName(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return errors.New("image is required") + } + if strings.ContainsAny(s, ";&|`$<>\n\r\"'\\") { + return fmt.Errorf("invalid image: %q", s) + } + return nil +} + +func validateContainerID(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return errors.New("container is required") + } + if strings.ContainsAny(s, ";&|`$<>\n\r\"'\\ /") { + return fmt.Errorf("invalid container id/name: %q", s) + } + return nil +} + +// ───── docker:container:create ──────────────────────────────────── + +func (a *Agent) handleDockerContainerCreate(ctx context.Context, params json.RawMessage) (interface{}, error) { + if a.docker == nil { + return nil, errors.New("docker not available") + } + var spec docker.ContainerCreateSpec + if err := json.Unmarshal(params, &spec); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := validateImageName(spec.Image); err != nil { + return nil, err + } + // v1 reject privileged-ish fields. They round-trip in the JSON but + // are not part of ContainerCreateSpec — defensively check raw params + // to fail loudly when a caller tries to set them. The struct-shaped + // check is at the field level above. + var raw map[string]interface{} + _ = json.Unmarshal(params, &raw) + for _, banned := range []string{"privileged", "cap_add", "devices"} { + if _, ok := raw[banned]; ok { + return nil, fmt.Errorf("%s is not supported in v1 container:create", banned) + } + } + id, err := a.docker.CreateContainer(ctx, spec) + if err != nil { + return nil, fmt.Errorf("create container: %w", err) + } + return map[string]interface{}{ + "id": id, + "name": spec.Name, + "image": spec.Image, + "status": "created", + }, nil +} + +// ───── docker:container:exec ────────────────────────────────────── + +func (a *Agent) handleDockerContainerExec(ctx context.Context, params json.RawMessage) (interface{}, error) { + if a.docker == nil { + return nil, errors.New("docker not available") + } + var p struct { + Container string `json:"container"` + Cmd []string `json:"cmd"` + User string `json:"user"` + WorkingDir string `json:"working_dir"` + TimeoutSeconds int `json:"timeout_seconds"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := validateContainerID(p.Container); err != nil { + return nil, err + } + if len(p.Cmd) == 0 { + return nil, errors.New("cmd is required") + } + timeout := time.Duration(p.TimeoutSeconds) * time.Second + if timeout <= 0 || timeout > 5*time.Minute { + timeout = 60 * time.Second + } + cctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + res, err := a.docker.ExecContainer(cctx, p.Container, p.Cmd, p.User, p.WorkingDir, 1024*1024) + if err != nil { + return nil, fmt.Errorf("exec: %w", err) + } + return res, nil +} + +// ───── docker:image:build ───────────────────────────────────────── + +func (a *Agent) handleDockerImageBuild(ctx context.Context, params json.RawMessage) (interface{}, error) { + if a.docker == nil { + return nil, errors.New("docker not available") + } + var spec docker.ImageBuildSpec + if err := json.Unmarshal(params, &spec); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := validateImageName(spec.Tag); err != nil { + return nil, fmt.Errorf("tag: %w", err) + } + abs, err := filepath.Abs(spec.ContextPath) + if err != nil { + return nil, fmt.Errorf("context_path: %w", err) + } + if !a.contextPathAllowed(abs) { + return nil, fmt.Errorf("context_path %q is not under any allowed root", abs) + } + spec.ContextPath = abs + + job := a.jobs.New(500) + go a.runDockerBuildJob(job, spec) + return map[string]interface{}{ + "job_id": job.ID, + "channel": job.Channel, + "tag": spec.Tag, + }, nil +} + +func (a *Agent) contextPathAllowed(path string) bool { + for _, root := range a.cfg.Security.AllowedPaths { + ar, err := filepath.Abs(root) + if err != nil { + continue + } + // path must be under ar (or equal). Use Rel to detect ".." escapes. + rel, err := filepath.Rel(ar, path) + if err != nil { + continue + } + if rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return true + } + } + return false +} + +func (a *Agent) runDockerBuildJob(job *jobs.Job, spec docker.ImageBuildSpec) { + exit := 0 + emitDone := func(errStr string) { + _ = job.Push(a.ws, jobs.Event{ + Phase: jobs.PhaseDone, + ExitCode: &exit, + Error: errStr, + Extra: map[string]interface{}{"tag": spec.Tag}, + }) + } + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + cmd, err := a.docker.BuildImageCmd(cctx, spec) + if err != nil { + exit = -1 + emitDone(err.Error()) + return + } + _ = job.Push(a.ws, jobs.Event{Phase: jobs.PhaseStart, Message: fmt.Sprintf("docker build -t %s", spec.Tag)}) + if err := streamCmdOutput(cmd, job, a.ws); err != nil { + exit = -1 + emitDone(err.Error()) + return + } + if cerr := cmd.Wait(); cerr != nil { + exit = exitCodeOfCmd(cerr) + if exit == 0 { + exit = -1 + } + emitDone(cerr.Error()) + return + } + emitDone("") +} + +// exitCodeOfCmd duplicates exitCodeOf from package_handlers.go to +// avoid an import cycle in this file. They're trivial enough that the +// duplication is cheaper than refactoring callers. +func exitCodeOfCmd(err error) int { + var ee *exec.ExitError + if errors.As(err, &ee) { + return ee.ExitCode() + } + return -1 +} + +// ───── docker:volume:create ─────────────────────────────────────── + +func (a *Agent) handleDockerVolumeCreate(ctx context.Context, params json.RawMessage) (interface{}, error) { + if a.docker == nil { + return nil, errors.New("docker not available") + } + var p struct { + Name string `json:"name"` + Driver string `json:"driver"` + Labels map[string]string `json:"labels"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if strings.TrimSpace(p.Name) == "" { + return nil, errors.New("name is required") + } + if strings.ContainsAny(p.Name, ";&|`$<>\n\r\"'\\ /") { + return nil, fmt.Errorf("invalid volume name: %q", p.Name) + } + vol, err := a.docker.CreateVolume(ctx, p.Name, p.Driver, p.Labels) + if err != nil { + return nil, fmt.Errorf("create volume: %w", err) + } + return vol, nil +} + +// ───── docker:network:create ────────────────────────────────────── + +func (a *Agent) handleDockerNetworkCreate(ctx context.Context, params json.RawMessage) (interface{}, error) { + if a.docker == nil { + return nil, errors.New("docker not available") + } + var spec docker.NetworkCreateSpec + if err := json.Unmarshal(params, &spec); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if strings.TrimSpace(spec.Name) == "" { + return nil, errors.New("name is required") + } + if strings.ContainsAny(spec.Name, ";&|`$<>\n\r\"'\\ /") { + return nil, fmt.Errorf("invalid network name: %q", spec.Name) + } + id, err := a.docker.CreateNetwork(ctx, spec) + if err != nil { + return nil, fmt.Errorf("create network: %w", err) + } + return map[string]interface{}{ + "id": id, + "name": spec.Name, + "driver": spec.Driver, + }, nil +} diff --git a/agent/internal/agent/file_handlers.go b/agent/internal/agent/file_handlers.go new file mode 100644 index 00000000..d719ee6b --- /dev/null +++ b/agent/internal/agent/file_handlers.go @@ -0,0 +1,230 @@ +package agent + +// File-access handlers (file:read, file:write, file:list) and the +// allowlist-aware path validator they share. Lives separately from +// agent.go so the security-sensitive path-resolution logic is easy to +// find and review without scrolling through 2000 lines of unrelated +// command handlers. +// +// Every handler in this file goes through validateFileAccess, which +// resolves symlinks before comparing against AllowedPaths — without +// that, an operator-allowed root containing a symlink to /etc would +// let the panel read or write arbitrary files. handleFileWrite also +// re-validates the parent directory before MkdirAll so CreateDirs +// can't escape via a not-yet-existing path that crosses a symlink +// boundary. + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +func (a *Agent) handleFileRead(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Path string `json:"path"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if p.Path == "" { + return nil, fmt.Errorf("path is required") + } + if err := a.validateFileAccess(p.Path); err != nil { + return nil, err + } + + data, err := os.ReadFile(p.Path) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return map[string]interface{}{ + "path": p.Path, + "content": base64.StdEncoding.EncodeToString(data), + "size": len(data), + }, nil +} + +func (a *Agent) handleFileWrite(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Path string `json:"path"` + Content string `json:"content"` // base64 encoded + Mode uint32 `json:"mode"` + CreateDirs bool `json:"create_dirs"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if p.Path == "" { + return nil, fmt.Errorf("path is required") + } + if err := a.validateFileAccess(p.Path); err != nil { + return nil, err + } + + data, err := base64.StdEncoding.DecodeString(p.Content) + if err != nil { + return nil, fmt.Errorf("invalid base64 content: %w", err) + } + + mode := os.FileMode(0644) + if p.Mode != 0 { + // Mask off SUID/SGID/sticky bits — the panel has no business + // requesting them and a compromised panel could otherwise drop + // a setuid binary into an allowed path. If an operator + // genuinely needs to write a setuid file they can chmod it via + // system:exec (which is independently gated). + mode = os.FileMode(p.Mode) & os.ModePerm + } + + if p.CreateDirs { + // Re-validate the parent directory under the symlink-resolved + // rules — MkdirAll otherwise lets the panel create directories + // outside AllowedPaths if the requested path is something like + // /var/lib/serverkit//foo and escapes. + parent := filepath.Dir(p.Path) + if err := a.validateFileAccess(parent); err != nil { + return nil, fmt.Errorf("parent dir not allowed: %w", err) + } + if err := os.MkdirAll(parent, 0755); err != nil { + return nil, fmt.Errorf("failed to create parent directories: %w", err) + } + } + + if err := os.WriteFile(p.Path, data, mode); err != nil { + return nil, fmt.Errorf("failed to write file: %w", err) + } + + return map[string]interface{}{ + "success": true, + "path": p.Path, + "size": len(data), + }, nil +} + +func (a *Agent) handleFileList(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Path string `json:"path"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if p.Path == "" { + p.Path = "/" + } + if err := a.validateFileAccess(p.Path); err != nil { + return nil, err + } + + entries, err := os.ReadDir(p.Path) + if err != nil { + return nil, fmt.Errorf("failed to list directory: %w", err) + } + + var files []map[string]interface{} + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, map[string]interface{}{ + "name": entry.Name(), + "is_dir": entry.IsDir(), + "size": info.Size(), + "modified": info.ModTime().UnixMilli(), + }) + } + + return map[string]interface{}{ + "path": p.Path, + "files": files, + }, nil +} + +func (a *Agent) validateFileAccess(path string) error { + allowedPaths := a.cfg.Security.AllowedPaths + if len(allowedPaths) == 0 { + return fmt.Errorf("file access denied: no allowed_paths configured") + } + + target, err := resolveSymlinkPath(path) + if err != nil { + return fmt.Errorf("invalid path: %w", err) + } + + for _, allowedPath := range allowedPaths { + if strings.TrimSpace(allowedPath) == "" { + continue + } + allowed, err := resolveSymlinkPath(allowedPath) + if err != nil { + continue + } + + if pathWithinAllowedRoot(target, allowed) { + return nil + } + } + + return fmt.Errorf("file access denied for path: %s", path) +} + +// resolveSymlinkPath returns an absolute, symlink-free path for use in +// allowlist comparison. Without this, an operator-allowed root that +// contains a symlink (eg. /var/lib/serverkit/data → /etc) lets the +// panel read or write any file the symlink points at — strings.HasPrefix +// matches the unresolved target string, which never sees the escape. +// +// For paths that don't yet exist (handleFileWrite creating a new file +// in an allowed directory), EvalSymlinks fails. Fall back to resolving +// the deepest existing ancestor and reattaching the unresolved suffix +// so a fresh file path is still validated against the real allowed +// root rather than its symlink alias. +func resolveSymlinkPath(p string) (string, error) { + abs, err := filepath.Abs(filepath.Clean(p)) + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return resolved, nil + } + // Path (or some ancestor) doesn't exist yet. Walk up to the + // nearest existing ancestor, resolve that, then reattach the + // missing suffix. This is the standard pattern for allowlist + // checks against not-yet-created files; the suffix can't + // introduce a new symlink because nothing on disk corresponds + // to it yet. + dir := abs + suffix := "" + for { + parent := filepath.Dir(dir) + if parent == dir { + return abs, nil + } + if resolved, err := filepath.EvalSymlinks(parent); err == nil { + return filepath.Join(resolved, filepath.Base(dir), suffix), nil + } + suffix = filepath.Join(filepath.Base(dir), suffix) + dir = parent + } +} + +func pathWithinAllowedRoot(target, allowed string) bool { + if runtime.GOOS == "windows" { + target = strings.ToLower(target) + allowed = strings.ToLower(allowed) + } + + if target == allowed { + return true + } + + allowedWithSeparator := strings.TrimRight(allowed, string(os.PathSeparator)) + string(os.PathSeparator) + return strings.HasPrefix(target, allowedWithSeparator) +} diff --git a/agent/internal/agent/package_handlers.go b/agent/internal/agent/package_handlers.go new file mode 100644 index 00000000..407920d1 --- /dev/null +++ b/agent/internal/agent/package_handlers.go @@ -0,0 +1,564 @@ +package agent + +// Phase 1c additions — package manager actions beyond the original +// install/remove/list trio: search, info, update-cache, plus a +// streaming variant of install/upgrade that emits progress on a job +// channel for the Packages tab and one-click templates. +// +// The existing handlePackagesInstall (single-name, synchronous, +// idempotent short-circuit) is left alone so the workflow engine's +// agent_command callers still get a structured result. New callers +// wanting progress streaming use packages:install_async / packages:upgrade. + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "time" + + "github.com/serverkit/agent/internal/jobs" +) + +// extendedPackageManager carries the new-action arg patterns alongside +// the existing packageManager. We don't fold these into packageManager +// itself to keep the original definition (and its callers) untouched. +type extendedPackageManager struct { + bin string + updateCache []string + upgradeAll []string + upgradePkg []string // appended with package name(s) + searchCmd []string + infoCmd []string // appended with name +} + +func extendedFor(pm *packageManager) *extendedPackageManager { + switch pm.bin { + case "apt-get": + return &extendedPackageManager{ + bin: "apt-get", + updateCache: []string{"update"}, + upgradeAll: []string{"upgrade", "-y", "-o", "Dpkg::Options::=--force-confold"}, + upgradePkg: []string{"install", "--only-upgrade", "-y", "-o", "Dpkg::Options::=--force-confold"}, + searchCmd: []string{"search"}, // run via apt-cache below + infoCmd: []string{"show"}, // run via apt-cache below + } + case "dnf", "yum": + return &extendedPackageManager{ + bin: pm.bin, + updateCache: []string{"check-update"}, + upgradeAll: []string{"upgrade", "-y"}, + upgradePkg: []string{"upgrade", "-y"}, + searchCmd: []string{"search"}, + infoCmd: []string{"info"}, + } + case "apk": + return &extendedPackageManager{ + bin: "apk", + updateCache: []string{"update"}, + upgradeAll: []string{"upgrade"}, + upgradePkg: []string{"upgrade"}, + searchCmd: []string{"search", "-v"}, + infoCmd: []string{"info"}, + } + case "pacman": + return &extendedPackageManager{ + bin: "pacman", + updateCache: []string{"-Sy"}, + upgradeAll: []string{"-Syu", "--noconfirm"}, + upgradePkg: []string{"-S", "--noconfirm"}, + searchCmd: []string{"-Ss"}, + infoCmd: []string{"-Si"}, + } + case "zypper": + return &extendedPackageManager{ + bin: "zypper", + updateCache: []string{"refresh"}, + upgradeAll: []string{"update", "-y"}, + upgradePkg: []string{"update", "-y"}, + searchCmd: []string{"se"}, + infoCmd: []string{"info"}, + } + } + return nil +} + +// ───── packages:update_cache ────────────────────────────────────── + +func (a *Agent) handlePackagesUpdateCache(ctx context.Context, params json.RawMessage) (interface{}, error) { + pm, err := detectPackageManager() + if err != nil { + return nil, err + } + ext := extendedFor(pm) + if ext == nil { + return nil, fmt.Errorf("update_cache: manager %q not supported", pm.bin) + } + a.pkgLock.Lock() + defer a.pkgLock.Unlock() + + cctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + cmd, err := sudoCommandContext(cctx, a.sudoMode, ext.bin, ext.updateCache...) + if err != nil { + return nil, err + } + out, err := cmd.CombinedOutput() + tail := truncate(string(out), 4096) + if err != nil && !isCheckUpdateExit100(ext.bin, err) { + // dnf check-update returns 100 when updates are available; that + // isn't a failure, just an "actionable" exit. Anything else is + // treated as a real error. + if classifySudoError(string(out)) { + return nil, fmt.Errorf("sudo refused: %w", errSudoRequired) + } + return nil, fmt.Errorf("%s %s: %w (%s)", ext.bin, strings.Join(ext.updateCache, " "), err, tail) + } + return map[string]interface{}{ + "manager": ext.bin, + "output": tail, + }, nil +} + +// dnf check-update conventionally exits 100 when there are updates +// pending, 0 when nothing to do. We treat both as success. +func isCheckUpdateExit100(bin string, err error) bool { + if bin != "dnf" && bin != "yum" { + return false + } + var ee *exec.ExitError + if errors.As(err, &ee) { + return ee.ExitCode() == 100 + } + return false +} + +// ───── packages:search ──────────────────────────────────────────── + +func (a *Agent) handlePackagesSearch(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + q := strings.TrimSpace(p.Query) + if q == "" { + return nil, errors.New("query is required") + } + if err := validatePackageName(q); err != nil { + // Reject metachars; reuse the install validator since search + // goes straight to argv. + return nil, err + } + if p.Limit <= 0 || p.Limit > 500 { + p.Limit = 500 + } + + pm, err := detectPackageManager() + if err != nil { + return nil, err + } + ext := extendedFor(pm) + if ext == nil { + return nil, fmt.Errorf("search: manager %q not supported", pm.bin) + } + + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + bin := ext.bin + args := append([]string{}, ext.searchCmd...) + if pm.bin == "apt-get" { + bin = "apt-cache" + } + args = append(args, q) + out, err := exec.CommandContext(cctx, bin, args...).CombinedOutput() + if err != nil && len(out) == 0 { + return nil, fmt.Errorf("%s %s: %w", bin, strings.Join(args, " "), err) + } + lines := splitNonEmptyLines(string(out)) + if len(lines) > p.Limit { + lines = lines[:p.Limit] + } + return map[string]interface{}{ + "manager": pm.bin, + "query": q, + "limit": p.Limit, + "results": lines, + }, nil +} + +// ───── packages:info ────────────────────────────────────────────── + +func (a *Agent) handlePackagesInfo(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Name string `json:"name"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := validatePackageName(p.Name); err != nil { + return nil, err + } + pm, err := detectPackageManager() + if err != nil { + return nil, err + } + ext := extendedFor(pm) + if ext == nil { + return nil, fmt.Errorf("info: manager %q not supported", pm.bin) + } + + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + bin := ext.bin + if pm.bin == "apt-get" { + bin = "apt-cache" + } + args := append(append([]string{}, ext.infoCmd...), p.Name) + out, err := exec.CommandContext(cctx, bin, args...).CombinedOutput() + if err != nil && len(out) == 0 { + return nil, fmt.Errorf("%s %s: %w", bin, strings.Join(args, " "), err) + } + return map[string]interface{}{ + "manager": pm.bin, + "name": p.Name, + "output": truncate(string(out), 16*1024), + }, nil +} + +// ───── packages:install_async + packages:upgrade ───────────────── + +// installRequest is the shared param shape for install_async / upgrade. +// Either Names (list) or Name (single) may be set; an empty Names with +// All=true upgrades everything (only meaningful for upgrade). +type installRequest struct { + Names []string `json:"names"` + Name string `json:"name"` + All bool `json:"all"` +} + +func (r *installRequest) normalize() error { + if r.Name != "" { + r.Names = append(r.Names, r.Name) + } + for _, n := range r.Names { + if err := validatePackageName(n); err != nil { + return err + } + } + return nil +} + +// handlePackagesInstallAsync is a streaming variant of install: returns +// {job_id, channel} immediately and emits per-line progress on the +// channel. Idempotent like the sync variant — packages already +// installed are skipped (we still emit a status event). +func (a *Agent) handlePackagesInstallAsync(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p installRequest + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := p.normalize(); err != nil { + return nil, err + } + if len(p.Names) == 0 { + return nil, errors.New("at least one package name required") + } + pm, err := detectPackageManager() + if err != nil { + return nil, err + } + if a.sudoMode == SudoUnavailable { + return nil, errSudoRequired + } + + job := a.jobs.New(200) + go a.runPackageInstallJob(job, pm, p.Names) + return map[string]interface{}{ + "job_id": job.ID, + "channel": job.Channel, + }, nil +} + +// handlePackagesUpgrade upgrades the named packages, or all packages +// when All=true. Streams progress identically to install_async. +func (a *Agent) handlePackagesUpgrade(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p installRequest + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := p.normalize(); err != nil { + return nil, err + } + if !p.All && len(p.Names) == 0 { + return nil, errors.New("either names[] or all=true required") + } + pm, err := detectPackageManager() + if err != nil { + return nil, err + } + ext := extendedFor(pm) + if ext == nil { + return nil, fmt.Errorf("upgrade: manager %q not supported", pm.bin) + } + if a.sudoMode == SudoUnavailable { + return nil, errSudoRequired + } + + job := a.jobs.New(200) + go a.runPackageUpgradeJob(job, pm, ext, p.Names, p.All) + return map[string]interface{}{ + "job_id": job.ID, + "channel": job.Channel, + }, nil +} + +// runPackageInstallJob runs the apt/dnf/etc. install in the background, +// streams output line-by-line on the job channel, and emits a final +// done event with the structured result. +func (a *Agent) runPackageInstallJob(job *jobs.Job, pm *packageManager, names []string) { + a.pkgLock.Lock() + defer a.pkgLock.Unlock() + + exit := 0 + emitDone := func(errStr string, installed []string) { + ev := jobs.Event{ + Phase: jobs.PhaseDone, + ExitCode: &exit, + Error: errStr, + Extra: map[string]interface{}{ + "manager": pm.bin, + "action": "install", + "installed": installed, + }, + } + _ = job.Push(a.ws, ev) + } + + // Skip already-installed packages. + probeCtx, probeCancel := context.WithTimeout(context.Background(), 30*time.Second) + var toInstall, alreadyInstalled []string + for _, n := range names { + if installed, _ := pm.isInstalled(probeCtx, n); installed { + alreadyInstalled = append(alreadyInstalled, n) + continue + } + toInstall = append(toInstall, n) + } + probeCancel() + + if len(alreadyInstalled) > 0 { + _ = job.Push(a.ws, jobs.Event{ + Phase: jobs.PhaseStatus, + Message: fmt.Sprintf("already installed: %s", strings.Join(alreadyInstalled, ", ")), + }) + } + if len(toInstall) == 0 { + emitDone("", alreadyInstalled) + return + } + + _ = job.Push(a.ws, jobs.Event{ + Phase: jobs.PhaseStart, + Message: fmt.Sprintf("installing %d package(s) via %s", len(toInstall), pm.bin), + }) + + // Long-running command — bound at 15 minutes. + cctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + args := append(append([]string{}, pm.install...), toInstall...) + cmd, err := sudoCommandContext(cctx, a.sudoMode, pm.bin, args...) + if err != nil { + exit = -1 + emitDone(err.Error(), nil) + return + } + if err := streamCmdOutput(cmd, job, a.ws); err != nil { + exit = -1 + emitDone(err.Error(), nil) + return + } + if cerr := cmd.Wait(); cerr != nil { + exit = exitCodeOf(cerr) + if exit == 0 { + exit = -1 + } + emitDone(cerr.Error(), nil) + return + } + emitDone("", append(alreadyInstalled, toInstall...)) +} + +func (a *Agent) runPackageUpgradeJob(job *jobs.Job, pm *packageManager, ext *extendedPackageManager, names []string, all bool) { + a.pkgLock.Lock() + defer a.pkgLock.Unlock() + + exit := 0 + emitDone := func(errStr string) { + ev := jobs.Event{ + Phase: jobs.PhaseDone, + ExitCode: &exit, + Error: errStr, + Extra: map[string]interface{}{ + "manager": pm.bin, + "action": "upgrade", + "all": all, + }, + } + _ = job.Push(a.ws, ev) + } + + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + // apt: refresh the package index first so upgrade sees newly + // published versions. Other managers do this implicitly. + if pm.bin == "apt-get" { + updCmd, err := sudoCommandContext(cctx, a.sudoMode, ext.bin, ext.updateCache...) + if err != nil { + exit = -1 + emitDone(err.Error()) + return + } + if err := streamCmdOutput(updCmd, job, a.ws); err != nil { + exit = -1 + emitDone(err.Error()) + return + } + _ = updCmd.Wait() + } + + var args []string + if all { + args = append([]string{}, ext.upgradeAll...) + } else { + args = append(append([]string{}, ext.upgradePkg...), names...) + } + cmd, err := sudoCommandContext(cctx, a.sudoMode, ext.bin, args...) + if err != nil { + exit = -1 + emitDone(err.Error()) + return + } + _ = job.Push(a.ws, jobs.Event{ + Phase: jobs.PhaseStart, + Message: fmt.Sprintf("%s %s", ext.bin, strings.Join(args, " ")), + }) + if err := streamCmdOutput(cmd, job, a.ws); err != nil { + exit = -1 + emitDone(err.Error()) + return + } + if cerr := cmd.Wait(); cerr != nil { + exit = exitCodeOf(cerr) + if exit == 0 { + exit = -1 + } + emitDone(cerr.Error()) + return + } + emitDone("") +} + +// streamCmdOutput attaches a line-buffered stdout/stderr scanner to cmd +// and pushes batches of lines on the job channel. Lines are flushed on +// a 100ms window or 32-line threshold, whichever comes first, so the +// panel sees steady progress without one event per line at high +// throughput. +// +// Blocks until both pipes close, then returns. Caller is expected to +// invoke cmd.Wait() afterwards to reap the process and get the exit +// code. Returns an error only if cmd.Start fails or pipe setup fails. +func streamCmdOutput(cmd *exec.Cmd, job *jobs.Job, s jobs.Streamer) error { + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + + lines := make(chan string, 256) + var wg sync.WaitGroup + wg.Add(2) + go scanLines(stdout, lines, &wg) + go scanLines(stderr, lines, &wg) + + // Closer goroutine: once both scanners have returned, close the + // channel so the batcher knows there's no more input. + go func() { + wg.Wait() + close(lines) + }() + + // Batcher: flush every 100ms or 32 lines. Runs synchronously so + // the caller can rely on "all output emitted before we return." + batch := make([]string, 0, 32) + flush := func() { + if len(batch) == 0 { + return + } + _ = job.Push(s, jobs.Event{Phase: jobs.PhaseLog, Lines: append([]string{}, batch...)}) + batch = batch[:0] + } + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case ln, ok := <-lines: + if !ok { + flush() + return nil + } + batch = append(batch, ln) + if len(batch) >= 32 { + flush() + } + case <-ticker.C: + flush() + } + } +} + +func scanLines(r io.ReadCloser, out chan<- string, wg *sync.WaitGroup) { + defer wg.Done() + defer r.Close() + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + out <- sc.Text() + } +} + +func exitCodeOf(err error) int { + var ee *exec.ExitError + if errors.As(err, &ee) { + return ee.ExitCode() + } + return -1 +} + +func splitNonEmptyLines(s string) []string { + var out []string + for _, ln := range strings.Split(s, "\n") { + ln = strings.TrimRight(ln, "\r") + if strings.TrimSpace(ln) == "" { + continue + } + out = append(out, ln) + } + return out +} + diff --git a/agent/internal/agent/recapabilities.go b/agent/internal/agent/recapabilities.go new file mode 100644 index 00000000..479139b2 --- /dev/null +++ b/agent/internal/agent/recapabilities.go @@ -0,0 +1,128 @@ +package agent + +// agent:recapabilities — re-runs the capability probe on demand. The +// panel calls this after the user installs a runtime / sudoers entry / +// new package manager, so new feature surfaces light up without a +// service restart. +// +// Concurrent re-probes are common (the panel may fire one per server +// detail page open) and the underlying probes spawn subprocesses, so +// we guard with a coarse mutex: if a probe is already in flight we +// return the cached set with reprobe="in_progress" instead of +// queueing. + +import ( + "context" + "encoding/json" + "time" + + "github.com/serverkit/agent/internal/capabilities" + "github.com/serverkit/agent/pkg/protocol" +) + +func (a *Agent) handleAgentRecapabilities(ctx context.Context, params json.RawMessage) (interface{}, error) { + a.capMu.Lock() + if a.reprobing { + caps := a.capabilities + a.capMu.Unlock() + return map[string]interface{}{ + "reprobe": "in_progress", + "capabilities": caps, + }, nil + } + a.reprobing = true + a.capMu.Unlock() + defer func() { + a.capMu.Lock() + a.reprobing = false + a.capMu.Unlock() + }() + + // Use a fresh, bounded context — the caller's request timeout + // might be shorter than the probe takes, but we still want to + // finish so the next subscriber sees fresh state. + probeCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Clear the PATH lookup cache so newly installed binaries (e.g. the + // user just ran apt install nginx) are seen by this re-probe. + capabilities.ClearPathCache() + + fresh := capabilities.Probe( + probeCtx, + a.log, + a.docker != nil, + a.cfg.Features.FileAccess, + a.cfg.Security.AllowedPaths, + ) + + // Re-probe sudo as well — the user may have added passwordless + // sudoers config since boot. + mode := probeSudoMode(probeCtx) + fresh.Sudo = string(mode) + // Re-detect runtime version managers; user may have just bootstrapped + // pyenv via runtimes:pyenv:bootstrap and is hitting refresh. + fresh.RuntimeManagers = map[string]string{ + "python": pyenvManagerKind(), + } + fresh.ProbedAt = time.Now().UnixMilli() + + // Capabilities are additive across re-probes: if the previous + // snapshot saw a feature the panel cared about, don't drop it just + // because a transient probe failed (e.g. dockerd briefly + // restarting). Only flip false→true here, never true→false. + a.capMu.Lock() + merged := mergeCapabilities(a.capabilities, fresh) + a.capabilities = merged + a.sudoMode = mode + a.capMu.Unlock() + + // Push to the panel so the in-memory ConnectedAgent record updates + // without an explicit refetch. + a.sendCapabilities() + + return map[string]interface{}{ + "reprobe": "ok", + "capabilities": merged, + }, nil +} + +// mergeCapabilities returns a payload where bool capabilities are the +// OR of prev and fresh, runtime maps are unioned, and the "presence" +// of optional fields prefers fresh values. Numeric/string fields like +// distro version are taken from fresh because the host might have been +// upgraded between probes. +// +// The asymmetry is deliberate: a transient probe failure (dockerd +// briefly down, journalctl hung) shouldn't cause a panel-side feature +// to disappear, but a real upgrade/install should be reflected. So we +// only flip false→true here, never true→false. To force a clean view +// the operator can restart the agent. +func mergeCapabilities(prev, fresh protocol.CapabilitiesMessage) protocol.CapabilitiesMessage { + out := fresh + if out.Capabilities == nil { + out.Capabilities = protocol.Capabilities{} + } + for k, v := range prev.Capabilities { + if v && !out.Capabilities[k] { + out.Capabilities[k] = true + } + } + if out.Runtimes == nil { + out.Runtimes = map[string]string{} + } + for k, v := range prev.Runtimes { + if _, ok := out.Runtimes[k]; !ok && v != "" { + out.Runtimes[k] = v + } + } + if out.RuntimeManagers == nil { + out.RuntimeManagers = map[string]string{} + } + for k, v := range prev.RuntimeManagers { + if _, ok := out.RuntimeManagers[k]; !ok && v != "" { + out.RuntimeManagers[k] = v + } + } + return out +} diff --git a/agent/internal/agent/runtime_handlers.go b/agent/internal/agent/runtime_handlers.go new file mode 100644 index 00000000..d6710748 --- /dev/null +++ b/agent/internal/agent/runtime_handlers.go @@ -0,0 +1,400 @@ +package agent + +// Phase 5 — runtime version managers (pyenv on Linux, pyenv-win on +// Windows). The agent already ships a Python version via the +// capability probe; these handlers add install / uninstall / select- +// version flows so users can pick a Python without SSHing in. +// +// Bootstrap and install actions stream on a job channel because they +// take minutes — pyenv compiles CPython from source on Linux and +// downloads + extracts MSI installers on Windows. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/serverkit/agent/internal/jobs" +) + +// pyenvLock serializes pyenv mutations on a single agent. pyenv's +// shim management is not safe under concurrent installs. +var pyenvLock sync.Mutex + +// pyenvBin returns the path to the pyenv binary the agent should use, +// or "" if pyenv isn't installed. On Linux we look at $HOME/.pyenv/bin +// and PATH; on Windows we look at %USERPROFILE%\.pyenv\pyenv-win\bin. +func pyenvBin() string { + if runtime.GOOS == "windows" { + home, _ := os.UserHomeDir() + if home == "" { + home = os.Getenv("USERPROFILE") + } + if home != "" { + candidate := filepath.Join(home, ".pyenv", "pyenv-win", "bin", "pyenv.bat") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + if p, err := exec.LookPath("pyenv"); err == nil { + return p + } + return "" + } + // Linux/macOS + home, _ := os.UserHomeDir() + if home != "" { + candidate := filepath.Join(home, ".pyenv", "bin", "pyenv") + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + if p, err := exec.LookPath("pyenv"); err == nil { + return p + } + return "" +} + +// pyenvManagerKind returns "pyenv", "pyenv-win", or "" for absent. +// Surfaced as RuntimeManagers["python"] in the capabilities payload. +func pyenvManagerKind() string { + if pyenvBin() == "" { + return "" + } + if runtime.GOOS == "windows" { + return "pyenv-win" + } + return "pyenv" +} + +// runPyenv runs the pyenv binary with the given args and returns the +// trimmed stdout/stderr. Used by the read-only handlers. +func runPyenv(ctx context.Context, args ...string) (string, error) { + bin := pyenvBin() + if bin == "" { + return "", errors.New("pyenv not installed (run runtimes:pyenv:bootstrap first)") + } + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + out, err := exec.CommandContext(cctx, bin, args...).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +// ───── runtimes:list ────────────────────────────────────────────── + +func (a *Agent) handleRuntimesList(ctx context.Context, params json.RawMessage) (interface{}, error) { + out := map[string]interface{}{ + "managers": map[string]string{ + "python": pyenvManagerKind(), + }, + "runtimes": a.capabilities.Runtimes, + } + if pyenvManagerKind() != "" { + current, _ := runPyenv(ctx, "version") + installed, _ := runPyenv(ctx, "versions", "--bare") + out["python"] = map[string]interface{}{ + "current": strings.SplitN(current, " ", 2)[0], + "installed": splitNonEmptyLines(installed), + } + } + return out, nil +} + +// ───── runtimes:python:installed ───────────────────────────────── + +func (a *Agent) handleRuntimesPythonInstalled(ctx context.Context, params json.RawMessage) (interface{}, error) { + out, err := runPyenv(ctx, "versions", "--bare") + if err != nil { + return nil, err + } + return map[string]interface{}{ + "versions": splitNonEmptyLines(out), + "manager": pyenvManagerKind(), + }, nil +} + +// ───── runtimes:python:available ───────────────────────────────── + +func (a *Agent) handleRuntimesPythonAvailable(ctx context.Context, params json.RawMessage) (interface{}, error) { + // `pyenv install --list` on Linux and `pyenv install -l` on + // pyenv-win both work, but pyenv-win prints them with leading + // whitespace. Normalize. + out, err := runPyenv(ctx, "install", "--list") + if err != nil && pyenvManagerKind() == "pyenv-win" { + out, err = runPyenv(ctx, "install", "-l") + } + if err != nil { + return nil, err + } + versions := []string{} + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Available") || strings.HasPrefix(line, ":") { + continue + } + versions = append(versions, line) + } + return map[string]interface{}{ + "versions": versions, + "manager": pyenvManagerKind(), + }, nil +} + +// ───── runtimes:python:current ─────────────────────────────────── + +func (a *Agent) handleRuntimesPythonCurrent(ctx context.Context, params json.RawMessage) (interface{}, error) { + out, err := runPyenv(ctx, "version") + if err != nil { + return nil, err + } + parts := strings.SplitN(out, " ", 2) + return map[string]interface{}{ + "version": parts[0], + "raw": out, + }, nil +} + +// ───── runtimes:python:set_global ──────────────────────────────── + +func (a *Agent) handleRuntimesPythonSetGlobal(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Version string `json:"version"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + if err := validateVersion(p.Version); err != nil { + return nil, err + } + out, err := runPyenv(ctx, "global", p.Version) + if err != nil { + return nil, fmt.Errorf("pyenv global %s: %w (%s)", p.Version, err, out) + } + return map[string]interface{}{"global": p.Version}, nil +} + +// ───── runtimes:python:set_local ───────────────────────────────── + +func (a *Agent) handleRuntimesPythonSetLocal(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Version string `json:"version"` + Dir string `json:"dir"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + if err := validateVersion(p.Version); err != nil { + return nil, err + } + abs, err := filepath.Abs(p.Dir) + if err != nil { + return nil, fmt.Errorf("dir: %w", err) + } + if !a.contextPathAllowed(abs) { + return nil, fmt.Errorf("dir %q is not under any allowed root", abs) + } + bin := pyenvBin() + if bin == "" { + return nil, errors.New("pyenv not installed") + } + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + cmd := exec.CommandContext(cctx, bin, "local", p.Version) + cmd.Dir = abs + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("pyenv local %s in %s: %w (%s)", p.Version, abs, err, string(out)) + } + return map[string]interface{}{"local": p.Version, "dir": abs}, nil +} + +// ───── runtimes:python:uninstall ───────────────────────────────── + +func (a *Agent) handleRuntimesPythonUninstall(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Version string `json:"version"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + if err := validateVersion(p.Version); err != nil { + return nil, err + } + pyenvLock.Lock() + defer pyenvLock.Unlock() + out, err := runPyenv(ctx, "uninstall", "-f", p.Version) + if err != nil { + return nil, fmt.Errorf("pyenv uninstall %s: %w (%s)", p.Version, err, out) + } + return map[string]interface{}{"uninstalled": p.Version}, nil +} + +// ───── runtimes:python:install (streaming) ─────────────────────── + +func (a *Agent) handleRuntimesPythonInstall(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Version string `json:"version"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + if err := validateVersion(p.Version); err != nil { + return nil, err + } + if pyenvBin() == "" { + return nil, errors.New("pyenv not installed (run runtimes:pyenv:bootstrap first)") + } + job := a.jobs.New(500) + go a.runPyenvInstallJob(job, p.Version) + return map[string]interface{}{ + "job_id": job.ID, + "channel": job.Channel, + "version": p.Version, + }, nil +} + +func (a *Agent) runPyenvInstallJob(job *jobs.Job, version string) { + pyenvLock.Lock() + defer pyenvLock.Unlock() + + exit := 0 + emitDone := func(errStr string) { + _ = job.Push(a.ws, jobs.Event{ + Phase: jobs.PhaseDone, + ExitCode: &exit, + Error: errStr, + Extra: map[string]interface{}{"version": version, "manager": pyenvManagerKind()}, + }) + } + + cctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + cmd := exec.CommandContext(cctx, pyenvBin(), "install", "-s", version) + _ = job.Push(a.ws, jobs.Event{Phase: jobs.PhaseStart, Message: fmt.Sprintf("pyenv install %s", version)}) + if err := streamCmdOutput(cmd, job, a.ws); err != nil { + exit = -1 + emitDone(err.Error()) + return + } + if cerr := cmd.Wait(); cerr != nil { + exit = exitCodeOfCmd(cerr) + if exit == 0 { + exit = -1 + } + emitDone(cerr.Error()) + return + } + emitDone("") +} + +// ───── runtimes:pyenv:bootstrap (streaming) ────────────────────── +// +// Linux: clones pyenv to ~/.pyenv. Does NOT touch ~/.bashrc — most +// agents run as a non-login systemd unit and the user already has +// shell init from their interactive sessions; we don't want to +// duplicate it. Clone path matches the upstream installer. +// +// Windows: clones pyenv-win to %USERPROFILE%\.pyenv\pyenv-win. + +func (a *Agent) handleRuntimesPyenvBootstrap(ctx context.Context, params json.RawMessage) (interface{}, error) { + if pyenvBin() != "" { + return map[string]interface{}{ + "status": "already_installed", + "manager": pyenvManagerKind(), + }, nil + } + if _, err := exec.LookPath("git"); err != nil { + return nil, errors.New("git is required to install pyenv (please install git first)") + } + job := a.jobs.New(200) + go a.runPyenvBootstrapJob(job) + return map[string]interface{}{ + "job_id": job.ID, + "channel": job.Channel, + }, nil +} + +func (a *Agent) runPyenvBootstrapJob(job *jobs.Job) { + exit := 0 + emitDone := func(errStr string) { + _ = job.Push(a.ws, jobs.Event{ + Phase: jobs.PhaseDone, + ExitCode: &exit, + Error: errStr, + Extra: map[string]interface{}{"manager": pyenvManagerKind()}, + }) + } + + home, _ := os.UserHomeDir() + if home == "" && runtime.GOOS == "windows" { + home = os.Getenv("USERPROFILE") + } + if home == "" { + exit = -1 + emitDone("could not determine user home directory") + return + } + + var repo, dest string + if runtime.GOOS == "windows" { + repo = "https://github.com/pyenv-win/pyenv-win.git" + dest = filepath.Join(home, ".pyenv") + } else { + repo = "https://github.com/pyenv/pyenv.git" + dest = filepath.Join(home, ".pyenv") + } + + _ = job.Push(a.ws, jobs.Event{Phase: jobs.PhaseStart, Message: fmt.Sprintf("git clone %s %s", repo, dest)}) + + cctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + cmd := exec.CommandContext(cctx, "git", "clone", "--depth", "1", repo, dest) + if err := streamCmdOutput(cmd, job, a.ws); err != nil { + exit = -1 + emitDone(err.Error()) + return + } + if cerr := cmd.Wait(); cerr != nil { + exit = exitCodeOfCmd(cerr) + if exit == 0 { + exit = -1 + } + emitDone(cerr.Error()) + return + } + emitDone("") +} + +// validateVersion rejects shell metacharacters in the user-supplied +// pyenv version reference. pyenv tags are dotted-version strings ("3.12.0", +// "3.13.0a4") so the allow-list is conservative. +func validateVersion(v string) error { + v = strings.TrimSpace(v) + if v == "" { + return errors.New("version is required") + } + for _, r := range v { + if r >= 'a' && r <= 'z' { + continue + } + if r >= 'A' && r <= 'Z' { + continue + } + if r >= '0' && r <= '9' { + continue + } + if r == '.' || r == '-' || r == '_' || r == '+' { + continue + } + return fmt.Errorf("invalid version: %q", v) + } + return nil +} diff --git a/agent/internal/agent/sampler.go b/agent/internal/agent/sampler.go new file mode 100644 index 00000000..e92e50ef --- /dev/null +++ b/agent/internal/agent/sampler.go @@ -0,0 +1,80 @@ +package agent + +import ( + "context" + "sync" + "time" + + "github.com/serverkit/agent/internal/ipc" +) + +// metricSampler maintains a fixed-size ring buffer of CPU and memory samples +// taken once per second. The agent's main metrics path (Collect → emit over +// the WebSocket) already pulls live values, but the desktop console needs a +// short-lived history to render sparklines without keeping a long-lived +// websocket of its own to the agent. 300 samples × 1 s = 5 minutes of +// scrollback, which matches the Overview tab design. +type metricSampler struct { + mu sync.Mutex + samples []ipc.MetricSample + cap int +} + +func newMetricSampler(capacity int) *metricSampler { + return &metricSampler{ + samples: make([]ipc.MetricSample, 0, capacity), + cap: capacity, + } +} + +// push records one sample, evicting the oldest when the buffer is full. +func (s *metricSampler) push(sample ipc.MetricSample) { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.samples) >= s.cap { + copy(s.samples, s.samples[1:]) + s.samples = s.samples[:len(s.samples)-1] + } + s.samples = append(s.samples, sample) +} + +// snapshot returns a copy of the current samples in chronological order so +// the caller can serialize them without holding the lock. +func (s *metricSampler) snapshot() []ipc.MetricSample { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]ipc.MetricSample, len(s.samples)) + copy(out, s.samples) + return out +} + +// samplerLoop runs until ctx is cancelled. Every tick it pulls one CPU/Mem +// sample from the agent's metrics collector and pushes it into the ring. +// Transient collection errors are swallowed: a one-tick gap is preferable +// to a stutter in the graph or a noisy log. +func (a *Agent) samplerLoop(ctx context.Context) { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if a.metrics == nil || a.sampler == nil { + continue + } + collectCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond) + sm, err := a.metrics.Collect(collectCtx) + cancel() + if err != nil { + continue + } + a.sampler.push(ipc.MetricSample{ + Timestamp: time.Now().UnixMilli(), + CPU: sm.CPUPercent, + Mem: sm.MemoryPercent, + }) + } + } +} diff --git a/agent/internal/agent/security_test.go b/agent/internal/agent/security_test.go new file mode 100644 index 00000000..eb7a84e6 --- /dev/null +++ b/agent/internal/agent/security_test.go @@ -0,0 +1,199 @@ +package agent + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/serverkit/agent/internal/auth" + "github.com/serverkit/agent/internal/config" + "github.com/serverkit/agent/pkg/protocol" +) + +func TestCommandBlocked(t *testing.T) { + cases := []struct { + name string + blocked []string + cmd string + want bool + }{ + {"empty list allows everything", nil, "/bin/ls", false}, + {"absolute path match", []string{"/usr/bin/rm"}, "/usr/bin/rm", true}, + {"basename match against absolute path", []string{"rm"}, "/usr/bin/rm", true}, + {"absolute-to-basename match", []string{"/sbin/shutdown"}, "/usr/local/sbin/shutdown", true}, + {"unrelated command not blocked", []string{"/usr/bin/rm"}, "/usr/bin/ls", false}, + {"blank entries ignored", []string{"", " ", "/usr/bin/rm"}, "/usr/bin/rm", true}, + {"blank entries don't deny everything", []string{"", " "}, "/bin/ls", false}, + {"whitespace trimmed on entries", []string{" /bin/dd "}, "/bin/dd", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := commandBlocked(tc.blocked, tc.cmd); got != tc.want { + t.Fatalf("commandBlocked(%v, %q) = %v, want %v", tc.blocked, tc.cmd, got, tc.want) + } + }) + } +} + +func TestResolveMaxExecTimeout(t *testing.T) { + a := &Agent{cfg: &config.Config{}} + got := a.resolveMaxExecTimeout() + if got != defaultMaxExecTimeout { + t.Fatalf("default fallback: got %s, want %s", got, defaultMaxExecTimeout) + } + + a.cfg.Security.MaxExecTimeout = defaultMaxExecTimeout / 2 + if got := a.resolveMaxExecTimeout(); got != defaultMaxExecTimeout/2 { + t.Fatalf("operator override: got %s, want %s", got, defaultMaxExecTimeout/2) + } + + a.cfg.Security.MaxExecTimeout = -1 + if got := a.resolveMaxExecTimeout(); got != defaultMaxExecTimeout { + t.Fatalf("negative ignored: got %s, want %s", got, defaultMaxExecTimeout) + } +} + +func TestValidateFileAccessAllowsConfiguredRoot(t *testing.T) { + root := t.TempDir() + a := &Agent{cfg: &config.Config{ + Security: config.SecurityConfig{AllowedPaths: []string{root}}, + }} + target := filepath.Join(root, "sub", "file.txt") + // Create the parent so EvalSymlinks succeeds for the parent walk. + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := a.validateFileAccess(target); err != nil { + t.Fatalf("expected access allowed for %s: %v", target, err) + } +} + +func TestValidateFileAccessDeniesOutsideRoot(t *testing.T) { + root := t.TempDir() + other := t.TempDir() + a := &Agent{cfg: &config.Config{ + Security: config.SecurityConfig{AllowedPaths: []string{root}}, + }} + if err := a.validateFileAccess(filepath.Join(other, "file.txt")); err == nil { + t.Fatalf("expected access denied for path outside allowed root") + } +} + +func TestValidateFileAccessDeniesSymlinkEscape(t *testing.T) { + if runtime.GOOS == "windows" { + // Windows symlink creation requires elevated privileges or + // SeCreateSymbolicLinkPrivilege in CI; skip rather than make + // the test environmentally fragile. + t.Skip("symlink test requires non-Windows environment") + } + root := t.TempDir() + secret := t.TempDir() + a := &Agent{cfg: &config.Config{ + Security: config.SecurityConfig{AllowedPaths: []string{root}}, + }} + // Drop a symlink inside the allowed root that points outside it. + // The pre-fix validateFileAccess would let the escape through + // because filepath.Clean doesn't resolve symlinks. + link := filepath.Join(root, "escape") + if err := os.Symlink(secret, link); err != nil { + t.Fatalf("symlink: %v", err) + } + target := filepath.Join(link, "file.txt") + if err := a.validateFileAccess(target); err == nil { + t.Fatalf("expected symlink escape to be denied; %s resolved past allowed root", target) + } +} + +func TestValidateFileAccessNoAllowedPaths(t *testing.T) { + a := &Agent{cfg: &config.Config{}} + if err := a.validateFileAccess("/anything"); err == nil { + t.Fatalf("expected denial when AllowedPaths is empty") + } +} + +func TestVerifyCredentialRotation(t *testing.T) { + currentSecret := "current-secret-value" + rotationID := "rot-123" + agentID := "agent-uuid" + newKey := "sk_newkey" + newSecret := "new-secret-value" + + a := &Agent{ + cfg: &config.Config{Agent: config.AgentConfig{ID: agentID}}, + auth: auth.New(agentID, "sk_oldkey", currentSecret), + } + + sign := func(secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(fmt.Sprintf("%s:%s:%s:%s", rotationID, agentID, newKey, newSecret))) + return hex.EncodeToString(mac.Sum(nil)) + } + + t.Run("valid signature accepted", func(t *testing.T) { + msg := protocol.CredentialUpdateMessage{ + RotationID: rotationID, + APIKey: newKey, + APISecret: newSecret, + HMACSig: sign(currentSecret), + } + if err := a.verifyCredentialRotation(msg); err != nil { + t.Fatalf("expected valid rotation accepted, got: %v", err) + } + }) + + t.Run("missing signature rejected", func(t *testing.T) { + msg := protocol.CredentialUpdateMessage{ + RotationID: rotationID, + APIKey: newKey, + APISecret: newSecret, + } + if err := a.verifyCredentialRotation(msg); err == nil { + t.Fatalf("expected rejection for missing signature") + } + }) + + t.Run("wrong-secret signature rejected", func(t *testing.T) { + msg := protocol.CredentialUpdateMessage{ + RotationID: rotationID, + APIKey: newKey, + APISecret: newSecret, + HMACSig: sign("attacker-guessed-secret"), + } + if err := a.verifyCredentialRotation(msg); err == nil { + t.Fatalf("expected rejection when HMAC computed with wrong secret") + } + }) + + t.Run("missing required field rejected", func(t *testing.T) { + msg := protocol.CredentialUpdateMessage{ + RotationID: "", + APIKey: newKey, + APISecret: newSecret, + HMACSig: sign(currentSecret), + } + if err := a.verifyCredentialRotation(msg); err == nil { + t.Fatalf("expected rejection for missing rotation_id") + } + }) + + t.Run("agent without secret refuses rotation", func(t *testing.T) { + empty := &Agent{ + cfg: &config.Config{Agent: config.AgentConfig{ID: agentID}}, + auth: auth.New(agentID, "", ""), + } + msg := protocol.CredentialUpdateMessage{ + RotationID: rotationID, + APIKey: newKey, + APISecret: newSecret, + HMACSig: sign(""), + } + if err := empty.verifyCredentialRotation(msg); err == nil { + t.Fatalf("expected rejection when agent has no current secret") + } + }) +} diff --git a/agent/internal/agent/sudo.go b/agent/internal/agent/sudo.go new file mode 100644 index 00000000..8fef5666 --- /dev/null +++ b/agent/internal/agent/sudo.go @@ -0,0 +1,124 @@ +package agent + +// Privilege escalation helpers. The agent's install script lays down a +// dedicated `serverkit-agent` user, so handlers that touch system state +// (systemctl, apt, dnf, journalctl on system journals) need to escalate +// when the agent isn't running as root. We use passwordless sudo (-n) +// so a hung password prompt can't tie up a request. +// +// The decision is made once at startup: probe whether `sudo -n true` +// succeeds, cache the result, and surface it on the capabilities +// payload as one of "root", "passwordless", or "unavailable" so the +// panel can warn users *before* they try to install something. +// +// When the mode is "unavailable", privileged handlers fail fast with a +// "sudo_required" error instead of spawning a subprocess that would +// deadlock waiting for a password. + +import ( + "context" + "errors" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +// SudoMode describes how the agent escalates privileges. +type SudoMode string + +const ( + SudoRoot SudoMode = "root" // EUID == 0 + SudoPasswordless SudoMode = "passwordless" // sudo -n works + SudoUnavailable SudoMode = "unavailable" // no escalation possible +) + +// errSudoRequired is returned by privileged handlers when escalation +// isn't available. Wrapped with extra context by the calling handler. +var errSudoRequired = errors.New("sudo required: agent is running as a non-root user and passwordless sudo is not configured") + +// probeSudoMode determines the agent's escalation capability. Called +// once at startup; the result is cached in Agent.sudoMode. +func probeSudoMode(ctx context.Context) SudoMode { + if runtime.GOOS != "linux" { + // Non-linux hosts don't run the privileged handlers anyway — + // systemd/packages capabilities are gated off on those. + if isAdminWindows() { + return SudoRoot + } + return SudoUnavailable + } + if os.Geteuid() == 0 { + return SudoRoot + } + if _, err := exec.LookPath("sudo"); err != nil { + return SudoUnavailable + } + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + cmd := exec.CommandContext(cctx, "sudo", "-n", "true") + if err := cmd.Run(); err != nil { + return SudoUnavailable + } + return SudoPasswordless +} + +// isAdminWindows is a placeholder: the privileged handlers are gated by +// runtime.GOOS == "linux" anyway, so the only effect is the value +// surfaced on the capabilities payload. Returning false everywhere on +// Windows is safe. +func isAdminWindows() bool { return false } + +// sudoCommandContext returns an exec.Cmd that runs `name args...` with +// privilege escalation according to the agent's sudo mode. When the +// mode is "unavailable", it returns nil and a sudo_required error — +// callers must check both. +// +// The returned Cmd has DEBIAN_FRONTEND=noninteractive in its env so +// dpkg conffile prompts don't hang installs in production. Callers +// that need additional env should append to cmd.Env after the call. +func sudoCommandContext(ctx context.Context, mode SudoMode, name string, args ...string) (*exec.Cmd, error) { + switch mode { + case SudoRoot: + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = privilegedEnv() + return cmd, nil + case SudoPasswordless: + full := append([]string{"-n", name}, args...) + cmd := exec.CommandContext(ctx, "sudo", full...) + cmd.Env = privilegedEnv() + return cmd, nil + default: + return nil, errSudoRequired + } +} + +// privilegedEnv builds the env block used for privileged commands. +// Inherits the parent process env and adds DEBIAN_FRONTEND so apt +// upgrades don't stop on conffile prompts. +func privilegedEnv() []string { + out := append([]string{}, os.Environ()...) + out = append(out, "DEBIAN_FRONTEND=noninteractive") + return out +} + +// classifySudoError matches stderr text from a failed escalation +// attempt and returns true when sudo specifically refused (password +// required, terminal required, no permission). Used to wrap downstream +// errors with a clean "sudo_required" code instead of leaking the raw +// sudo banner to the panel. +func classifySudoError(stderr string) bool { + low := strings.ToLower(stderr) + for _, marker := range []string{ + "a password is required", + "a terminal is required", + "sudo: not allowed", + "sudo: must be setuid root", + } { + if strings.Contains(low, marker) { + return true + } + } + return false +} diff --git a/agent/internal/agent/systemd_handlers.go b/agent/internal/agent/systemd_handlers.go new file mode 100644 index 00000000..5cb7c9df --- /dev/null +++ b/agent/internal/agent/systemd_handlers.go @@ -0,0 +1,363 @@ +package agent + +// Phase 1c additions — systemd actions beyond status/start/stop/restart/ +// enable/disable: daemon-reload, list-units, journal logs (bounded fetch +// and follow-stream variants). +// +// list-units prefers `systemctl --output=json` (systemd 244+) for clean +// parsing and falls back to plain text on older systems. The choice is +// probed once and cached on the agent so we don't pay the fork cost on +// every list call. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/serverkit/agent/internal/jobs" + "github.com/serverkit/agent/pkg/protocol" +) + +// runSystemctlPrivileged is the sudo-aware variant of runSystemctl. It +// preserves the existing 30-second timeout for read operations; long +// operations (start/stop/restart/daemon-reload) override via timeout +// param. +func (a *Agent) runSystemctlPrivileged(ctx context.Context, timeout time.Duration, args ...string) (string, error) { + cctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + cmd, err := sudoCommandContext(cctx, a.sudoMode, "systemctl", args...) + if err != nil { + return "", err + } + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +// ───── systemd:daemon_reload ────────────────────────────────────── + +func (a *Agent) handleSystemdDaemonReload(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := systemctlAvailable(); err != nil { + return nil, err + } + out, err := a.runSystemctlPrivileged(ctx, 5*time.Minute, "daemon-reload") + if err != nil { + if classifySudoError(out) { + return nil, fmt.Errorf("sudo refused: %w", errSudoRequired) + } + return nil, fmt.Errorf("systemctl daemon-reload: %w (%s)", err, out) + } + return map[string]interface{}{ + "action": "daemon_reloaded", + "output": out, + }, nil +} + +// ───── systemd:list_units ───────────────────────────────────────── + +// systemctlUnit is the canonical row shape returned by list_units +// regardless of whether we got JSON or plain text from systemctl. +type systemctlUnit struct { + Unit string `json:"unit"` + Load string `json:"load,omitempty"` + Active string `json:"active,omitempty"` + Sub string `json:"sub,omitempty"` + Description string `json:"description,omitempty"` +} + +func (a *Agent) handleSystemdListUnits(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := systemctlAvailable(); err != nil { + return nil, err + } + var p struct { + State string `json:"state"` // optional filter: failed, active, inactive… + Type string `json:"type"` // unit type: service (default), socket, timer… + } + _ = json.Unmarshal(params, &p) + if p.Type == "" { + p.Type = "service" + } + if err := validateUnitName(p.Type); err != nil { + return nil, fmt.Errorf("type: %w", err) + } + if p.State != "" { + if err := validateUnitName(p.State); err != nil { + return nil, fmt.Errorf("state: %w", err) + } + } + + args := []string{"list-units", "--all", "--no-legend", "--no-pager", "--type=" + p.Type} + if p.State != "" { + args = append(args, "--state="+p.State) + } + + useJSON := a.detectSystemctlJSON(ctx) + if useJSON { + jsonArgs := append(append([]string{}, args...), "--output=json") + out, err := a.runSystemctlPrivileged(ctx, 30*time.Second, jsonArgs...) + if err == nil { + var rows []map[string]interface{} + if jerr := json.Unmarshal([]byte(out), &rows); jerr == nil { + units := make([]systemctlUnit, 0, len(rows)) + for _, r := range rows { + u := systemctlUnit{} + if v, ok := r["unit"].(string); ok { + u.Unit = v + } + if v, ok := r["load"].(string); ok { + u.Load = v + } + if v, ok := r["active"].(string); ok { + u.Active = v + } + if v, ok := r["sub"].(string); ok { + u.Sub = v + } + if v, ok := r["description"].(string); ok { + u.Description = v + } + units = append(units, u) + } + return map[string]interface{}{"units": units, "format": "json"}, nil + } + } + // JSON failed (older systemd, malformed output) — fall through + // to plain text and remember the choice. + a.capMu.Lock() + a.capabilities.SystemdJSON = false + a.systemdJSON = false + a.capMu.Unlock() + } + + // Plain text fallback. Columns: UNIT LOAD ACTIVE SUB DESCRIPTION. + out, err := a.runSystemctlPrivileged(ctx, 30*time.Second, args...) + if err != nil { + if classifySudoError(out) { + return nil, fmt.Errorf("sudo refused: %w", errSudoRequired) + } + return nil, fmt.Errorf("systemctl list-units: %w (%s)", err, truncate(out, 1024)) + } + units := parsePlainListUnits(out) + return map[string]interface{}{"units": units, "format": "plain"}, nil +} + +// detectSystemctlJSON probes once whether `systemctl --output=json` is +// supported. Caches the result on the agent + advertises via the +// capabilities payload so the panel can show an "older systemd" hint. +func (a *Agent) detectSystemctlJSON(ctx context.Context) bool { + a.systemdJSONOnce.Do(func() { + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + err := exec.CommandContext(cctx, "systemctl", "list-units", "--no-legend", "--no-pager", "--output=json", "--type=service").Run() + ok := err == nil + a.capMu.Lock() + a.systemdJSON = ok + a.capabilities.SystemdJSON = ok + a.capMu.Unlock() + }) + a.capMu.Lock() + defer a.capMu.Unlock() + return a.systemdJSON +} + +func parsePlainListUnits(out string) []systemctlUnit { + units := []systemctlUnit{} + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "●") { + continue + } + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + u := systemctlUnit{ + Unit: fields[0], + Load: fields[1], + Active: fields[2], + Sub: fields[3], + } + if len(fields) > 4 { + u.Description = strings.Join(fields[4:], " ") + } + units = append(units, u) + } + return units +} + +// ───── systemd:logs (bounded fetch) ────────────────────────────── + +func (a *Agent) handleSystemdLogs(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := systemctlAvailable(); err != nil { + return nil, err + } + var p struct { + Unit string `json:"unit"` + Lines int `json:"lines"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := validateUnitName(p.Unit); err != nil { + return nil, err + } + if p.Lines <= 0 { + p.Lines = 200 + } + if p.Lines > 5000 { + p.Lines = 5000 + } + + if _, err := exec.LookPath("journalctl"); err != nil { + return nil, errors.New("journalctl not on PATH") + } + + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + cmd, err := sudoCommandContext(cctx, a.sudoMode, "journalctl", + "-u", p.Unit, "--no-pager", "-n", fmt.Sprintf("%d", p.Lines), "-o", "json") + if err != nil { + return nil, err + } + out, err := cmd.CombinedOutput() + if err != nil { + if classifySudoError(string(out)) { + return nil, fmt.Errorf("sudo refused: %w", errSudoRequired) + } + return nil, fmt.Errorf("journalctl -u %s: %w (%s)", p.Unit, err, truncate(string(out), 1024)) + } + + // journalctl -o json prints one JSON object per line. Parse each + // into a flat shape the panel can render directly. + type entry struct { + Time string `json:"time"` + Priority string `json:"priority"` + PID string `json:"pid"` + Message string `json:"message"` + } + var entries []entry + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var raw map[string]interface{} + if err := json.Unmarshal([]byte(line), &raw); err != nil { + continue + } + e := entry{ + Time: stringField(raw, "__REALTIME_TIMESTAMP"), + Priority: stringField(raw, "PRIORITY"), + PID: stringField(raw, "_PID"), + Message: stringField(raw, "MESSAGE"), + } + entries = append(entries, e) + } + return map[string]interface{}{ + "unit": p.Unit, + "lines": p.Lines, + "entries": entries, + }, nil +} + +func stringField(m map[string]interface{}, k string) string { + v, ok := m[k] + if !ok { + return "" + } + switch t := v.(type) { + case string: + return t + case float64: + return fmt.Sprintf("%.0f", t) + default: + return fmt.Sprintf("%v", t) + } +} + +// ───── systemd:logs_follow (streaming) ─────────────────────────── + +// handleSystemdLogsFollow returns a job channel and starts streaming +// `journalctl -fu ` lines on it. The job ends when either the +// caller unsubscribes (which we detect by closing job.Done) or a fixed +// 1-hour cap elapses to prevent runaway logs. +// +// The pattern mirrors handlePackagesInstallAsync: hand back +// {job_id, channel} immediately, work happens in a goroutine. +func (a *Agent) handleSystemdLogsFollow(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := systemctlAvailable(); err != nil { + return nil, err + } + var p struct { + Unit string `json:"unit"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := validateUnitName(p.Unit); err != nil { + return nil, err + } + if _, err := exec.LookPath("journalctl"); err != nil { + return nil, errors.New("journalctl not on PATH") + } + + job := a.jobs.New(500) + // The protocol channel format is documented but we override to use + // the job: namespace so the panel's existing job-channel + // infrastructure handles it. The systemd::logs format is + // reserved for a future multi-subscriber broadcast variant. + _ = protocol.ChannelSystemdLogs + go a.runSystemdLogsFollowJob(job, p.Unit) + return map[string]interface{}{ + "job_id": job.ID, + "channel": job.Channel, + "unit": p.Unit, + }, nil +} + +func (a *Agent) runSystemdLogsFollowJob(job *jobs.Job, unit string) { + exit := 0 + emitDone := func(errStr string) { + _ = job.Push(a.ws, jobs.Event{ + Phase: jobs.PhaseDone, + ExitCode: &exit, + Error: errStr, + Extra: map[string]interface{}{"unit": unit}, + }) + } + + // Cap any single follow at 1 hour. Panel can re-subscribe. + cctx, cancel := context.WithTimeout(context.Background(), 1*time.Hour) + defer cancel() + cmd, err := sudoCommandContext(cctx, a.sudoMode, "journalctl", + "-fu", unit, "--no-pager", "-o", "short-iso") + if err != nil { + exit = -1 + emitDone(err.Error()) + return + } + _ = job.Push(a.ws, jobs.Event{ + Phase: jobs.PhaseStart, + Message: fmt.Sprintf("following journal for %s", unit), + }) + if err := streamCmdOutput(cmd, job, a.ws); err != nil { + exit = -1 + emitDone(err.Error()) + return + } + if cerr := cmd.Wait(); cerr != nil { + exit = exitCodeOf(cerr) + // journalctl returns non-zero when killed by the deadline; that's + // a graceful end, not an error. + if cctx.Err() == context.DeadlineExceeded { + emitDone("") + return + } + emitDone(cerr.Error()) + return + } + emitDone("") +} diff --git a/agent/internal/agent/workflow_handlers.go b/agent/internal/agent/workflow_handlers.go new file mode 100644 index 00000000..4dee27aa --- /dev/null +++ b/agent/internal/agent/workflow_handlers.go @@ -0,0 +1,481 @@ +package agent + +// Phase 4 primitive handlers — package install/remove/list, systemd +// unit control, and bounded shell exec. The workflow engine on the +// panel sequences these to drive playbooks like "install Docker" or +// "install LAMP" against any agent-managed server. +// +// All handlers are Linux-only. The capability probe advertises +// "packages" / "systemd" only on Linux hosts where the relevant tooling +// is present, so the panel won't normally call these on Windows/macOS; +// the runtime guard here is belt-and-suspenders. +// +// Idempotency is the contract: re-running an install on a host where +// the package is already there returns success with installed=true and +// no side effects. Same for systemd start on a unit already active. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + // Package install can be slow on a fresh host (apt update + index + // + downloads). Bound at 5 minutes — longer than any single + // install we've seen, short enough to surface a hung mirror. + packageOpTimeout = 5 * time.Minute + // systemctl operations are quick; if one hangs longer than 30s + // something is wrong (mount-blocked unit, hung shutdown). + systemdOpTimeout = 30 * time.Second + // Hard ceiling on exec timeouts when the operator hasn't set + // Security.MaxExecTimeout. The configured value, if any, wins — + // this is just the safety net so a misconfigured agent can't run + // arbitrary commands forever. + defaultMaxExecTimeout = 10 * time.Minute +) + +// ───── packages ──────────────────────────────────────────────────── + +type packageManager struct { + bin string // "apt-get", "dnf", "apk", "pacman", "zypper" + install []string // args before package name(s) + remove []string + list []string + queryFlag string // arg pattern for "is X installed" +} + +// detectPackageManager returns the active manager on PATH or an error +// if none is recognised. Order matters — apt-get is preferred over apt +// for non-interactive use. +func detectPackageManager() (*packageManager, error) { + if runtime.GOOS != "linux" { + return nil, errors.New("packages:* is Linux-only") + } + candidates := []*packageManager{ + {bin: "apt-get", install: []string{"install", "-y"}, remove: []string{"remove", "-y"}, list: []string{"list", "--installed"}, queryFlag: "dpkg"}, + {bin: "dnf", install: []string{"install", "-y"}, remove: []string{"remove", "-y"}, list: []string{"list", "installed"}, queryFlag: "rpm"}, + {bin: "yum", install: []string{"install", "-y"}, remove: []string{"remove", "-y"}, list: []string{"list", "installed"}, queryFlag: "rpm"}, + {bin: "apk", install: []string{"add"}, remove: []string{"del"}, list: []string{"info"}, queryFlag: "apk"}, + {bin: "pacman", install: []string{"-S", "--noconfirm"}, remove: []string{"-R", "--noconfirm"}, list: []string{"-Q"}, queryFlag: "pacman"}, + {bin: "zypper", install: []string{"install", "-y"}, remove: []string{"remove", "-y"}, list: []string{"se", "--installed-only"}, queryFlag: "rpm"}, + } + for _, c := range candidates { + if _, err := exec.LookPath(c.bin); err == nil { + return c, nil + } + } + return nil, errors.New("no recognised package manager on PATH") +} + +// isInstalled checks the package database directly so install/remove +// can short-circuit when there's nothing to do. Avoids re-running the +// full manager (which on apt would otherwise refresh sources first). +func (pm *packageManager) isInstalled(ctx context.Context, name string) (bool, error) { + switch pm.queryFlag { + case "dpkg": + out, err := exec.CommandContext(ctx, "dpkg-query", "-W", "-f=${Status}", name).Output() + if err != nil { + return false, nil // not installed + } + return strings.Contains(string(out), "install ok installed"), nil + case "rpm": + err := exec.CommandContext(ctx, "rpm", "-q", name).Run() + return err == nil, nil + case "apk": + err := exec.CommandContext(ctx, "apk", "info", "-e", name).Run() + return err == nil, nil + case "pacman": + err := exec.CommandContext(ctx, "pacman", "-Q", name).Run() + return err == nil, nil + } + return false, fmt.Errorf("unsupported query flag: %s", pm.queryFlag) +} + +func validatePackageName(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return errors.New("name is required") + } + // Reject shell metacharacters — the package name goes straight + // into argv but we still don't want to explain to a user why + // `apt-get install "foo; rm -rf /"` worked. + if strings.ContainsAny(name, ";&|`$<>\n\r\"'\\") { + return fmt.Errorf("invalid package name: %q", name) + } + return nil +} + +func (a *Agent) handlePackagesInstall(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Name string `json:"name"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := validatePackageName(p.Name); err != nil { + return nil, err + } + pm, err := detectPackageManager() + if err != nil { + return nil, err + } + + // Idempotency: check first so a re-run is cheap and obvious. + if installed, _ := pm.isInstalled(ctx, p.Name); installed { + return map[string]interface{}{ + "package": p.Name, + "installed": true, + "action": "noop", + "manager": pm.bin, + }, nil + } + + cctx, cancel := context.WithTimeout(ctx, packageOpTimeout) + defer cancel() + args := append(append([]string{}, pm.install...), p.Name) + out, err := exec.CommandContext(cctx, pm.bin, args...).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%s %s: %w (%s)", pm.bin, strings.Join(args, " "), err, truncate(string(out), 1024)) + } + return map[string]interface{}{ + "package": p.Name, + "installed": true, + "action": "installed", + "manager": pm.bin, + "output": truncate(string(out), 4096), + }, nil +} + +func (a *Agent) handlePackagesRemove(ctx context.Context, params json.RawMessage) (interface{}, error) { + var p struct { + Name string `json:"name"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + if err := validatePackageName(p.Name); err != nil { + return nil, err + } + pm, err := detectPackageManager() + if err != nil { + return nil, err + } + + if installed, _ := pm.isInstalled(ctx, p.Name); !installed { + return map[string]interface{}{ + "package": p.Name, + "installed": false, + "action": "noop", + "manager": pm.bin, + }, nil + } + + cctx, cancel := context.WithTimeout(ctx, packageOpTimeout) + defer cancel() + args := append(append([]string{}, pm.remove...), p.Name) + out, err := exec.CommandContext(cctx, pm.bin, args...).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%s %s: %w (%s)", pm.bin, strings.Join(args, " "), err, truncate(string(out), 1024)) + } + return map[string]interface{}{ + "package": p.Name, + "installed": false, + "action": "removed", + "manager": pm.bin, + "output": truncate(string(out), 4096), + }, nil +} + +func (a *Agent) handlePackagesListInstalled(ctx context.Context, params json.RawMessage) (interface{}, error) { + pm, err := detectPackageManager() + if err != nil { + return nil, err + } + cctx, cancel := context.WithTimeout(ctx, packageOpTimeout) + defer cancel() + out, err := exec.CommandContext(cctx, pm.bin, pm.list...).Output() + if err != nil { + return nil, fmt.Errorf("%s %s: %w", pm.bin, strings.Join(pm.list, " "), err) + } + // Don't try to parse — formats vary too much. Ship the raw output + // and let downstream tooling pick what it cares about. + return map[string]interface{}{ + "manager": pm.bin, + "output": truncate(string(out), 1024*256), + }, nil +} + +// ───── systemd ───────────────────────────────────────────────────── + +func validateUnitName(name string) error { + name = strings.TrimSpace(name) + if name == "" { + return errors.New("unit is required") + } + if strings.ContainsAny(name, ";&|`$<>\n\r\"'\\ ") { + return fmt.Errorf("invalid unit name: %q", name) + } + return nil +} + +func systemctlAvailable() error { + if runtime.GOOS != "linux" { + return errors.New("systemd:* is Linux-only") + } + if _, err := exec.LookPath("systemctl"); err != nil { + return errors.New("systemctl not on PATH") + } + return nil +} + +func runSystemctl(ctx context.Context, args ...string) (string, error) { + cctx, cancel := context.WithTimeout(ctx, systemdOpTimeout) + defer cancel() + out, err := exec.CommandContext(cctx, "systemctl", args...).CombinedOutput() + return strings.TrimSpace(string(out)), err +} + +func parseUnitParam(params json.RawMessage) (string, error) { + var p struct { + Unit string `json:"unit"` + } + if err := json.Unmarshal(params, &p); err != nil { + return "", fmt.Errorf("invalid params: %w", err) + } + if err := validateUnitName(p.Unit); err != nil { + return "", err + } + return p.Unit, nil +} + +func (a *Agent) handleSystemdStatus(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := systemctlAvailable(); err != nil { + return nil, err + } + unit, err := parseUnitParam(params) + if err != nil { + return nil, err + } + // is-active exits non-zero for inactive — that's expected, capture it. + active, _ := runSystemctl(ctx, "is-active", unit) + enabled, _ := runSystemctl(ctx, "is-enabled", unit) + return map[string]interface{}{ + "unit": unit, + "active": active, + "enabled": enabled, + }, nil +} + +// systemdMutateTimeout is the per-action timeout for state-changing +// systemctl calls (start/stop/restart/enable/disable). Cold service +// starts (postgresql, mongodb) routinely take 30-90s, so the original +// 30s ceiling was too tight; bump to 2 minutes. +const systemdMutateTimeout = 2 * time.Minute + +func (a *Agent) handleSystemdStart(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := systemctlAvailable(); err != nil { + return nil, err + } + unit, err := parseUnitParam(params) + if err != nil { + return nil, err + } + if out, err := a.runSystemctlPrivileged(ctx, systemdMutateTimeout, "start", unit); err != nil { + if classifySudoError(out) { + return nil, fmt.Errorf("sudo refused: %w", errSudoRequired) + } + return nil, fmt.Errorf("systemctl start %s: %w (%s)", unit, err, out) + } + return map[string]interface{}{"unit": unit, "action": "started"}, nil +} + +func (a *Agent) handleSystemdStop(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := systemctlAvailable(); err != nil { + return nil, err + } + unit, err := parseUnitParam(params) + if err != nil { + return nil, err + } + if out, err := a.runSystemctlPrivileged(ctx, systemdMutateTimeout, "stop", unit); err != nil { + if classifySudoError(out) { + return nil, fmt.Errorf("sudo refused: %w", errSudoRequired) + } + return nil, fmt.Errorf("systemctl stop %s: %w (%s)", unit, err, out) + } + return map[string]interface{}{"unit": unit, "action": "stopped"}, nil +} + +func (a *Agent) handleSystemdRestart(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := systemctlAvailable(); err != nil { + return nil, err + } + unit, err := parseUnitParam(params) + if err != nil { + return nil, err + } + if out, err := a.runSystemctlPrivileged(ctx, systemdMutateTimeout, "restart", unit); err != nil { + if classifySudoError(out) { + return nil, fmt.Errorf("sudo refused: %w", errSudoRequired) + } + return nil, fmt.Errorf("systemctl restart %s: %w (%s)", unit, err, out) + } + return map[string]interface{}{"unit": unit, "action": "restarted"}, nil +} + +func (a *Agent) handleSystemdEnable(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := systemctlAvailable(); err != nil { + return nil, err + } + unit, err := parseUnitParam(params) + if err != nil { + return nil, err + } + if out, err := a.runSystemctlPrivileged(ctx, systemdMutateTimeout, "enable", unit); err != nil { + if classifySudoError(out) { + return nil, fmt.Errorf("sudo refused: %w", errSudoRequired) + } + return nil, fmt.Errorf("systemctl enable %s: %w (%s)", unit, err, out) + } + return map[string]interface{}{"unit": unit, "action": "enabled"}, nil +} + +func (a *Agent) handleSystemdDisable(ctx context.Context, params json.RawMessage) (interface{}, error) { + if err := systemctlAvailable(); err != nil { + return nil, err + } + unit, err := parseUnitParam(params) + if err != nil { + return nil, err + } + if out, err := a.runSystemctlPrivileged(ctx, systemdMutateTimeout, "disable", unit); err != nil { + if classifySudoError(out) { + return nil, fmt.Errorf("sudo refused: %w", errSudoRequired) + } + return nil, fmt.Errorf("systemctl disable %s: %w (%s)", unit, err, out) + } + return map[string]interface{}{"unit": unit, "action": "disabled"}, nil +} + +// ───── system:exec ───────────────────────────────────────────────── + +// commandBlocked reports whether cmd matches one of the operator's +// configured BlockedCommands entries. Comparison is exact-match against +// either the full path or the basename, after both sides are +// normalised — operators tend to write either "/usr/bin/rm" or just +// "rm" and we want to honour both. Empty entries are ignored so a +// stray newline in YAML doesn't deny everything. +func commandBlocked(blocked []string, cmd string) bool { + if len(blocked) == 0 { + return false + } + cmdAbs := strings.TrimSpace(cmd) + cmdBase := filepath.Base(cmdAbs) + for _, raw := range blocked { + entry := strings.TrimSpace(raw) + if entry == "" { + continue + } + if entry == cmdAbs || entry == cmdBase { + return true + } + if filepath.Base(entry) == cmdBase { + return true + } + } + return false +} + +// resolveMaxExecTimeout honours the operator's Security.MaxExecTimeout +// when set (any positive duration), otherwise falls back to the +// hardcoded ceiling. Negative or zero values are treated as "unset" +// rather than "no timeout"; the agent never runs an unbounded +// subprocess. +func (a *Agent) resolveMaxExecTimeout() time.Duration { + if a.cfg != nil && a.cfg.Security.MaxExecTimeout > 0 { + return a.cfg.Security.MaxExecTimeout + } + return defaultMaxExecTimeout +} + +// handleSystemExec runs an arbitrary command, captures stdout/stderr, +// and returns the exit code. The first token must be an absolute path +// (same rule as cron commands) so a misconfigured caller can't depend +// on $PATH state of the agent process. Output is truncated to keep the +// command envelope reasonable; the runner can poll a file if it needs +// the full output of a chatty install. +// +// This handler is only registered when cfg.Features.Exec is true (see +// agent.go:registerHandlers). When Exec is disabled, the panel sees an +// "unknown action" error rather than the command silently running. +func (a *Agent) handleSystemExec(ctx context.Context, params json.RawMessage) (interface{}, error) { + if a.cfg == nil || !a.cfg.Features.Exec { + return nil, errors.New("system:exec is disabled by agent config (features.exec=false)") + } + + var p struct { + Command string `json:"command"` + Args []string `json:"args"` + WorkingDir string `json:"working_dir"` + TimeoutSeconds int `json:"timeout_seconds"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + cmd := strings.TrimSpace(p.Command) + if cmd == "" { + return nil, errors.New("command is required") + } + if !strings.HasPrefix(cmd, "/") { + return nil, errors.New("command must be an absolute path") + } + if strings.ContainsAny(cmd, ";&|`$<>\n\r") { + return nil, errors.New("command contains shell metacharacters; pass arguments via args[]") + } + if commandBlocked(a.cfg.Security.BlockedCommands, cmd) { + return nil, fmt.Errorf("command %q is blocked by agent security config", cmd) + } + + maxTimeout := a.resolveMaxExecTimeout() + timeout := time.Duration(p.TimeoutSeconds) * time.Second + if timeout <= 0 || timeout > maxTimeout { + timeout = maxTimeout + } + cctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + c := exec.CommandContext(cctx, cmd, p.Args...) + if p.WorkingDir != "" { + c.Dir = p.WorkingDir + } + out, err := c.CombinedOutput() + exitCode := 0 + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + exitCode = ee.ExitCode() + } else { + return nil, fmt.Errorf("exec %s: %w", cmd, err) + } + } + return map[string]interface{}{ + "command": cmd, + "args": p.Args, + "exit_code": exitCode, + "output": truncate(string(out), 1024*32), + }, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "\n…[truncated]" +} diff --git a/agent/internal/agentui/actions.go b/agent/internal/agentui/actions.go new file mode 100644 index 00000000..06c03e34 --- /dev/null +++ b/agent/internal/agentui/actions.go @@ -0,0 +1,194 @@ +package agentui + +import ( + "encoding/json" + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/serverkit/agent/internal/config" +) + +// localActions exposes a small set of console-process-level operations to +// the React app: things that need to happen even when the agent service is +// down, or that need to be performed by a Windows interactive session +// rather than by SYSTEM-context service code. +// +// All endpoints live under /local/ so they're trivially distinguishable +// from agent-service IPC calls (which target a different port entirely). +type localActions struct { + exePath string + configPath string +} + +func newLocalActions(configPath string) *localActions { + exe, _ := exeForSpawn() + return &localActions{exePath: exe, configPath: configPath} +} + +// register hooks the action handlers into the asset server's mux. +func (a *localActions) register(mux *http.ServeMux) { + mux.HandleFunc("/local/service/restart", a.handleServiceAction("restart")) + mux.HandleFunc("/local/service/start", a.handleServiceAction("start")) + mux.HandleFunc("/local/service/stop", a.handleServiceAction("stop")) + mux.HandleFunc("/local/open", a.handleOpen) + mux.HandleFunc("/local/wizard", a.handleWizard) + mux.HandleFunc("/local/diag", a.handleDiag) + mux.HandleFunc("/local/status", a.handleStatus) + mux.HandleFunc("/local/ipc-token", a.handleIPCToken) +} + +// handleIPCToken returns the agent's IPC bearer token to the in-process +// React UI so it can authenticate against the agent service's HTTP API. +// Both processes run on the same machine under the same user, so the +// console process can read the token file directly — this endpoint +// just hides the OS-specific path from the JS side. +// +// Returns 503 (not 401) when the token isn't ready yet so the UI can +// distinguish "agent service hasn't started writing the file" from +// "you got the wrong credential" — the former resolves itself when the +// service starts, the latter is a real config bug. +func (a *localActions) handleIPCToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + data, err := os.ReadFile(config.IPCTokenPath()) + if err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "ipc token not yet available; is the agent service running?", + }) + return + } + tok := strings.TrimSpace(string(data)) + if tok == "" { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "ipc token file empty", + }) + return + } + writeJSON(w, http.StatusOK, map[string]string{"token": tok}) +} + +// handleStatus reports whether the agent has been paired by reading +// config.yaml directly. The React PairGate uses this as a fallback when +// the agent service IPC is unreachable — which is exactly the case on a +// fresh install: no config means no service, so the service can't tell +// the UI it isn't registered. Without this endpoint the wizard is a +// dead end on first run. +func (a *localActions) handleStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + resp := map[string]interface{}{ + "registered": false, + "agent_id": "", + "server_url": "", + } + if cfg, err := config.Load(a.configPath); err == nil && cfg != nil { + if cfg.Agent.ID != "" { + resp["registered"] = true + resp["agent_id"] = cfg.Agent.ID + resp["server_url"] = cfg.Server.URL + } + } + writeJSON(w, http.StatusOK, resp) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// handleServiceAction performs sc.exe stop/start/restart against the +// installed ServerKitAgent service. Restart is "stop, brief wait, start" +// because sc.exe has no native restart verb and the agent's IPC restart +// is just a graceful stop with no auto-spin-up. +func (a *localActions) handleServiceAction(action string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + switch action { + case "start": + if err := runServiceCmd("start"); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + case "stop": + if err := runServiceCmd("stop"); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + case "restart": + // Best-effort stop — ignore "service not running" errors and + // move on. Then start. This is what the tray's restart button + // should have been doing all along. + _ = runServiceCmd("stop") + time.Sleep(1500 * time.Millisecond) + if err := runServiceCmd("start"); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + } + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) + } +} + +// handleOpen launches a path in Explorer or a URL in the default browser. +// Body: {"path": "C:\\..."} OR {"url": "https://..."}. +func (a *localActions) handleOpen(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Path string `json:"path"` + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body"}) + return + } + target := body.URL + if target == "" { + target = body.Path + } + if target == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path or url required"}) + return + } + if err := openTarget(target); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// handleWizard spawns the pairing wizard as a detached child process. +// Used by the Re-pair button — gives the user a fresh form without +// interrupting the running console window. +func (a *localActions) handleWizard(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if a.exePath == "" { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "agent executable path unknown"}) + return + } + cmd := exec.Command(a.exePath, "setup") + if err := cmd.Start(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + // We don't Wait on the child — it lives on its own. + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/agent/internal/agentui/diag.go b/agent/internal/agentui/diag.go new file mode 100644 index 00000000..eb490e6f --- /dev/null +++ b/agent/internal/agentui/diag.go @@ -0,0 +1,214 @@ +package agentui + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// handleDiag builds a diagnostic bundle on the user's Desktop. Includes the +// current agent log, the activity events file, a redacted copy of +// config.yaml, and a small system info JSON. Returns the path so the React +// app can prompt "Open in Explorer" or just inform the user where it is. +// +// We write to Desktop on purpose: the most common reason to grab a bundle +// is to share it with someone, and Desktop is the universally-known spot. +// Falls back to the user's home dir if Desktop can't be located. +func (a *localActions) handleDiag(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + dest, err := buildDiagBundle() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"path": dest}) +} + +func buildDiagBundle() (string, error) { + dataDir := dataDirGuess() + dest := diagDestPath() + + out, err := os.Create(dest) + if err != nil { + return "", fmt.Errorf("create diag: %w", err) + } + defer out.Close() + + zw := zip.NewWriter(out) + defer zw.Close() + + // Current agent log (might not exist if never started — that's fine) + if dataDir != "" { + _ = addFileToZip(zw, filepath.Join(dataDir, "logs", "agent.log"), "agent.log") + // Most-recent rotated backup, if any (lumberjack names them with a + // timestamp suffix). Search and add up to 3 to keep the bundle slim. + if backups, err := findLogBackups(filepath.Join(dataDir, "logs")); err == nil { + for i, p := range backups { + if i >= 3 { + break + } + _ = addFileToZip(zw, p, "logs-backups/"+filepath.Base(p)) + } + } + // Events + _ = addFileToZip(zw, filepath.Join(dataDir, "events.json"), "events.json") + // Config (redacted) + if redacted, err := redactedConfigYAML(filepath.Join(dataDir, "config.yaml")); err == nil { + _ = addBytesToZip(zw, "config.redacted.yaml", redacted) + } + } + + // System info written last so it's the easiest to find when somebody + // opens the zip. + sysJSON, _ := json.MarshalIndent(systemInfo(), "", " ") + _ = addBytesToZip(zw, "sysinfo.json", sysJSON) + + return dest, nil +} + +func addFileToZip(zw *zip.Writer, src, archivedName string) error { + info, err := os.Stat(src) + if err != nil || info.IsDir() { + return err + } + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + w, err := zw.Create(archivedName) + if err != nil { + return err + } + _, err = io.Copy(w, f) + return err +} + +func addBytesToZip(zw *zip.Writer, name string, data []byte) error { + w, err := zw.Create(name) + if err != nil { + return err + } + _, err = w.Write(data) + return err +} + +// redactedConfigYAML reads config.yaml and returns it with any auth secrets +// stripped. We don't need the api_key / api_secret to debug connectivity, +// and shipping them through casual support channels is a bad habit. +func redactedConfigYAML(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var doc map[string]interface{} + if err := yaml.Unmarshal(data, &doc); err != nil { + // Couldn't parse — return the raw bytes with a header noting that + // redaction was skipped. Better than failing the bundle. + var buf bytes.Buffer + buf.WriteString("# diag: failed to parse for redaction; original returned as-is\n") + buf.Write(data) + return buf.Bytes(), nil + } + if auth, ok := doc["auth"].(map[string]interface{}); ok { + for _, k := range []string{"api_key", "api_secret", "apikey", "apisecret"} { + if _, has := auth[k]; has { + auth[k] = "[REDACTED]" + } + } + } + return yaml.Marshal(doc) +} + +func findLogBackups(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var out []string + for _, e := range entries { + name := e.Name() + if strings.HasPrefix(name, "agent-") && (strings.HasSuffix(name, ".log") || strings.HasSuffix(name, ".log.gz")) { + out = append(out, filepath.Join(dir, name)) + } + } + // Newest first + sortDescByModTime(out) + return out, nil +} + +func sortDescByModTime(paths []string) { + type entry struct { + path string + mod time.Time + } + infos := make([]entry, 0, len(paths)) + for _, p := range paths { + st, err := os.Stat(p) + if err != nil { + continue + } + infos = append(infos, entry{p, st.ModTime()}) + } + // Tiny list (<= a handful) — bubble sort is plenty. + for i := 0; i < len(infos); i++ { + for j := i + 1; j < len(infos); j++ { + if infos[j].mod.After(infos[i].mod) { + infos[i], infos[j] = infos[j], infos[i] + } + } + } + for i, e := range infos { + if i < len(paths) { + paths[i] = e.path + } + } +} + +func systemInfo() map[string]interface{} { + host, _ := os.Hostname() + return map[string]interface{}{ + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "hostname": host, + "go_version": runtime.Version(), + "generated": time.Now().Format(time.RFC3339), + } +} + +func dataDirGuess() string { + // Mirrors agent/config.DefaultConfigPath() without importing it (avoids + // a cycle with the rest of the agent package). Best-effort — if the env + // var isn't set we just skip the parts that need it. + if pd := os.Getenv("ProgramData"); pd != "" { + return filepath.Join(pd, "ServerKit", "Agent") + } + return "" +} + +func diagDestPath() string { + stamp := time.Now().Format("20060102-150405") + name := fmt.Sprintf("serverkit-diag-%s.zip", stamp) + if home, err := os.UserHomeDir(); err == nil { + desktop := filepath.Join(home, "Desktop") + if st, err := os.Stat(desktop); err == nil && st.IsDir() { + return filepath.Join(desktop, name) + } + return filepath.Join(home, name) + } + return name +} diff --git a/agent/internal/agentui/embed.go b/agent/internal/agentui/embed.go new file mode 100644 index 00000000..07e6324c --- /dev/null +++ b/agent/internal/agentui/embed.go @@ -0,0 +1,27 @@ +// Package agentui hosts the ServerKit agent's desktop console: a WebView2 +// window driven by an embedded React SPA. It coexists with the legacy walk +// pairing wizard in setupui — the wizard handles first-run pairing today, +// and this package owns the post-pair "running app" experience that the +// tray previously delegated to a few menu items. Over the next milestones +// the wizard migrates here too so there's one window for everything. +package agentui + +import ( + "embed" + "io/fs" +) + +// uiDist is the built React app from agent/ui/dist. The Makefile / CI must +// run `npm run build` in that folder before `go build`, otherwise the +// embed below fails at compile time. We accept that hard-fail so a release +// can never accidentally ship a stale or empty UI bundle. +// +//go:embed all:dist +var uiDist embed.FS + +// distFS returns the embedded UI rooted at the dist/ directory so the HTTP +// server can serve "/" → "dist/index.html" without leaking the dist prefix +// into URLs. +func distFS() (fs.FS, error) { + return fs.Sub(uiDist, "dist") +} diff --git a/agent/internal/agentui/focus_windows.go b/agent/internal/agentui/focus_windows.go new file mode 100644 index 00000000..d229894f --- /dev/null +++ b/agent/internal/agentui/focus_windows.go @@ -0,0 +1,95 @@ +//go:build windows + +package agentui + +import ( + "syscall" + "unsafe" +) + +// 1.6.1 shipped without explicit show/focus, leaving the host window hidden +// when the wizard was spawned with HideWindow:true (the STARTUPINFO bit +// propagates to child windows on some Windows builds — explicit SW_SHOW in +// the webview library wasn't enough). This file forces visibility from our +// side so 1.6.2 doesn't depend on that race. + +const ( + swHide = 0 + swShow = 5 + swRestore = 9 + hwndTopmost = ^uintptr(0) // -1 + hwndNotopmost = ^uintptr(1) // -2 + swpNoMove = 0x0002 + swpNoSize = 0x0001 + swpShowWindow = 0x0040 +) + +// hideHostWindow stuffs the WebView2 host out of sight immediately after +// construction so the user never sees the white pre-paint or black +// post-CSS-but-pre-React frames. We re-show via forceForeground once the +// JS bundle reports ready (or a safety timeout fires). +func hideHostWindow(hwnd unsafe.Pointer) { + if hwnd == nil { + return + } + procShowWindow.Call(uintptr(hwnd), swHide) +} + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + procShowWindow = user32.NewProc("ShowWindow") + procSetForegroundWindow = user32.NewProc("SetForegroundWindow") + procBringWindowToTop = user32.NewProc("BringWindowToTop") + procSetWindowPos = user32.NewProc("SetWindowPos") + procIsIconic = user32.NewProc("IsIconic") + procAttachThreadInput = user32.NewProc("AttachThreadInput") + procGetForegroundWindow = user32.NewProc("GetForegroundWindow") + procGetWindowThreadProc = user32.NewProc("GetWindowThreadProcessId") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetCurrentThreadID = kernel32.NewProc("GetCurrentThreadId") +) + +// forceForeground does the dance Windows requires when a non-foreground +// process wants to grab focus: attach to the foreground thread's input +// queue, call SetForegroundWindow + BringWindowToTop, then detach. Without +// the attach step Windows silently demotes the request to a flashing +// taskbar button — exactly the symptom we saw in 1.6.1. +func forceForeground(hwnd unsafe.Pointer) { + if hwnd == nil { + return + } + h := uintptr(hwnd) + + // If minimized, restore first. + if iconic, _, _ := procIsIconic.Call(h); iconic != 0 { + procShowWindow.Call(h, swRestore) + } else { + procShowWindow.Call(h, swShow) + } + + // Topmost-then-not flash forces the window onto Z-top reliably; some + // users reported the window living below the taskbar otherwise. + procSetWindowPos.Call(h, hwndTopmost, 0, 0, 0, 0, swpNoMove|swpNoSize|swpShowWindow) + procSetWindowPos.Call(h, hwndNotopmost, 0, 0, 0, 0, swpNoMove|swpNoSize|swpShowWindow) + + fg, _, _ := procGetForegroundWindow.Call() + if fg == 0 { + procSetForegroundWindow.Call(h) + procBringWindowToTop.Call(h) + return + } + + curThread, _, _ := procGetCurrentThreadID.Call() + var fgPid uint32 + fgThread, _, _ := procGetWindowThreadProc.Call(fg, uintptr(unsafe.Pointer(&fgPid))) + if fgThread == 0 || fgThread == curThread { + procSetForegroundWindow.Call(h) + procBringWindowToTop.Call(h) + return + } + + procAttachThreadInput.Call(curThread, fgThread, 1) + procSetForegroundWindow.Call(h) + procBringWindowToTop.Call(h) + procAttachThreadInput.Call(curThread, fgThread, 0) +} diff --git a/agent/internal/agentui/pairer.go b/agent/internal/agentui/pairer.go new file mode 100644 index 00000000..c50f18dd --- /dev/null +++ b/agent/internal/agentui/pairer.go @@ -0,0 +1,365 @@ +package agentui + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/serverkit/agent/internal/agent" + "github.com/serverkit/agent/internal/config" + "github.com/serverkit/agent/internal/connstring" + "github.com/serverkit/agent/internal/logger" + "github.com/serverkit/agent/internal/pairdriver" +) + +// pairer adapts the callback-style pairdriver into a polled state machine +// the React wizard can drive over HTTP. The wizard POSTs /local/pair/start +// once with panel URL + server name; from then on it polls +// /local/pair/state every second to render the current stage and react +// to claim / error transitions. +type pairer struct { + log *logger.Logger + configPath string + + mu sync.Mutex + state string // "idle" | "enrolling" | "waiting" | "claimed" | "error" + code string + codeFmt string + pass string + panelURL string + server string + errMsg string + cancel context.CancelFunc +} + +func newPairer(log *logger.Logger, configPath string) *pairer { + return &pairer{ + log: log.WithComponent("agentui-pair"), + configPath: configPath, + state: "idle", + } +} + +// register adds the wizard endpoints to the asset server's mux. +func (p *pairer) register(mux *http.ServeMux) { + mux.HandleFunc("/local/pair/start", p.handleStart) + mux.HandleFunc("/local/pair/state", p.handleState) + mux.HandleFunc("/local/pair/cancel", p.handleCancel) + mux.HandleFunc("/local/pair/connection-string", p.handleConnectionString) +} + +type pairStartRequest struct { + PanelURL string `json:"panel_url"` + ServerName string `json:"server_name"` +} + +type pairStateResponse struct { + State string `json:"state"` + Code string `json:"code,omitempty"` + CodeFormatted string `json:"code_formatted,omitempty"` + Passphrase string `json:"passphrase,omitempty"` + PanelURL string `json:"panel_url,omitempty"` + ServerName string `json:"server_name,omitempty"` + Error string `json:"error,omitempty"` +} + +func (p *pairer) handleStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var req pairStartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body"}) + return + } + if req.PanelURL == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "panel_url is required"}) + return + } + + pass, err := pairdriver.GeneratePassphrase() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + p.mu.Lock() + if p.cancel != nil { + // Replace any in-flight pairing — typical when the user edits the + // form and re-submits. + p.cancel() + } + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + p.state = "enrolling" + p.errMsg = "" + p.code = "" + p.codeFmt = "" + p.pass = pass + p.panelURL = req.PanelURL + p.server = req.ServerName + p.mu.Unlock() + + cb := pairdriver.Callbacks{ + OnEnrolled: func(code, formatted string) { + p.mu.Lock() + p.state = "waiting" + p.code = code + p.codeFmt = formatted + p.mu.Unlock() + }, + OnClaimed: func(serverName string) { + p.mu.Lock() + p.state = "claimed" + if serverName != "" { + p.server = serverName + } + p.mu.Unlock() + // After credentials land on disk the running service still has + // the old config in memory — restart it so the new URL/agent_id + // take effect immediately, and verify it actually came up. + // Earlier versions ignored sc start errors silently which is + // why users saw "successfully paired" on a dead service and + // had to start it manually from the CLI. + if err := runServiceCmd("stop"); err != nil { + p.log.Info("Service stop reported error (likely already stopped)", "error", err) + } + // sc.exe returns once SCM accepts the stop request, not when the + // service is fully stopped. Issuing sc start while the service is + // still STOP_PENDING fails with 1056 ("instance already running"), + // so wait until SCM reports STOPPED before starting. + if err := waitForServiceStopped(15 * time.Second); err != nil { + p.log.Info("Service did not reach STOPPED before start", "error", err) + } + if err := runServiceCmd("start"); err != nil { + p.log.Error("Failed to start service after pairing", "error", err) + p.mu.Lock() + p.state = "error" + p.errMsg = "Pairing succeeded but the agent service failed to start: " + err.Error() + + "\n\nTry: restart the machine, or open Actions → Restart agent." + p.mu.Unlock() + return + } + // sc start returns as soon as the SCM accepts the request, not + // when the service is actually running. Poll for state=RUNNING + // so the wizard's "claimed" stage reflects reality. + if err := waitForServiceRunning(20 * time.Second); err != nil { + p.log.Error("Service did not reach RUNNING after pairing", "error", err) + p.mu.Lock() + p.state = "error" + p.errMsg = "Pairing succeeded but the agent service didn't come up within 20s. " + + "This usually means another agent process is holding port 19780 — " + + "try ending all serverkit-agent.exe processes from Task Manager and reopen the wizard.\n\n" + + "Detail: " + err.Error() + p.mu.Unlock() + return + } + p.log.Info("Agent service running after pair", "server", serverName) + }, + OnError: func(err error) { + p.mu.Lock() + // Cancellation isn't a "failure" the UI should surface. + if !errors.Is(err, context.Canceled) { + p.state = "error" + p.errMsg = err.Error() + } + p.mu.Unlock() + }, + } + + go pairdriver.Run(ctx, p.log, p.configPath, req.PanelURL, pass, req.ServerName, cb) + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (p *pairer) handleState(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + p.mu.Lock() + resp := pairStateResponse{ + State: p.state, + Code: p.code, + CodeFormatted: p.codeFmt, + Passphrase: p.pass, + PanelURL: p.panelURL, + ServerName: p.server, + Error: p.errMsg, + } + p.mu.Unlock() + writeJSON(w, http.StatusOK, resp) +} + +func (p *pairer) handleCancel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + p.mu.Lock() + if p.cancel != nil { + p.cancel() + } + p.state = "idle" + p.code = "" + p.codeFmt = "" + p.pass = "" + p.errMsg = "" + p.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +type connStringRequest struct { + ConnectionString string `json:"connection_string"` +} + +// handleConnectionString accepts a single sk1://… connection string, +// decodes it into a panel URL + registration token, and runs the legacy +// /api/v1/servers/register flow against the panel — the same one the +// `serverkit-agent register` CLI uses. We reuse the existing pairer +// state machine ("enrolling" -> "claimed") so the React wizard's polling +// loop doesn't need a separate state for this entry path. +// +// Note: this bypasses the pair-code/passphrase flow entirely. The token +// inside the connection string IS the credential — the panel mints the +// agent's permanent api_key/api_secret server-side once we POST to +// /register, which is exactly what the operator wanted: "one string from +// the panel, one paste in the agent." +func (p *pairer) handleConnectionString(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var req connStringRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body"}) + return + } + decoded, err := connstring.Decode(req.ConnectionString) + if err != nil { + // Surface decoder errors directly: "unknown version" / "missing + // url or token" are actionable for the user, where as a generic + // "invalid input" wouldn't tell them whether to regenerate or + // upgrade. + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + panelURL := pairdriver.NormalizePanelURL(decoded.URL) + + // Mark the pairer state machine "in-flight" so the wizard's poll + // loop renders a spinner instead of bouncing back to the form. + p.mu.Lock() + if p.cancel != nil { + p.cancel() + } + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + p.state = "enrolling" + p.errMsg = "" + p.code = "" + p.codeFmt = "" + p.pass = "" + p.panelURL = panelURL + p.server = "" + p.mu.Unlock() + + go p.runConnectionStringFlow(ctx, panelURL, decoded.Token) + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// runConnectionStringFlow performs the actual register + persist + service +// restart sequence. Mirrors the OnClaimed path of the pair-code flow so +// the user-visible end state ("agent running, panel reconnected") is +// identical regardless of which entry path they took. +func (p *pairer) runConnectionStringFlow(ctx context.Context, panelURL, token string) { + reg := agent.NewRegistration(p.log) + + result, err := reg.Register(panelURL, token, "") + if err != nil { + p.failConnString("register: " + err.Error()) + return + } + if ctx.Err() != nil { + // User clicked Cancel between the network call and the save. + return + } + + if err := saveRegistrationCredentials(p.configPath, panelURL, result); err != nil { + p.failConnString("save credentials: " + err.Error()) + return + } + + p.mu.Lock() + p.state = "claimed" + p.server = result.Name + p.mu.Unlock() + + // Same restart-and-verify dance as the pair-code claim path. Earlier + // versions skipped this and left users staring at "successfully + // paired" on a service that never actually picked up the new config. + if err := runServiceCmd("stop"); err != nil { + p.log.Info("Service stop reported error (likely already stopped)", "error", err) + } + if err := runServiceCmd("start"); err != nil { + p.log.Error("Failed to start service after pairing", "error", err) + p.failConnString("Pairing succeeded but the agent service failed to start: " + err.Error() + + "\n\nTry: restart the machine, or open Actions → Restart agent.") + return + } + if err := waitForServiceRunning(20 * time.Second); err != nil { + p.log.Error("Service did not reach RUNNING after pairing", "error", err) + p.failConnString("Pairing succeeded but the agent service didn't come up within 20s. " + + "This usually means another agent process is holding port 19780 — " + + "try ending all serverkit-agent.exe processes from Task Manager and reopen the wizard.\n\n" + + "Detail: " + err.Error()) + return + } + p.log.Info("Agent service running after connection-string pair", "server", result.Name) +} + +func (p *pairer) failConnString(msg string) { + p.mu.Lock() + p.state = "error" + p.errMsg = msg + p.mu.Unlock() +} + +// saveRegistrationCredentials persists the result of a /register call to +// disk in the same shape as pairdriver.saveCredentials does for the +// claim flow. Lives here rather than in registration.go because the CLI +// `register` command does its own (slightly older-shaped) save and we +// don't want to disturb that path. +func saveRegistrationCredentials(configPath, panelURL string, r *agent.RegistrationResult) error { + cfg, err := config.Load(configPath) + if err != nil { + cfg = config.Default() + } + wsURL := strings.TrimSuffix(panelURL, "/") + wsURL = strings.Replace(wsURL, "https://", "wss://", 1) + wsURL = strings.Replace(wsURL, "http://", "ws://", 1) + cfg.Server.URL = wsURL + "/agent" + cfg.Agent.ID = r.AgentID + cfg.Agent.Name = r.Name + cfg.Auth.APIKey = r.APIKey + cfg.Auth.APISecret = r.APISecret + + if configPath == "" { + configPath = config.DefaultConfigPath() + } + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { + return err + } + if err := cfg.Save(configPath); err != nil { + return err + } + return cfg.SaveCredentials() +} diff --git a/agent/internal/agentui/platform_other.go b/agent/internal/agentui/platform_other.go new file mode 100644 index 00000000..8a26086b --- /dev/null +++ b/agent/internal/agentui/platform_other.go @@ -0,0 +1,27 @@ +//go:build !windows + +package agentui + +import ( + "fmt" + "time" +) + +// runServiceCmd is a stub on non-Windows: the console window is Windows-only +// for now, and the platform-specific service controls land alongside the +// cross-platform webview wrapper later. +func runServiceCmd(verb string) error { + return fmt.Errorf("service control not implemented on this platform") +} + +func waitForServiceRunning(timeout time.Duration) error { + return fmt.Errorf("service control not implemented on this platform") +} + +func openTarget(target string) error { + return fmt.Errorf("open not implemented on this platform") +} + +func exeForSpawn() (string, error) { + return "", fmt.Errorf("not implemented on this platform") +} diff --git a/agent/internal/agentui/platform_windows.go b/agent/internal/agentui/platform_windows.go new file mode 100644 index 00000000..fa7c1862 --- /dev/null +++ b/agent/internal/agentui/platform_windows.go @@ -0,0 +1,87 @@ +//go:build windows + +package agentui + +import ( + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// runServiceCmd issues sc.exe verb against ServerKitAgent. The MSI grants +// BUILTIN\Users start/stop/configure rights so this works as the regular +// user with no UAC prompt. +func runServiceCmd(verb string) error { + cmd := exec.Command("sc.exe", verb, "ServerKitAgent") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("sc %s: %w (output: %s)", verb, err, string(out)) + } + return nil +} + +// waitForServiceRunning polls `sc query` until the service reports state +// 4 (RUNNING) or the deadline passes. Returns nil when running, or a +// descriptive error including the most recent state output. Used to +// verify the post-pair sc start actually took — otherwise the wizard +// silently shows "claimed" while the service quietly failed to start. +func waitForServiceRunning(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + var lastOut string + for time.Now().Before(deadline) { + cmd := exec.Command("sc.exe", "query", "ServerKitAgent") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + out, err := cmd.CombinedOutput() + lastOut = string(out) + if err == nil && strings.Contains(lastOut, "RUNNING") { + return nil + } + // STATE = 2 (START_PENDING) and 3 (STOP_PENDING) are transients — + // just wait. STATE = 1 (STOPPED) means the service either died or + // never came up; we'll catch it after the deadline. + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("service did not reach RUNNING within %s (last sc query: %s)", + timeout, strings.TrimSpace(lastOut)) +} + +// waitForServiceStopped polls `sc query` until the service reports state +// 1 (STOPPED) or the deadline passes. `sc stop` returns as soon as SCM +// accepts the stop request — issuing `sc start` while the service is +// still STOP_PENDING returns error 1056 ("instance already running"), +// which is exactly what users hit on the post-pair restart. +func waitForServiceStopped(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + var lastOut string + for time.Now().Before(deadline) { + cmd := exec.Command("sc.exe", "query", "ServerKitAgent") + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + out, err := cmd.CombinedOutput() + lastOut = string(out) + if err == nil && strings.Contains(lastOut, "STOPPED") { + return nil + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("service did not reach STOPPED within %s (last sc query: %s)", + timeout, strings.TrimSpace(lastOut)) +} + +// openTarget hands a path or URL to Explorer / the default browser via +// rundll32 + url.dll, which is the canonical "open this thing" entry point +// on Windows. Faster than shelling out to cmd /c start. +func openTarget(target string) error { + cmd := exec.Command("rundll32", "url.dll,FileProtocolHandler", target) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + return cmd.Start() +} + +// exeForSpawn returns the path to the currently running agent binary so the +// wizard re-launch can spawn another instance of the same exe. +func exeForSpawn() (string, error) { + return os.Executable() +} diff --git a/agent/internal/agentui/preflight_other.go b/agent/internal/agentui/preflight_other.go new file mode 100644 index 00000000..3ea019dc --- /dev/null +++ b/agent/internal/agentui/preflight_other.go @@ -0,0 +1,6 @@ +//go:build !windows + +package agentui + +func detectWebView2() string { return "" } +func preflightWebView2() error { return nil } diff --git a/agent/internal/agentui/preflight_windows.go b/agent/internal/agentui/preflight_windows.go new file mode 100644 index 00000000..b0f66dc7 --- /dev/null +++ b/agent/internal/agentui/preflight_windows.go @@ -0,0 +1,59 @@ +//go:build windows + +package agentui + +import ( + "fmt" + + "golang.org/x/sys/windows/registry" +) + +// WebView2 evergreen runtime registers itself under this UUID. We probe both +// the 32-bit and 64-bit hives plus per-user, mirroring Microsoft's own +// detection guidance. If pv is non-empty we treat the runtime as installed. +// +// https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#detect-if-a-webview2-runtime-is-already-installed +const webview2GUID = `{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}` + +// detectWebView2 returns the installed runtime version, or empty string if +// not installed. Errors at the registry layer are folded into "not present" +// — the calling code only needs a yes/no with a version when found. +func detectWebView2() string { + candidates := []struct { + root registry.Key + path string + }{ + {registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\` + webview2GUID}, + {registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\EdgeUpdate\Clients\` + webview2GUID}, + {registry.CURRENT_USER, `Software\Microsoft\EdgeUpdate\Clients\` + webview2GUID}, + } + for _, c := range candidates { + k, err := registry.OpenKey(c.root, c.path, registry.QUERY_VALUE) + if err != nil { + continue + } + v, _, err := k.GetStringValue("pv") + k.Close() + if err == nil && v != "" { + return v + } + } + return "" +} + +// preflightWebView2 returns nil when the runtime is present, or an error +// pointing the user at the download URL. We surface this *before* trying +// to construct the webview, because go-webview2's NewWithOptions can +// silently produce a non-nil but non-functional handle in certain +// failure modes — leaving the user with a blank window and no error. +func preflightWebView2() error { + v := detectWebView2() + if v == "" { + return fmt.Errorf( + "WebView2 runtime not installed. " + + "Install the Evergreen Bootstrapper from " + + "https://developer.microsoft.com/microsoft-edge/webview2/ and try again", + ) + } + return nil +} diff --git a/agent/internal/agentui/server.go b/agent/internal/agentui/server.go new file mode 100644 index 00000000..560d4d07 --- /dev/null +++ b/agent/internal/agentui/server.go @@ -0,0 +1,72 @@ +package agentui + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "github.com/serverkit/agent/internal/logger" +) + +// startAssetServer binds an HTTP server to a free 127.0.0.1 port and serves +// the embedded UI bundle from it. WebView2 then navigates to the resulting +// URL — using a real HTTP origin (rather than file:// or a virtual host +// mapping) means relative imports, fetch(), and the dev tools "Network" +// panel all behave the same as when running the dev server, which keeps +// surprises out of the migration. Returns the URL to navigate to and a +// shutdown func that stops the server cleanly. +func startAssetServer(ctx context.Context, log *logger.Logger, configPath string) (url string, shutdown func(), err error) { + dist, err := distFS() + if err != nil { + return "", nil, fmt.Errorf("load embedded ui: %w", err) + } + + // Bind to :0 so the OS hands us an unused port; we don't conflict with + // the agent's IPC server (19780) or any panel running locally. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", nil, fmt.Errorf("bind asset server: %w", err) + } + + mux := http.NewServeMux() + newLocalActions(configPath).register(mux) + newPairer(log, configPath).register(mux) + + // Wrap the static file server with no-store headers. WebView2 caches + // per-user-data-folder aggressively; on an MSI upgrade the new bundle + // would otherwise be shadowed by the previous index.html for several + // runs. Asset payload is ~600KB total, so loading it fresh each launch + // has no perceptible cost and removes a whole class of stale-asset + // bug reports. + noCache := func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + h.ServeHTTP(w, r) + }) + } + mux.Handle("/", noCache(http.FileServer(http.FS(dist)))) + + srv := &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + _ = srv.Serve(ln) + }() + + addr := ln.Addr().(*net.TCPAddr) + url = fmt.Sprintf("http://127.0.0.1:%d/", addr.Port) + + shutdown = func() { + ctxShut, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(ctxShut) + } + + return url, shutdown, nil +} diff --git a/agent/internal/agentui/window_other.go b/agent/internal/agentui/window_other.go new file mode 100644 index 00000000..f8e4a72d --- /dev/null +++ b/agent/internal/agentui/window_other.go @@ -0,0 +1,17 @@ +//go:build !windows + +package agentui + +import ( + "context" + "fmt" + + "github.com/serverkit/agent/internal/logger" +) + +// Run is a stub on non-Windows platforms; the WebView2-based console is +// Windows-only for now. macOS / Linux desktop console support arrives once +// the wizard migration is done and we pick a cross-platform webview wrapper. +func Run(_ context.Context, _ *logger.Logger, _ string) error { + return fmt.Errorf("the agent console window is only available on Windows") +} diff --git a/agent/internal/agentui/window_windows.go b/agent/internal/agentui/window_windows.go new file mode 100644 index 00000000..7e657741 --- /dev/null +++ b/agent/internal/agentui/window_windows.go @@ -0,0 +1,216 @@ +//go:build windows + +package agentui + +import ( + "context" + "fmt" + "os" + "runtime" + "time" + + webview2 "github.com/jchv/go-webview2" + + "github.com/serverkit/agent/internal/logger" +) + +// Run launches the agent's WebView2 console window and blocks until the user +// closes it. configPath is reserved for the upcoming wizard-migration +// milestone — for now the React app loads in console-only mode and the +// existing setupui wizard still owns first-run pairing. +// +// Set SERVERKIT_AGENT_UI_DEV=http://localhost:5174 to point the webview at +// the Vite dev server instead of the embedded bundle. Useful while iterating +// on the UI: changes hot-reload without rebuilding the Go binary. +// +// Set SERVERKIT_AGENT_UI_DEVTOOLS=false to disable DevTools. By default the +// 1.6.2 console ships with DevTools enabled so blank-page reports come with +// a way for the user to press F12 and capture errors. +func Run(ctx context.Context, log *logger.Logger, configPath string) error { + runtime.LockOSThread() + + log.Info("agentui.Run: starting") + + // Preflight: bail with a clear error before constructing the webview. + // Skipping this leads to "blank window" reports when the runtime is + // missing — go-webview2 doesn't always return nil on failure. + if v := detectWebView2(); v == "" { + log.Error("agentui.Run: WebView2 runtime not detected") + return fmt.Errorf( + "WebView2 runtime not installed. " + + "Download the Evergreen Bootstrapper from " + + "https://developer.microsoft.com/microsoft-edge/webview2/ and re-run setup", + ) + } else { + log.Info("agentui.Run: WebView2 runtime detected", "version", v) + } + + target := os.Getenv("SERVERKIT_AGENT_UI_DEV") + var stopServer func() + if target == "" { + log.Info("agentui.Run: starting embedded asset server") + url, shutdown, err := startAssetServer(ctx, log, configPath) + if err != nil { + log.Error("agentui.Run: asset server failed", "error", err) + return fmt.Errorf("start asset server: %w", err) + } + log.Info("agentui.Run: asset server up", "url", url) + target = url + stopServer = shutdown + } else { + // Even in dev mode we still need the action/pair endpoints — the + // Vite dev server only serves UI assets. Start the asset server + // alongside it; dev UI fetches the local API by absolute origin. + _, shutdown, err := startAssetServer(ctx, log, configPath) + if err != nil { + return fmt.Errorf("start asset server: %w", err) + } + stopServer = shutdown + log.Info("agentui.Run: using dev server", "url", target) + } + if stopServer != nil { + defer stopServer() + } + + // 1.6.1 reports said: blank white window, no errors. We could not see + // what the React app was doing because the only diagnostic was the Go + // log on the host side. 1.6.2 ships with DevTools enabled by default so + // users can press F12 and screenshot errors. Set the env var to "false" + // to turn it off in installs where security policy prohibits it. + devtools := os.Getenv("SERVERKIT_AGENT_UI_DEVTOOLS") != "false" + log.Info("agentui.Run: constructing WebView2 window", "devtools", devtools) + w := webview2.NewWithOptions(webview2.WebViewOptions{ + Debug: devtools, + AutoFocus: true, + WindowOptions: webview2.WindowOptions{ + Title: "ServerKit Agent", + // Sidebar is 220px; content area at 1200x800 lands at 980x800 + // which gives the Overview metric cards and the Logs/Activity + // timeline room to breathe without horizontal scrollbars. + Width: 1200, + Height: 800, + IconId: 2, + Center: true, + }, + }) + if w == nil { + log.Error("agentui.Run: webview2.NewWithOptions returned nil") + return fmt.Errorf("webview2 init failed; the runtime may be present but blocked by group policy or AV") + } + defer w.Destroy() + + // Bridge JS console + window errors back to the Go log so 1.6.2 doesn't + // leave us blind on blank-page reports. The page calls these via + // window.agentLog / window.agentNavReady, which Init() wires up below. + if err := w.Bind("agentLog", func(level, msg string) { + switch level { + case "error": + log.Error("ui.js: " + msg) + case "warn": + log.Warn("ui.js: " + msg) + default: + log.Info("ui.js: " + msg) + } + return + }); err != nil { + log.Warn("agentui.Run: Bind agentLog failed", "error", err) + } + navReady := make(chan struct{}, 1) + if err := w.Bind("agentNavReady", func(href string) { + log.Info("ui.js: navigation ready", "href", href) + select { + case navReady <- struct{}{}: + default: + } + return + }); err != nil { + log.Warn("agentui.Run: Bind agentNavReady failed", "error", err) + } + + // Inject a tiny shim *before* the React bundle runs. It forwards every + // console.error / unhandledrejection / window.onerror through agentLog, + // and pings agentNavReady once the document has parsed. This is what + // fills the gap that NavigationCompleted would have filled if the + // upstream library exposed it on the public WebView interface. + w.Init(` + (function() { + var safe = function(fn) { try { fn(); } catch (e) {} }; + var send = function(level, msg) { + if (window.agentLog) safe(function(){ window.agentLog(level, String(msg)); }); + }; + safe(function(){ + var origErr = console.error; + console.error = function() { + send('error', Array.prototype.slice.call(arguments).map(String).join(' ')); + return origErr.apply(console, arguments); + }; + }); + window.addEventListener('error', function(ev) { + send('error', (ev.message || 'window.onerror') + ' @ ' + (ev.filename||'?') + ':' + (ev.lineno||0)); + }); + window.addEventListener('unhandledrejection', function(ev) { + var r = ev && ev.reason; + send('error', 'unhandledrejection: ' + (r && r.message ? r.message : String(r))); + }); + var ready = function() { + if (window.agentNavReady) safe(function(){ window.agentNavReady(location.href); }); + }; + if (document.readyState === 'complete' || document.readyState === 'interactive') { + setTimeout(ready, 0); + } else { + document.addEventListener('DOMContentLoaded', ready); + } + send('info', 'agent shim installed, ua=' + navigator.userAgent); + })(); + `) + + w.SetSize(900, 600, webview2.HintMin) + + // Hide the host window immediately so the user never sees WebView2's + // pre-paint white rectangle or the dark-CSS-but-pre-React black gap. + // We re-show in forceForeground once navReady fires (or 3s elapses as + // a safety net for cases where the JS shim never installs). + hideHostWindow(w.Window()) + + log.Info("agentui.Run: navigating", "url", target) + w.Navigate(target) + + // Reveal the window the moment the bundle reports ready. The 3s + // fallback is deliberately generous: on cold-start machines, WebView2 + // can take ~1.5s to hydrate the first paint, and we'd rather show a + // real (if late) UI than show a flash. The 8s mark logs a louder + // warning so blank-page reports have a clear breadcrumb. + go func() { + select { + case <-navReady: + log.Info("agentui.Run: UI bundle reported ready, showing window") + forceForeground(w.Window()) + case <-time.After(3 * time.Second): + log.Warn("agentui.Run: no UI ready signal after 3s; revealing window anyway") + forceForeground(w.Window()) + select { + case <-navReady: + log.Info("agentui.Run: UI bundle reported ready (after fallback)") + case <-time.After(5 * time.Second): + log.Warn("agentui.Run: no UI ready signal after 8s; bundle likely failed (right-click → Inspect to debug)") + case <-ctx.Done(): + } + case <-ctx.Done(): + return + } + }() + + // Hook ctx cancellation to terminate the webview from a background + // goroutine. Run() blocks on the OS message pump, so we need an external + // signal to break it cleanly when the parent process is torn down. + go func() { + <-ctx.Done() + log.Info("agentui.Run: ctx cancelled, terminating webview") + w.Terminate() + }() + + log.Info("agentui.Run: entering message loop (w.Run)") + w.Run() + log.Info("agentui.Run: message loop exited cleanly") + return nil +} diff --git a/agent/internal/auth/auth.go b/agent/internal/auth/auth.go index c522f467..8e111b0c 100644 --- a/agent/internal/auth/auth.go +++ b/agent/internal/auth/auth.go @@ -79,13 +79,18 @@ func (a *Authenticator) VerifyTimestamp(timestamp int64, maxAgeSeconds int64) bo return diff <= maxAgeSeconds*1000 } -// GetAPIKeyPrefix returns the first 8 characters of the API key -// Used for identification without exposing full key +// GetAPIKeyPrefix returns the first 12 characters of the API key for +// server-side identification. Must match the panel's storage width +// (backend/app/models/server.py: api_key_prefix = api_key[:12]) — the +// panel does direct string equality, so any drift between the two +// sides 401s every auth attempt. Was 8 here historically, which is a +// dormant bug only exposed once the polling fallback transport made +// auth failures visible (WS would just retry silently forever). func (a *Authenticator) GetAPIKeyPrefix() string { - if len(a.apiKey) < 8 { + if len(a.apiKey) < 12 { return a.apiKey } - return a.apiKey[:8] + return a.apiKey[:12] } // AgentID returns the agent ID diff --git a/agent/internal/capabilities/capabilities.go b/agent/internal/capabilities/capabilities.go new file mode 100644 index 00000000..7d7e142c --- /dev/null +++ b/agent/internal/capabilities/capabilities.go @@ -0,0 +1,319 @@ +// Package capabilities probes the host for which feature surfaces the +// agent can manage (cron, docker, systemd, nginx, …). The result is a +// flat map[string]bool the agent ships to the panel on connect; the +// panel uses it to gate per-feature target pickers. +// +// Probes are cheap (PATH lookups + a couple of file/socket checks) and +// run once per agent process at startup. Results are cached for the +// process lifetime — re-probing is a future concern (e.g. SIGHUP, an +// explicit panel-issued "recapability" command). +package capabilities + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + + "github.com/serverkit/agent/internal/logger" + "github.com/serverkit/agent/pkg/protocol" + gopsutilhost "github.com/shirou/gopsutil/v3/host" +) + +// Probe runs all known capability probes and returns the result. log is +// optional — used to record what the probe found, mostly for ops +// debugging when an agent unexpectedly fails to expose a feature. +// +// dockerAvailable is passed in by the agent because it has already +// dialled the docker socket during startup; re-probing here would just +// duplicate that work. +// +// fileAccess + allowedPaths come from agent config (Features.FileAccess / +// Security.AllowedPaths). Surfacing them here lets the panel hide +// remote-file features on agents that have file access disabled, and +// show the user which roots they can browse instead of guessing. +func Probe(ctx context.Context, log *logger.Logger, dockerAvailable bool, fileAccess bool, allowedPaths []string) protocol.CapabilitiesMessage { + caps := protocol.Capabilities{} + + // Docker — the agent's own client is the source of truth. If the + // agent couldn't dial dockerd, nothing this layer probes will fix + // it. + caps["docker"] = dockerAvailable + + // File access — exposed via the Features.FileAccess config bit. + // The agent already gates file:* handler registration on this; we + // advertise it so the panel can hide the file manager target picker + // for agents where it's off (and Phase 3+ verbs that depend on it). + caps["files"] = fileAccess && len(allowedPaths) > 0 + + // Linux service surfaces. On non-Linux these stay false; the + // panel's UI is already conditional on them. + caps["cron"] = probeCron() + caps["systemd"] = probeSystemd() + caps["nginx"] = probeNginx() + caps["php_fpm"] = probePHPFPM() + caps["packages"] = probePackageManager() + + // Cloudflared — present if the binary is on PATH. The panel uses + // this to decide whether to show the Cloudflare Tunnels tab; it + // doesn't imply the user has authenticated yet (cert.pem check + // happens in the cloudflared:status action). + caps["cloudflared"] = probeCloudflared() + + // Language runtimes — best-effort version probes. A missing key + // means "not installed"; an empty string means "installed but + // `--version` parse failed" so the panel can still light up the + // runtime indicator without claiming a wrong version. + runtimes := probeRuntimes(ctx) + + // Platform / distro for any code that needs more than the booleans + // (e.g. "use apt vs dnf" — Phase 4 territory, but we capture it + // now so we don't have to add another roundtrip later). + platform := runtime.GOOS + distro := "" + distroVer := "" + if info, err := gopsutilhost.InfoWithContext(ctx); err == nil { + // gopsutil reports "ubuntu", "debian", "centos", etc. on Linux; + // "windows", "darwin" on those platforms — we don't need it + // there. + distro = info.Platform + distroVer = info.PlatformVersion + } + + if log != nil { + log.Info("Capability probe complete", + "platform", platform, + "distro", distro, + "docker", caps["docker"], + "cron", caps["cron"], + "systemd", caps["systemd"], + "nginx", caps["nginx"], + "php_fpm", caps["php_fpm"], + "packages", caps["packages"], + "cloudflared", caps["cloudflared"], + "runtimes", runtimes, + ) + } + + // Only forward allowed_paths when files capability is true — saves + // the panel from having to defensively check both fields. + var advertisedPaths []string + if caps["files"] { + advertisedPaths = append(advertisedPaths, allowedPaths...) + } + + return protocol.CapabilitiesMessage{ + Capabilities: caps, + Platform: platform, + Distro: distro, + DistroVersion: distroVer, + Runtimes: runtimes, + AllowedPaths: advertisedPaths, + } +} + +// probeCloudflared — true if cloudflared is on PATH. The capability +// flips to true at install time, well before the user has logged in +// (cloudflared tunnel login). The auth state is exposed separately by +// the cloudflared:status action so the UI can show "binary installed, +// not authenticated" without having to teach the panel about +// /etc/cloudflared/cert.pem locations. +func probeCloudflared() bool { + return hasOnPath("cloudflared") +} + +// probeRuntimes detects common language runtimes by shelling out to +// each " --version" and parsing the first line. Versions are kept +// as raw strings (e.g. "3.11.4", "20.10.0", "8.2.0") so consumers can +// do their own semver compares without us guessing the schema. +// +// Per-binary timeout is short — every probe is bounded so a hung +// subprocess can't block the agent from connecting. +func probeRuntimes(ctx context.Context) map[string]string { + results := map[string]string{} + probes := []struct { + key string + bins []string // try in order; first match wins + }{ + {key: "python", bins: []string{"python3", "python"}}, + {key: "node", bins: []string{"node", "nodejs"}}, + {key: "php", bins: []string{"php"}}, + {key: "go", bins: []string{"go"}}, + {key: "ruby", bins: []string{"ruby"}}, + {key: "java", bins: []string{"java"}}, + } + for _, p := range probes { + for _, bin := range p.bins { + if !hasOnPath(bin) { + continue + } + ver := readVersion(ctx, bin) + results[p.key] = ver + break // don't keep trying once we found one + } + } + return results +} + +// versionPattern grabs the first dotted-number sequence in a version +// string. Works across the messy outputs: +// - python: "Python 3.11.4" +// - node: "v20.10.0" +// - php: "PHP 8.2.0 (cli) ..." +// - go: "go version go1.22.0 linux/amd64" +// - ruby: "ruby 3.2.2p53 ..." +// - java: "openjdk version \"17.0.6\" 2023-01-17" +var versionPattern = regexp.MustCompile(`(\d+\.\d+(?:\.\d+)?)`) + +func readVersion(ctx context.Context, bin string) string { + // Each runtime has its own way to print version. Try a small + // allow-list of forms in order; the first non-empty wins. + // --version: python, node, php, ruby + // -version: java (legacy) + // version: go (subcommand, NOT a flag) + candidates := [][]string{{"--version"}, {"-version"}, {"version"}} + // Special-case go up front so we don't waste two failed forks on + // hosts where probing this matters. + base := strings.ToLower(filepathBase(bin)) + if base == "go" || base == "go.exe" { + candidates = [][]string{{"version"}, {"--version"}} + } + var out []byte + for _, args := range candidates { + cmd := exec.CommandContext(ctx, bin, args...) + o, err := cmd.CombinedOutput() + if err == nil && len(o) > 0 { + out = o + break + } + } + if len(out) == 0 { + return "" + } + first := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0] + if m := versionPattern.FindString(first); m != "" { + return m + } + // Fall through with the raw first line so the panel at least shows + // something rather than a blank. + return first +} + +// filepathBase mirrors filepath.Base without pulling the import — this +// file already has plenty. +func filepathBase(p string) string { + p = strings.ReplaceAll(p, "\\", "/") + if i := strings.LastIndex(p, "/"); i >= 0 { + p = p[i+1:] + } + return p +} + +// probeCron — present if `crontab` is on PATH OR a cron daemon is +// installed (we don't require it to be running; the panel can start it). +func probeCron() bool { + if runtime.GOOS != "linux" { + return false + } + if hasOnPath("crontab") { + return true + } + // On systemd hosts that only expose the timer subsystem, crontab + // may be missing but cron.d is still useful. Treat presence of + // /etc/cron.d as the fallback signal. + if _, err := os.Stat("/etc/cron.d"); err == nil { + return true + } + return false +} + +// probeSystemd — systemctl on PATH and the system bus is reachable. +// On a container without systemd, systemctl is sometimes installed as a +// shim that fails with "Failed to connect to bus", so we additionally +// check for /run/systemd/system which is the canonical "systemd is PID 1 +// here" marker. +func probeSystemd() bool { + if runtime.GOOS != "linux" { + return false + } + if !hasOnPath("systemctl") { + return false + } + if _, err := os.Stat("/run/systemd/system"); err == nil { + return true + } + return false +} + +func probeNginx() bool { + if runtime.GOOS != "linux" { + return false + } + return hasOnPath("nginx") +} + +func probePHPFPM() bool { + if runtime.GOOS != "linux" { + return false + } + // PHP-FPM is shipped per-version on Debian/Ubuntu (php-fpm, + // php8.1-fpm, php8.2-fpm, …). hasOnPath only catches the bare + // name, so glob the common install dirs as a fallback. + if hasOnPath("php-fpm") { + return true + } + for _, pat := range []string{ + "/usr/sbin/php*-fpm*", + "/usr/local/sbin/php*-fpm*", + } { + if matches, _ := filepath.Glob(pat); len(matches) > 0 { + return true + } + } + return false +} + +// probePackageManager — true if any of the recognized managers (apt, +// dnf, yum, apk, pacman, zypper) is on PATH. Phase 0 only needs +// "package management is possible from here"; identifying which manager +// goes in the system_info / capabilities Distro field above. +func probePackageManager() bool { + if runtime.GOOS != "linux" { + return false + } + for _, mgr := range []string{"apt-get", "apt", "dnf", "yum", "apk", "pacman", "zypper"} { + if hasOnPath(mgr) { + return true + } + } + return false +} + +// hasOnPath checks whether a binary is reachable via $PATH. Cached to +// avoid hammering the filesystem on agents that re-probe via SIGHUP. +func hasOnPath(name string) bool { + if v, ok := pathCache.Load(name); ok { + return v.(bool) + } + _, err := exec.LookPath(name) + ok := err == nil + pathCache.Store(name, ok) + return ok +} + +var pathCache sync.Map + +// ClearPathCache empties the binary-on-PATH cache. Called by agent: +// recapabilities so a binary that was installed since the last probe +// (e.g. the user just ran apt install nginx) is detected fresh +// instead of returning the stale "not found" result. +func ClearPathCache() { + pathCache.Range(func(k, _ interface{}) bool { + pathCache.Delete(k) + return true + }) +} diff --git a/agent/internal/cloudflared/cloudflared.go b/agent/internal/cloudflared/cloudflared.go new file mode 100644 index 00000000..c56345c3 --- /dev/null +++ b/agent/internal/cloudflared/cloudflared.go @@ -0,0 +1,106 @@ +// Package cloudflared manages Cloudflare named tunnels via the +// cloudflared CLI. Linux-only. +// +// Auth model (approach A): +// +// The user runs `cloudflared tunnel login` once per host. That writes +// ~/.cloudflared/cert.pem (or /etc/cloudflared/cert.pem when run as +// root). The cert is the long-lived "I'm allowed to manage tunnels +// for account X" credential. We never see, copy, or store the user's +// Cloudflare API token; the panel only ever shells out to +// `cloudflared` and trusts whatever auth the binary already has. +// +// Status() reports two flags: +// - Available: cloudflared is on PATH +// - Authenticated: cert.pem is present in one of the standard +// locations +// +// The panel uses both: the tab is gated on `capabilities.cloudflared` +// (binary installed, set by capabilities.Probe at agent startup) and +// the tab body shows an "authenticate first" affordance when +// Authenticated=false. +// +// Tunnel lifecycle: +// +// 1. Create: `cloudflared tunnel create ` +// (writes .json credentials file) +// 2. Route: `cloudflared tunnel route dns ` +// (creates a Cloudflare DNS record pointing the +// hostname at the tunnel — *the* useful action) +// 3. Run: out of scope here; the operator wires up +// `cloudflared tunnel run ` as a systemd service +// with a config.yml ingress mapping. Future iteration. +// 4. Delete: `cloudflared tunnel delete ` +package cloudflared + +import ( + "context" + "os/exec" +) + +// Status reports usability and auth state. +type Status struct { + Available bool `json:"available"` // binary installed + Authenticated bool `json:"authenticated"` // cert.pem present + CertPath string `json:"cert_path,omitempty"` // detected cert.pem path, if any + Reason string `json:"reason,omitempty"` // explanation when Available=false + LoginHint string `json:"login_hint,omitempty"` // canned "run this command" when Authenticated=false + Version string `json:"version,omitempty"` // cloudflared --version line, best-effort +} + +// Tunnel mirrors `cloudflared tunnel list --output json` rows. +type Tunnel struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at,omitempty"` + Connections []string `json:"connections,omitempty"` +} + +// CreateRequest creates a new named tunnel. +type CreateRequest struct { + Name string `json:"name"` +} + +// RouteRequest binds a hostname to an existing tunnel via Cloudflare +// DNS. TunnelRef can be the tunnel name or UUID — cloudflared accepts +// either. +type RouteRequest struct { + TunnelRef string `json:"tunnel_ref"` + Hostname string `json:"hostname"` +} + +// LoginEvent is emitted while a `cloudflared tunnel login` flow is in +// flight. AuthURL is the OAuth URL the user must open in a browser +// to authorise the agent host with their Cloudflare account; cert.pem +// is written by cloudflared once the OAuth round-trips. +type LoginEvent struct { + AuthURL string `json:"auth_url,omitempty"` + Line string `json:"line,omitempty"` + Done bool `json:"done,omitempty"` + CertPath string `json:"cert_path,omitempty"` + Error string `json:"error,omitempty"` +} + +// Manager is the platform-agnostic interface; the Linux build wires +// it up with real exec calls, non-Linux builds return "unsupported." +type Manager interface { + Status(ctx context.Context) (*Status, error) + List(ctx context.Context) ([]Tunnel, error) + Create(ctx context.Context, req CreateRequest) (*Tunnel, error) + Route(ctx context.Context, req RouteRequest) error + Delete(ctx context.Context, ref string) error + // Login starts `cloudflared tunnel login` and streams events on + // the returned channel. The first event carries the auth URL; the + // channel closes when cert.pem is written (Done=true) or the + // underlying process errors out. + Login(ctx context.Context) (<-chan LoginEvent, error) +} + +// hasCloudflared is shared between the Linux backend and tests. +func hasCloudflared() (string, bool) { + p, err := exec.LookPath("cloudflared") + if err != nil { + return "", false + } + return p, true +} diff --git a/agent/internal/cloudflared/cloudflared_linux.go b/agent/internal/cloudflared/cloudflared_linux.go new file mode 100644 index 00000000..844f2594 --- /dev/null +++ b/agent/internal/cloudflared/cloudflared_linux.go @@ -0,0 +1,302 @@ +//go:build linux + +package cloudflared + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +// New returns a Linux Manager that drives `cloudflared`. +func New() Manager { return &linuxManager{} } + +type linuxManager struct{} + +// validNameRegex constrains tunnel names to characters that won't +// trip cloudflared or shell escaping. Matches the cloudflared docs: +// up to 32 chars, alphanumeric + dash + underscore. +var validNameRegex = regexp.MustCompile(`^[A-Za-z0-9_\-]{1,32}$`) + +// validHostnameRegex is a permissive FQDN check — no protocol, no +// path, no spaces. Cloudflare itself does the authoritative check. +var validHostnameRegex = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)+$`) + +// certSearchPaths covers the two install patterns: the user ran +// `cloudflared tunnel login` as a regular user (~/.cloudflared) or as +// root / via the official install (/etc/cloudflared). +func certSearchPaths() []string { + paths := []string{"/etc/cloudflared/cert.pem"} + if home, err := os.UserHomeDir(); err == nil { + paths = append(paths, filepath.Join(home, ".cloudflared", "cert.pem")) + } + return paths +} + +func findCert() (string, bool) { + for _, p := range certSearchPaths() { + if _, err := os.Stat(p); err == nil { + return p, true + } + } + return "", false +} + +func (l *linuxManager) Status(ctx context.Context) (*Status, error) { + binPath, ok := hasCloudflared() + if !ok { + return &Status{ + Available: false, + Reason: "cloudflared not installed on host", + LoginHint: "Install cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/", + }, nil + } + + cert, authed := findCert() + + // Best-effort version probe — failure is non-fatal, we still + // report Available=true based on PATH. + verCmd := exec.CommandContext(ctx, binPath, "--version") + verOut, _ := verCmd.Output() + version := strings.TrimSpace(strings.SplitN(string(verOut), "\n", 2)[0]) + + st := &Status{ + Available: true, + Authenticated: authed, + CertPath: cert, + Version: version, + } + if !authed { + st.LoginHint = "Run on the server: sudo cloudflared tunnel login" + } + return st, nil +} + +// runTunnel runs `cloudflared tunnel ` and returns +// (stdout, error). Errors include the full stderr because the user +// will likely see them in the panel UI when something goes wrong. +func (l *linuxManager) runTunnel(ctx context.Context, args ...string) ([]byte, error) { + args = append([]string{"tunnel"}, args...) + cmd := exec.CommandContext(ctx, "cloudflared", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("cloudflared %s: %w (%s)", + strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return stdout.Bytes(), nil +} + +func (l *linuxManager) List(ctx context.Context) ([]Tunnel, error) { + out, err := l.runTunnel(ctx, "list", "--output", "json") + if err != nil { + return nil, err + } + + // cloudflared prints `[]` (sometimes with trailing whitespace) when + // no tunnels exist; json.Unmarshal handles both. Tunnel rows have + // more fields than we surface — we cherry-pick id, name, + // created_at, connections. + var raw []struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + Connections []map[string]interface{} `json:"connections"` + } + if err := json.Unmarshal(bytes.TrimSpace(out), &raw); err != nil { + return nil, fmt.Errorf("parse tunnel list: %w", err) + } + + out2 := make([]Tunnel, 0, len(raw)) + for _, r := range raw { + conns := make([]string, 0, len(r.Connections)) + for _, c := range r.Connections { + if origin, ok := c["origin_ip"].(string); ok && origin != "" { + conns = append(conns, origin) + } + } + out2 = append(out2, Tunnel{ + ID: r.ID, Name: r.Name, CreatedAt: r.CreatedAt, Connections: conns, + }) + } + return out2, nil +} + +func (l *linuxManager) Create(ctx context.Context, req CreateRequest) (*Tunnel, error) { + if !validNameRegex.MatchString(req.Name) { + return nil, fmt.Errorf("invalid tunnel name (alphanumeric, -, _, max 32 chars)") + } + // `cloudflared tunnel create ` prints something like: + // Created tunnel with id + // Newer versions also have --output json on create; we parse the + // stdout text to be portable across versions. + out, err := l.runTunnel(ctx, "create", req.Name) + if err != nil { + return nil, err + } + // Look up by name afterwards so we have the full Tunnel record + // (the create stdout is informational, not structured JSON across + // all cloudflared versions). + _ = out + tunnels, err := l.List(ctx) + if err != nil { + return nil, err + } + for _, t := range tunnels { + if t.Name == req.Name { + return &t, nil + } + } + return nil, fmt.Errorf("tunnel created but not found in list") +} + +func (l *linuxManager) Route(ctx context.Context, req RouteRequest) error { + if req.TunnelRef == "" { + return fmt.Errorf("tunnel_ref is required") + } + if !validHostnameRegex.MatchString(req.Hostname) { + return fmt.Errorf("invalid hostname") + } + // `cloudflared tunnel route dns ` creates the + // CNAME in Cloudflare DNS. + _, err := l.runTunnel(ctx, "route", "dns", req.TunnelRef, req.Hostname) + return err +} + +// loginURLRegex matches the `https://dash.cloudflare.com/argotunnel?...` +// URL cloudflared prints to stdout during `tunnel login`. The exact +// text varies by version ("Please open the following URL", "Please +// visit", etc.), so we match the URL itself rather than the prompt. +var loginURLRegex = regexp.MustCompile(`https://dash\.cloudflare\.com/argotunnel\?\S+`) + +// Login spawns `cloudflared tunnel login` and streams a typed event +// channel. The first event after the URL is found carries AuthURL; +// the channel closes (with a final Done event) when cert.pem appears +// or the process exits / 15-minute hard ceiling fires. +// +// We can't just look at process exit because cloudflared sometimes +// prints the URL and exits cleanly while waiting on the OAuth callback +// in a separate flow. The cert.pem watcher is the source of truth. +func (l *linuxManager) Login(ctx context.Context) (<-chan LoginEvent, error) { + if _, ok := hasCloudflared(); !ok { + return nil, fmt.Errorf("cloudflared not installed") + } + // If cert.pem already exists, short-circuit — login would + // overwrite, which surprises the user. + if cert, ok := findCert(); ok { + out := make(chan LoginEvent, 1) + out <- LoginEvent{Done: true, CertPath: cert, Line: "already authenticated"} + close(out) + return out, nil + } + + out := make(chan LoginEvent, 8) + + // 15-minute ceiling on the login flow — long enough for a coffee + // break, short enough to garbage-collect a forgotten browser tab. + cctx, cancel := context.WithTimeout(ctx, 15*time.Minute) + + cmd := exec.CommandContext(cctx, "cloudflared", "tunnel", "login") + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start cloudflared: %w", err) + } + + go func() { + defer cancel() + defer close(out) + + // Tee stdout + stderr into a single line stream. cloudflared + // has historically printed the auth URL on stderr (older + // versions) and stdout (newer), so listen to both. + lines := make(chan string, 32) + go scanLines(stdout, lines) + go scanLines(stderr, lines) + + urlSent := false + // Best-effort cert.pem watcher running in parallel. cloudflared + // itself exits as soon as the cert is fetched on success, but + // the watcher is the more reliable signal in case of weird + // stdout buffering. + certCheck := time.NewTicker(2 * time.Second) + defer certCheck.Stop() + + // Drain pipes + watch cert in the same select loop. + for { + select { + case <-cctx.Done(): + out <- LoginEvent{Done: true, Error: "login timed out"} + _ = cmd.Process.Kill() + return + case line, ok := <-lines: + if !ok { + // pipes closed — wait for cmd to reap and emit final. + _ = cmd.Wait() + if cert, ok := findCert(); ok { + out <- LoginEvent{Done: true, CertPath: cert} + } else { + out <- LoginEvent{Done: true, Error: "cloudflared exited without writing cert.pem"} + } + return + } + if !urlSent { + if u := loginURLRegex.FindString(line); u != "" { + urlSent = true + out <- LoginEvent{AuthURL: u, Line: line} + continue + } + } + out <- LoginEvent{Line: line} + case <-certCheck.C: + if cert, ok := findCert(); ok { + out <- LoginEvent{Done: true, CertPath: cert} + _ = cmd.Process.Kill() + return + } + } + } + }() + return out, nil +} + +// scanLines pumps r line-by-line into out. Closes out when r is +// exhausted; multiple producers are fine because the consumer treats +// a single closed channel as "all done." +func scanLines(r io.ReadCloser, out chan<- string) { + defer r.Close() + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + out <- sc.Text() + } +} + +func (l *linuxManager) Delete(ctx context.Context, ref string) error { + if ref == "" { + return fmt.Errorf("ref is required") + } + // -f forces deletion when the tunnel still has active connections; + // safer default for a panel-driven flow. + _, err := l.runTunnel(ctx, "delete", "-f", ref) + return err +} diff --git a/agent/internal/cloudflared/cloudflared_other.go b/agent/internal/cloudflared/cloudflared_other.go new file mode 100644 index 00000000..2bb1b071 --- /dev/null +++ b/agent/internal/cloudflared/cloudflared_other.go @@ -0,0 +1,28 @@ +//go:build !linux + +package cloudflared + +import ( + "context" + "fmt" +) + +// New returns a stub Manager on non-Linux platforms. +func New() Manager { return stubManager{} } + +type stubManager struct{} + +var errUnsupported = fmt.Errorf("cloudflared is only supported on Linux agents") + +func (stubManager) Status(ctx context.Context) (*Status, error) { + return &Status{Available: false, Reason: "cloudflared is only supported on Linux agents"}, nil +} +func (stubManager) List(ctx context.Context) ([]Tunnel, error) { return nil, errUnsupported } +func (stubManager) Create(ctx context.Context, _ CreateRequest) (*Tunnel, error) { + return nil, errUnsupported +} +func (stubManager) Route(ctx context.Context, _ RouteRequest) error { return errUnsupported } +func (stubManager) Delete(ctx context.Context, _ string) error { return errUnsupported } +func (stubManager) Login(ctx context.Context) (<-chan LoginEvent, error) { + return nil, errUnsupported +} diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go index bb6ef6bf..5095bd89 100644 --- a/agent/internal/config/config.go +++ b/agent/internal/config/config.go @@ -50,6 +50,11 @@ type AuthConfig struct { KeyFile string `yaml:"key_file"` APIKey string `yaml:"api_key,omitempty"` // Not saved to config file APISecret string `yaml:"api_secret,omitempty"` // Not saved to config file + // LoadError is populated by Load() when LoadCredentials() failed. + // Surfaced in agent startup logs so "service runs but every /connect + // returns 400 missing fields" reports become trivial to diagnose + // (means the key file couldn't be decrypted under any known key). + LoadError string `yaml:"-"` } // FeaturesConfig controls enabled features @@ -122,7 +127,7 @@ func Default() *Config { Docker: true, Metrics: true, Logs: true, - FileAccess: false, + FileAccess: true, Exec: false, }, Metrics: MetricsConfig{ @@ -136,7 +141,7 @@ func Default() *Config { Timeout: 30 * time.Second, }, Security: SecurityConfig{ - AllowedPaths: []string{}, + AllowedPaths: defaultAllowedPaths(), BlockedCommands: []string{}, MaxExecTimeout: 5 * time.Minute, }, @@ -180,10 +185,14 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("failed to parse config: %w", err) } - // Load credentials from secure storage + // Load credentials from secure storage. We don't fail loading the + // config if creds are missing — that's normal pre-pairing — but we + // stash the error on the config so callers running with cfg.Agent.ID + // set (i.e. should-be-paired) can detect "creds went missing" + // scenarios that would otherwise silently produce empty auth fields + // and 400s on every connect attempt. if err := cfg.LoadCredentials(); err != nil { - // Credentials may not exist yet (before registration) - // This is not an error + cfg.Auth.LoadError = err.Error() } return cfg, nil @@ -263,7 +272,12 @@ func (c *Config) SaveCredentials() error { return nil } -// LoadCredentials loads API credentials from secure storage +// LoadCredentials loads API credentials from secure storage. Tries the +// current key derivation first, then falls back to the legacy (pre-1.6.14) +// key that included USERNAME. If the legacy key is what worked, we +// transparently re-encrypt under the current scheme so the next load +// uses the host-stable key — required for the SCM-launched service +// (running as SYSTEM) to read credentials paired by a user-context wizard. func (c *Config) LoadCredentials() error { keyPath := c.Auth.KeyFile if keyPath == "" { @@ -275,8 +289,7 @@ func (c *Config) LoadCredentials() error { return fmt.Errorf("failed to read key file: %w", err) } - // Decrypt credentials - decrypted, err := decryptCredentials(data) + decrypted, usedLegacy, err := decryptCredentialsWithMigration(data) if err != nil { return fmt.Errorf("failed to decrypt credentials: %w", err) } @@ -296,6 +309,13 @@ func (c *Config) LoadCredentials() error { c.Auth.APIKey = apiKey c.Auth.APISecret = apiSecret + // Migrate creds onto the host-only key so the next service start + // (which may run as a different user / SYSTEM) can decrypt them. + // Best-effort: failure here doesn't block this run. + if usedLegacy { + _ = c.SaveCredentials() + } + return nil } @@ -328,10 +348,36 @@ func defaultLogPath() string { return "/var/log/serverkit-agent/agent.log" } -// getMachineKey generates a machine-specific encryption key +// IPCTokenPath returns the absolute path to the agent's IPC bearer-token +// file. The token gates the local HTTP API the desktop console and tray +// app use; clients must pass it as `Authorization: Bearer `. +// Living next to the existing key file means it inherits the same 0600 +// directory ACLs and ships with the same backup/restore policies. +func IPCTokenPath() string { + if runtime.GOOS == "windows" { + return filepath.Join(os.Getenv("ProgramData"), "ServerKit", "Agent", "ipc.token") + } + return "/etc/serverkit-agent/ipc.token" +} + +func defaultAllowedPaths() []string { + if runtime.GOOS == "windows" { + return []string{filepath.Join(os.Getenv("ProgramData"), "ServerKit")} + } + return []string{"/var/lib/serverkit", "/var/serverkit"} +} + +// getMachineKey generates a machine-specific encryption key. +// +// Stable across user contexts on the same host. The previous derivation +// included USERNAME, which meant credentials encrypted by a user-context +// pairing wizard (juanh) were undecryptable by the SYSTEM-context service +// — agent service started up with empty credentials, every /connect +// returned 400 "Missing required fields". This was masked for years +// because the agent was never actually running as a real Windows service +// (the SCM dispatcher only landed in 1.6.13). Now that it is, the key +// must be host-stable, not user-stable. func getMachineKey() []byte { - // Use machine-specific data to derive key - // This makes the credentials only decryptable on this machine hostname, _ := os.Hostname() var machineID string @@ -341,7 +387,28 @@ func getMachineKey() []byte { machineID = string(data) } } else if runtime.GOOS == "windows" { - // On Windows, use a combination of hostname and username + machineID = os.Getenv("COMPUTERNAME") + } + + combined := fmt.Sprintf("serverkit-agent:%s:%s", hostname, machineID) + hash := sha256.Sum256([]byte(combined)) + return hash[:] +} + +// getMachineKeyLegacyV1 is the pre-1.6.14 derivation (Windows: included +// USERNAME). Kept solely so already-paired agents can decrypt their old +// key file once, re-encrypt under the stable v2 key, and never need it +// again. Drop after a few releases. +func getMachineKeyLegacyV1() []byte { + hostname, _ := os.Hostname() + + var machineID string + if runtime.GOOS == "linux" { + data, err := os.ReadFile("/etc/machine-id") + if err == nil { + machineID = string(data) + } + } else if runtime.GOOS == "windows" { machineID = os.Getenv("COMPUTERNAME") + os.Getenv("USERNAME") } @@ -372,31 +439,46 @@ func encryptCredentials(plaintext []byte) ([]byte, error) { return []byte(base64.StdEncoding.EncodeToString(ciphertext)), nil } +// decryptCredentials tries the current (host-only) machine key first, +// then falls back to the legacy v1 key (host + username) so credentials +// paired before 1.6.14 still load. The bool return indicates whether the +// legacy fallback was used — callers re-encrypt under the stable v2 key +// so the migration only happens once. func decryptCredentials(data []byte) ([]byte, error) { - key := getMachineKey() + plaintext, _, err := decryptCredentialsWithMigration(data) + return plaintext, err +} +func decryptCredentialsWithMigration(data []byte) (plaintext []byte, usedLegacy bool, err error) { ciphertext, err := base64.StdEncoding.DecodeString(string(data)) if err != nil { - return nil, err + return nil, false, err + } + + if pt, err2 := tryDecrypt(ciphertext, getMachineKey()); err2 == nil { + return pt, false, nil + } + if pt, err2 := tryDecrypt(ciphertext, getMachineKeyLegacyV1()); err2 == nil { + return pt, true, nil } + return nil, false, fmt.Errorf("decryption failed with both current and legacy machine keys") +} +func tryDecrypt(ciphertext, key []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { return nil, err } - gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } - nonceSize := gcm.NonceSize() if len(ciphertext) < nonceSize { return nil, fmt.Errorf("ciphertext too short") } - - nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] - return gcm.Open(nil, nonce, ciphertext, nil) + nonce, body := ciphertext[:nonceSize], ciphertext[nonceSize:] + return gcm.Open(nil, nonce, body, nil) } func splitFirst(s string, sep byte) []string { @@ -407,3 +489,32 @@ func splitFirst(s string, sep byte) []string { } return []string{s} } + +// EncryptBytes encrypts bytes using the machine-derived AES-GCM key. +// Exported for use by other internal packages (e.g. pairing keypair storage). +func EncryptBytes(plaintext []byte) ([]byte, error) { + return encryptCredentials(plaintext) +} + +// DecryptBytes decrypts bytes previously encrypted with EncryptBytes. +func DecryptBytes(data []byte) ([]byte, error) { + return decryptCredentials(data) +} + +// MachineID returns a stable per-host identifier suitable for re-pair detection. +func MachineID() string { + hostname, _ := os.Hostname() + var id string + if runtime.GOOS == "linux" { + if data, err := os.ReadFile("/etc/machine-id"); err == nil { + id = string(data) + } + } else if runtime.GOOS == "windows" { + id = os.Getenv("COMPUTERNAME") + } + if id == "" { + id = hostname + } + hash := sha256.Sum256([]byte("serverkit-machine-id:" + hostname + ":" + id)) + return base64.RawURLEncoding.EncodeToString(hash[:16]) +} diff --git a/agent/internal/connstring/connstring.go b/agent/internal/connstring/connstring.go new file mode 100644 index 00000000..91af9e95 --- /dev/null +++ b/agent/internal/connstring/connstring.go @@ -0,0 +1,102 @@ +// Package connstring decodes the panel's "connection string" — a single +// URL-shaped string the user copies from the ServerKit panel and pastes +// into the agent's pairing wizard. It bundles the panel host plus a +// single-use registration token plus an optional expiry, replacing the +// older flow where the user typed those into separate fields. +// +// Format: ``sk1://[:]/[?exp=][&insecure=1]`` +// +// The ``sk1://`` scheme self-identifies the format (you can recognise a +// ServerKit connection string at a glance) and gives us a version +// lever: any future format change goes out as ``sk2://``. The host is +// visible up front so the user can sanity-check which panel they're +// pointing at before pasting. ``insecure=1`` flips the implied scheme +// from https to http for dev / local-network use; absent it, https is +// implied. Older agents reject newer-versioned payloads cleanly with +// ErrUnknownVersion instead of mis-parsing them. +package connstring + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +const ( + // SchemePrefix is the scheme + separator that introduces every v1 + // connection string. Used to fail fast on legacy or unrelated input + // before we try to parse it as a URL. + SchemePrefix = "sk1://" +) + +// Decoded is the data extracted from a connection string. +type Decoded struct { + URL string // reconstructed http/https URL of the panel + Token string // single-use registration token + ExpiresAt time.Time // zero value means "never" or "unparseable" + ExpiresAtRaw string // panel's exact expiry string (or "" if none) +} + +// ErrUnknownVersion is returned when the prefix is missing or names a +// version this build doesn't understand. Callers should surface this as +// "your panel is newer than this agent" rather than a generic decode +// error — it's almost always the cause. +var ErrUnknownVersion = errors.New("connstring: unknown version prefix") + +// Decode parses a connection string. Whitespace surrounding the input +// is stripped (paste-from-clipboard often picks up a trailing newline). +func Decode(s string) (*Decoded, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, errors.New("connstring: empty input") + } + if !strings.HasPrefix(s, SchemePrefix) { + return nil, ErrUnknownVersion + } + + u, err := url.Parse(s) + if err != nil { + return nil, fmt.Errorf("connstring: parse: %w", err) + } + if u.Scheme != "sk1" { + return nil, ErrUnknownVersion + } + if u.Host == "" { + return nil, errors.New("connstring: missing host") + } + + token := strings.TrimPrefix(u.Path, "/") + if token == "" { + return nil, errors.New("connstring: missing token") + } + // Tokens are url-safe by construction (panel uses secrets.token_urlsafe); + // a stray slash means the user mangled the string. Better to fail + // loudly than silently truncate. + if strings.Contains(token, "/") { + return nil, errors.New("connstring: token must not contain '/'") + } + + q := u.Query() + scheme := "https" + switch q.Get("insecure") { + case "1", "true", "yes": + scheme = "http" + } + + out := &Decoded{ + URL: scheme + "://" + u.Host, + Token: token, + } + if exp := q.Get("exp"); exp != "" { + out.ExpiresAtRaw = exp + // Best-effort parse — if the panel sends a format we don't + // understand we still surface the raw value, and the panel + // remains the source of truth on whether the token is good. + if t, perr := time.Parse(time.RFC3339, exp); perr == nil { + out.ExpiresAt = t + } + } + return out, nil +} diff --git a/agent/internal/connstring/connstring_test.go b/agent/internal/connstring/connstring_test.go new file mode 100644 index 00000000..e76f8337 --- /dev/null +++ b/agent/internal/connstring/connstring_test.go @@ -0,0 +1,124 @@ +package connstring + +import ( + "errors" + "strings" + "testing" +) + +func TestDecode_Roundtrip(t *testing.T) { + got, err := Decode("sk1://panel.example.com/sk_reg_abc?exp=2026-05-08T17:00:00Z") + if err != nil { + t.Fatalf("decode: %v", err) + } + if got.URL != "https://panel.example.com" { + t.Errorf("url = %q", got.URL) + } + if got.Token != "sk_reg_abc" { + t.Errorf("token = %q", got.Token) + } + if got.ExpiresAtRaw != "2026-05-08T17:00:00Z" { + t.Errorf("expires_at_raw = %q", got.ExpiresAtRaw) + } + if got.ExpiresAt.IsZero() { + t.Errorf("expires_at not parsed: %v", got.ExpiresAt) + } +} + +func TestDecode_PreservesPort(t *testing.T) { + got, err := Decode("sk1://panel.example.com:9443/t") + if err != nil { + t.Fatalf("decode: %v", err) + } + if got.URL != "https://panel.example.com:9443" { + t.Errorf("url = %q", got.URL) + } +} + +func TestDecode_HonoursInsecureFlag(t *testing.T) { + // http panels (typically dev / local-network) round-trip via the + // insecure=1 flag. Without honouring it, the agent would default + // to https and fail TLS against an http-only backend. + got, err := Decode("sk1://localhost:47927/t?insecure=1") + if err != nil { + t.Fatalf("decode: %v", err) + } + if got.URL != "http://localhost:47927" { + t.Errorf("url = %q (want http scheme)", got.URL) + } +} + +func TestDecode_NoExpiry(t *testing.T) { + got, err := Decode("sk1://panel.example.com/t") + if err != nil { + t.Fatalf("decode: %v", err) + } + if got.ExpiresAtRaw != "" { + t.Errorf("expected no raw expiry, got %q", got.ExpiresAtRaw) + } + if !got.ExpiresAt.IsZero() { + t.Errorf("expected zero ExpiresAt, got %v", got.ExpiresAt) + } +} + +func TestDecode_TrimsWhitespace(t *testing.T) { + // Clipboard pastes often have a trailing newline; verify Decode + // doesn't reject a string just because of that. + if _, err := Decode("\n sk1://panel.example.com/t \n"); err != nil { + t.Fatalf("decode with whitespace: %v", err) + } +} + +func TestDecode_RejectsUnknownVersion(t *testing.T) { + cases := []string{ + "sk2://panel.example.com/t", + "sk_conn_v1.eyJ1cmwiOiJodHRwczovL3gifQ", // legacy format + "not-a-connection-string", + } + for _, in := range cases { + _, err := Decode(in) + if !errors.Is(err, ErrUnknownVersion) { + t.Errorf("Decode(%q): want ErrUnknownVersion, got %v", in, err) + } + } +} + +func TestDecode_RejectsEmpty(t *testing.T) { + if _, err := Decode(""); err == nil { + t.Errorf("want error for empty input") + } + if _, err := Decode(" "); err == nil { + t.Errorf("want error for whitespace-only input") + } +} + +func TestDecode_RejectsMissingToken(t *testing.T) { + cases := []string{ + "sk1://panel.example.com", + "sk1://panel.example.com/", + } + for _, in := range cases { + _, err := Decode(in) + if err == nil { + t.Errorf("Decode(%q): want error, got nil", in) + continue + } + if !strings.Contains(err.Error(), "token") { + t.Errorf("Decode(%q): unexpected error %v", in, err) + } + } +} + +func TestDecode_RejectsTokenWithSlash(t *testing.T) { + _, err := Decode("sk1://panel.example.com/sk_reg/abc") + if err == nil || !strings.Contains(err.Error(), "/") { + t.Errorf("expected error about '/', got %v", err) + } +} + +func TestDecode_RejectsMissingHost(t *testing.T) { + _, err := Decode("sk1:///t") + if err == nil || !strings.Contains(err.Error(), "host") { + t.Errorf("expected host error, got %v", err) + } +} diff --git a/agent/internal/cron/cron.go b/agent/internal/cron/cron.go new file mode 100644 index 00000000..abcccefb --- /dev/null +++ b/agent/internal/cron/cron.go @@ -0,0 +1,68 @@ +// Package cron manages user-crontab entries for the agent host. +// +// Linux-only. The Manager is implemented by a build-tagged file +// (cron_linux.go for the real thing, cron_other.go for a stub that +// returns "unsupported" on every call). Higher layers always go through +// New() so non-Linux agents short-circuit before doing any work. +// +// Storage model: +// +// - The user's crontab is the source of truth for schedule + command. +// - Entries get a stable ID derived from sha256(schedule + command), +// truncated to 12 hex chars. IDs survive add/remove of unrelated +// entries — line-index IDs would not. +// - Disabled entries are commented (`# `); the leading `# ` +// is stripped on parse and re-added on toggle. We do NOT store +// enable state out-of-band. +// - Optional human metadata (name, description) lives in a sibling +// JSON file owned by the agent. Loss of that file degrades to "no +// names" but doesn't lose any actual schedules. +package cron + +import ( + "context" + "crypto/sha256" + "encoding/hex" +) + +// Entry is one cron line. +type Entry struct { + ID string `json:"id"` + Schedule string `json:"schedule"` + Command string `json:"command"` + Enabled bool `json:"enabled"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +// Status reports whether cron is usable on this host. +type Status struct { + Available bool `json:"available"` + Running bool `json:"running"` + Daemon string `json:"daemon,omitempty"` // e.g. "cron", "cronie" + Reason string `json:"reason,omitempty"` // explanation when Available=false +} + +// AddRequest is what the panel sends to add a job. +type AddRequest struct { + Schedule string `json:"schedule"` + Command string `json:"command"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +// Manager is the platform-agnostic interface over the crontab. +type Manager interface { + Status(ctx context.Context) (*Status, error) + List(ctx context.Context) ([]Entry, error) + Add(ctx context.Context, req AddRequest) (*Entry, error) + Remove(ctx context.Context, id string) error + Toggle(ctx context.Context, id string, enabled bool) error +} + +// entryID is the public hashing rule for content-based IDs. +// Exposed so tests can mirror it; not used outside the package. +func entryID(schedule, command string) string { + sum := sha256.Sum256([]byte(schedule + "\x00" + command)) + return "cron_" + hex.EncodeToString(sum[:6]) +} diff --git a/agent/internal/cron/cron_linux.go b/agent/internal/cron/cron_linux.go new file mode 100644 index 00000000..df9e0703 --- /dev/null +++ b/agent/internal/cron/cron_linux.go @@ -0,0 +1,313 @@ +//go:build linux + +package cron + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os/exec" + "regexp" + "strings" +) + +// New returns a Manager. On Linux this drives crontab(1). +func New() Manager { return &linuxManager{} } + +type linuxManager struct{} + +// blockedShellPatterns mirror the panel's CronService validation. We +// re-check on the agent because a malicious or misconfigured panel +// shouldn't be able to push shell-injection payloads through. Reject +// anything that turns "X command" into multiple shell-evaluated +// statements. +var blockedShellPatterns = []string{";", "&&", "||", "|", "`", "$(", ">", "<", "\n", "\r"} + +func validateCommand(cmd string) error { + cmd = strings.TrimSpace(cmd) + if cmd == "" { + return fmt.Errorf("command cannot be empty") + } + for _, p := range blockedShellPatterns { + if strings.Contains(cmd, p) { + return fmt.Errorf("command contains blocked shell operator %q", p) + } + } + // First token must be an absolute path to a binary — same rule as + // the panel-local CronService. + first := strings.Fields(cmd)[0] + if !strings.HasPrefix(first, "/") { + return fmt.Errorf("command must start with an absolute path") + } + return nil +} + +var scheduleRegex = regexp.MustCompile(`^[\d\*,\-/]+$`) + +func validateSchedule(schedule string) error { + parts := strings.Fields(schedule) + if len(parts) != 5 { + return fmt.Errorf("schedule must have 5 fields, got %d", len(parts)) + } + for _, p := range parts { + if !scheduleRegex.MatchString(p) { + return fmt.Errorf("invalid schedule field %q", p) + } + } + return nil +} + +func (l *linuxManager) Status(ctx context.Context) (*Status, error) { + if _, err := exec.LookPath("crontab"); err != nil { + return &Status{ + Available: false, + Reason: "crontab not installed on host", + }, nil + } + // Best-effort daemon liveness. systemctl will respond on most modern + // distros; if it isn't present we still report Available=true since + // crontab(1) is what we drive — the user's job sits in the table + // either way. + running, daemon := false, "" + for _, name := range []string{"cron", "crond", "cronie"} { + out, _ := exec.CommandContext(ctx, "systemctl", "is-active", name).Output() + if strings.TrimSpace(string(out)) == "active" { + running = true + daemon = name + break + } + } + return &Status{Available: true, Running: running, Daemon: daemon}, nil +} + +// readCrontab returns the current user's crontab. An exit code 1 with +// "no crontab for $USER" on stderr is normal and means "empty"; treat +// that as success with empty content. +func (l *linuxManager) readCrontab(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "crontab", "-l") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + // "no crontab for X" is not an error — it's the empty state. + if strings.Contains(stderr.String(), "no crontab for") { + return "", nil + } + return "", fmt.Errorf("crontab -l: %w (%s)", err, strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} + +// writeCrontab installs new content via `crontab -`. +func (l *linuxManager) writeCrontab(ctx context.Context, content string) error { + cmd := exec.CommandContext(ctx, "crontab", "-") + cmd.Stdin = strings.NewReader(content) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("crontab install: %w (%s)", err, strings.TrimSpace(stderr.String())) + } + return nil +} + +// parsedLine is a typed view of one crontab row. +type parsedLine struct { + Raw string // verbatim text including leading "# " for disabled + Schedule string + Command string + Enabled bool + IsEntry bool // false for blank lines / pure comments +} + +// parseLine extracts schedule + command from one crontab row. Pure +// comments (lines that are # without a 5-field schedule body) are +// ignored. Disabled entries (# ) are recognised. +func parseLine(line string) parsedLine { + out := parsedLine{Raw: line} + body := strings.TrimSpace(line) + if body == "" { + return out + } + enabled := true + if strings.HasPrefix(body, "#") { + // Could be a pure comment or a disabled entry. Strip the leading + // "#" and any whitespace, then try to parse as cron syntax. + body = strings.TrimSpace(strings.TrimPrefix(body, "#")) + enabled = false + } + fields := strings.Fields(body) + if len(fields) < 6 { + return out // not an entry + } + if validateSchedule(strings.Join(fields[:5], " ")) != nil { + return out // first 5 fields don't look like a schedule + } + out.IsEntry = true + out.Schedule = strings.Join(fields[:5], " ") + out.Command = strings.Join(fields[5:], " ") + out.Enabled = enabled + return out +} + +func (l *linuxManager) List(ctx context.Context) ([]Entry, error) { + content, err := l.readCrontab(ctx) + if err != nil { + return nil, err + } + var entries []Entry + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + p := parseLine(scanner.Text()) + if !p.IsEntry { + continue + } + entries = append(entries, Entry{ + ID: entryID(p.Schedule, p.Command), + Schedule: p.Schedule, + Command: p.Command, + Enabled: p.Enabled, + Description: describeSchedule(p.Schedule), + }) + } + return entries, nil +} + +func (l *linuxManager) Add(ctx context.Context, req AddRequest) (*Entry, error) { + if err := validateSchedule(req.Schedule); err != nil { + return nil, err + } + if err := validateCommand(req.Command); err != nil { + return nil, err + } + id := entryID(req.Schedule, req.Command) + + content, err := l.readCrontab(ctx) + if err != nil { + return nil, err + } + + // Reject duplicates by content ID. Lets the panel safely retry on a + // flaky network without ending up with two identical rows. + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + p := parseLine(scanner.Text()) + if p.IsEntry && entryID(p.Schedule, p.Command) == id { + return nil, fmt.Errorf("an entry with the same schedule and command already exists") + } + } + + var b strings.Builder + b.WriteString(strings.TrimRight(content, "\n")) + if b.Len() > 0 { + b.WriteString("\n") + } + if req.Name != "" { + fmt.Fprintf(&b, "# ServerKit: %s\n", req.Name) + } + fmt.Fprintf(&b, "%s %s\n", req.Schedule, req.Command) + + if err := l.writeCrontab(ctx, b.String()); err != nil { + return nil, err + } + + return &Entry{ + ID: id, + Schedule: req.Schedule, + Command: req.Command, + Enabled: true, + Name: req.Name, + Description: describeSchedule(req.Schedule), + }, nil +} + +func (l *linuxManager) Remove(ctx context.Context, id string) error { + content, err := l.readCrontab(ctx) + if err != nil { + return err + } + var out strings.Builder + scanner := bufio.NewScanner(strings.NewReader(content)) + removed := false + skipNextComment := false + for scanner.Scan() { + line := scanner.Text() + if skipNextComment { + // Don't drop unrelated comments — only strip the + // immediately-following entry comment if we just removed + // its schedule line. Reset the flag here regardless. + skipNextComment = false + } + p := parseLine(line) + if p.IsEntry && entryID(p.Schedule, p.Command) == id { + removed = true + continue + } + out.WriteString(line) + out.WriteString("\n") + } + if !removed { + return fmt.Errorf("entry %s not found", id) + } + return l.writeCrontab(ctx, strings.TrimRight(out.String(), "\n")+"\n") +} + +func (l *linuxManager) Toggle(ctx context.Context, id string, enabled bool) error { + content, err := l.readCrontab(ctx) + if err != nil { + return err + } + var out strings.Builder + scanner := bufio.NewScanner(strings.NewReader(content)) + matched := false + for scanner.Scan() { + line := scanner.Text() + p := parseLine(line) + if p.IsEntry && entryID(p.Schedule, p.Command) == id { + matched = true + if enabled { + out.WriteString(p.Schedule + " " + p.Command + "\n") + } else { + out.WriteString("# " + p.Schedule + " " + p.Command + "\n") + } + continue + } + out.WriteString(line) + out.WriteString("\n") + } + if !matched { + return fmt.Errorf("entry %s not found", id) + } + return l.writeCrontab(ctx, strings.TrimRight(out.String(), "\n")+"\n") +} + +// describeSchedule returns a short human label for common patterns. +// Falls back to the raw schedule when nothing matches — matches the +// behaviour the panel-local CronService already produces, so per-server +// rows look the same as panel-local rows in the UI. +func describeSchedule(schedule string) string { + switch schedule { + case "* * * * *": + return "Every minute" + case "*/5 * * * *": + return "Every 5 minutes" + case "*/15 * * * *": + return "Every 15 minutes" + case "*/30 * * * *": + return "Every 30 minutes" + case "0 * * * *": + return "Hourly" + case "0 0 * * *": + return "Daily at midnight" + case "0 12 * * *": + return "Daily at noon" + case "0 0 * * 0": + return "Weekly (Sunday)" + case "0 0 1 * *": + return "Monthly (1st)" + case "0 0 1 1 *": + return "Yearly (Jan 1)" + } + return schedule +} diff --git a/agent/internal/cron/cron_other.go b/agent/internal/cron/cron_other.go new file mode 100644 index 00000000..2c1f64d8 --- /dev/null +++ b/agent/internal/cron/cron_other.go @@ -0,0 +1,27 @@ +//go:build !linux + +package cron + +import ( + "context" + "fmt" +) + +// New returns a stub Manager on non-Linux platforms. Every operation +// reports the feature as unavailable so callers fail loudly rather +// than silently doing nothing. +func New() Manager { return stubManager{} } + +type stubManager struct{} + +var errUnsupported = fmt.Errorf("cron is only supported on Linux agents") + +func (stubManager) Status(ctx context.Context) (*Status, error) { + return &Status{Available: false, Reason: "cron is only supported on Linux agents"}, nil +} +func (stubManager) List(ctx context.Context) ([]Entry, error) { return nil, errUnsupported } +func (stubManager) Add(ctx context.Context, _ AddRequest) (*Entry, error) { + return nil, errUnsupported +} +func (stubManager) Remove(ctx context.Context, _ string) error { return errUnsupported } +func (stubManager) Toggle(ctx context.Context, _ string, _ bool) error { return errUnsupported } diff --git a/agent/internal/docker/client.go b/agent/internal/docker/client.go index beaf28bc..b546ecec 100644 --- a/agent/internal/docker/client.go +++ b/agent/internal/docker/client.go @@ -11,8 +11,10 @@ import ( "github.com/docker/docker/api/types" containertypes "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" + networktypes "github.com/docker/docker/api/types/network" volumetypes "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" "github.com/serverkit/agent/internal/config" "github.com/serverkit/agent/internal/logger" ) @@ -413,6 +415,11 @@ func (c *Client) ListNetworks(ctx context.Context) ([]NetworkInfo, error) { return result, nil } +// RemoveNetwork removes a network +func (c *Client) RemoveNetwork(ctx context.Context, id string) error { + return c.cli.NetworkRemove(ctx, id) +} + // GetContainerCount returns the number of containers func (c *Client) GetContainerCount(ctx context.Context) (total int, running int, err error) { allContainers, err := c.cli.ContainerList(ctx, types.ContainerListOptions{All: true}) @@ -435,6 +442,239 @@ func (c *Client) Close() error { return c.cli.Close() } +// ───── container:create / container:exec / image:build / network:create + +// ContainerCreateSpec is the curated input shape for CreateContainer. +// We deliberately don't expose raw HostConfig — the panel sends the +// fields a one-click template needs and we map them. Privileged / +// CapAdd / Devices are intentionally absent in v1; if a user needs +// them they should run `docker create` directly. +type ContainerCreateSpec struct { + Image string `json:"image"` + Name string `json:"name,omitempty"` + Command []string `json:"command,omitempty"` + Env []string `json:"env,omitempty"` // KEY=VAL + Labels map[string]string `json:"labels,omitempty"` + RestartPolicy string `json:"restart_policy,omitempty"` // no, on-failure, always, unless-stopped + Network string `json:"network,omitempty"` + Ports []ContainerPort `json:"ports,omitempty"` + Volumes []ContainerMount `json:"volumes,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` +} + +type ContainerPort struct { + Host uint16 `json:"host"` + Container uint16 `json:"container"` + Proto string `json:"proto,omitempty"` // "tcp" (default) or "udp" +} + +type ContainerMount struct { + Source string `json:"src"` + Target string `json:"dst"` + Mode string `json:"mode,omitempty"` // "ro" or "" for rw +} + +// CreateContainer wires a curated spec to the Docker SDK's +// ContainerCreate. Returns the new container ID. +func (c *Client) CreateContainer(ctx context.Context, spec ContainerCreateSpec) (string, error) { + if strings.TrimSpace(spec.Image) == "" { + return "", fmt.Errorf("image is required") + } + + exposed := nat.PortSet{} + bindings := nat.PortMap{} + for _, p := range spec.Ports { + proto := p.Proto + if proto == "" { + proto = "tcp" + } + port, err := nat.NewPort(proto, fmt.Sprintf("%d", p.Container)) + if err != nil { + return "", fmt.Errorf("invalid port %d/%s: %w", p.Container, proto, err) + } + exposed[port] = struct{}{} + bindings[port] = []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: fmt.Sprintf("%d", p.Host)}} + } + + binds := make([]string, 0, len(spec.Volumes)) + for _, v := range spec.Volumes { + bind := v.Source + ":" + v.Target + if v.Mode != "" { + bind += ":" + v.Mode + } + binds = append(binds, bind) + } + + cfg := &containertypes.Config{ + Image: spec.Image, + Cmd: spec.Command, + Env: spec.Env, + Labels: spec.Labels, + WorkingDir: spec.WorkingDir, + ExposedPorts: exposed, + } + hostCfg := &containertypes.HostConfig{ + PortBindings: bindings, + Binds: binds, + RestartPolicy: containertypes.RestartPolicy{ + Name: spec.RestartPolicy, + }, + } + var netCfg *networktypes.NetworkingConfig + if spec.Network != "" { + netCfg = &networktypes.NetworkingConfig{ + EndpointsConfig: map[string]*networktypes.EndpointSettings{ + spec.Network: {}, + }, + } + } + resp, err := c.cli.ContainerCreate(ctx, cfg, hostCfg, netCfg, nil, spec.Name) + if err != nil { + return "", err + } + return resp.ID, nil +} + +// ContainerExecResult is the bounded result of an exec call. +type ContainerExecResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` +} + +// ExecContainer runs a command inside a running container, captures +// stdout/stderr (capped to outputCap bytes), and returns the exit code. +// User and WorkingDir are optional; pass an empty string to inherit. +func (c *Client) ExecContainer(ctx context.Context, id string, cmd []string, user, workingDir string, outputCap int) (ContainerExecResult, error) { + if outputCap <= 0 { + outputCap = 1024 * 1024 // 1MB default + } + createResp, err := c.cli.ContainerExecCreate(ctx, id, types.ExecConfig{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + User: user, + WorkingDir: workingDir, + }) + if err != nil { + return ContainerExecResult{}, err + } + hijack, err := c.cli.ContainerExecAttach(ctx, createResp.ID, types.ExecStartCheck{}) + if err != nil { + return ContainerExecResult{}, err + } + defer hijack.Close() + + // Read multiplexed stream: docker frames stdout/stderr together + // with an 8-byte header. stdcopy.StdCopy demultiplexes, but to + // avoid an extra import we use a bounded ReadAll and ship the raw + // stream — the panel only needs the combined output anyway. + buf := &boundedBuffer{cap: outputCap} + _, _ = io.Copy(buf, hijack.Reader) + + insp, err := c.cli.ContainerExecInspect(ctx, createResp.ID) + if err != nil { + return ContainerExecResult{}, err + } + return ContainerExecResult{ + Stdout: buf.String(), + ExitCode: insp.ExitCode, + }, nil +} + +// boundedBuffer is a tiny io.Writer that drops bytes past a cap. Used +// instead of bytes.Buffer to keep exec output from blowing memory on a +// chatty command like `find /`. +type boundedBuffer struct { + cap int + data []byte +} + +func (b *boundedBuffer) Write(p []byte) (int, error) { + remaining := b.cap - len(b.data) + if remaining <= 0 { + return len(p), nil + } + if len(p) > remaining { + b.data = append(b.data, p[:remaining]...) + return len(p), nil + } + b.data = append(b.data, p...) + return len(p), nil +} + +func (b *boundedBuffer) String() string { return string(b.data) } + +// ImageBuildSpec carries the curated input for BuildImage. +type ImageBuildSpec struct { + ContextPath string `json:"context_path"` + Dockerfile string `json:"dockerfile,omitempty"` + Tag string `json:"tag"` + BuildArgs map[string]string `json:"build_args,omitempty"` + NoCache bool `json:"no_cache,omitempty"` + Pull bool `json:"pull,omitempty"` +} + +// BuildImageCmd builds the *exec.Cmd for a `docker build` invocation +// based on spec. The caller streams stdout/stderr (we use the same +// jobs streaming helper as packages). Returning a Cmd rather than a +// reader keeps this file from importing the docker SDK's archive +// package, which pulls in containerd/moby deps that aren't in our +// go.mod. +func (c *Client) BuildImageCmd(ctx context.Context, spec ImageBuildSpec) (*exec.Cmd, error) { + if strings.TrimSpace(spec.ContextPath) == "" { + return nil, fmt.Errorf("context_path is required") + } + if strings.TrimSpace(spec.Tag) == "" { + return nil, fmt.Errorf("tag is required") + } + args := []string{"build", "-t", spec.Tag} + if spec.Dockerfile != "" { + args = append(args, "-f", spec.Dockerfile) + } + for k, v := range spec.BuildArgs { + args = append(args, "--build-arg", k+"="+v) + } + if spec.NoCache { + args = append(args, "--no-cache") + } + if spec.Pull { + args = append(args, "--pull") + } + args = append(args, spec.ContextPath) + return exec.CommandContext(ctx, "docker", args...), nil +} + +// NetworkCreateSpec carries curated input for CreateNetwork. +type NetworkCreateSpec struct { + Name string `json:"name"` + Driver string `json:"driver,omitempty"` // bridge (default), overlay, host + Internal bool `json:"internal,omitempty"` + Attachable bool `json:"attachable,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +// CreateNetwork creates a Docker network and returns its ID. +func (c *Client) CreateNetwork(ctx context.Context, spec NetworkCreateSpec) (string, error) { + if strings.TrimSpace(spec.Name) == "" { + return "", fmt.Errorf("name is required") + } + driver := spec.Driver + if driver == "" { + driver = "bridge" + } + resp, err := c.cli.NetworkCreate(ctx, spec.Name, types.NetworkCreate{ + Driver: driver, + Internal: spec.Internal, + Attachable: spec.Attachable, + Labels: spec.Labels, + }) + if err != nil { + return "", err + } + return resp.ID, nil +} + // calculateCPUPercent calculates CPU usage percentage func calculateCPUPercent(stats *types.StatsJSON) float64 { cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage - stats.PreCPUStats.CPUUsage.TotalUsage) diff --git a/agent/internal/events/store.go b/agent/internal/events/store.go new file mode 100644 index 00000000..8f1a4c3e --- /dev/null +++ b/agent/internal/events/store.go @@ -0,0 +1,191 @@ +// Package events provides a small append-only event log that powers the +// agent console's Activity tab. Events are higher-signal than raw log lines: +// "Service started", "Connection lost (timeout)", "Restart requested by +// tray". The agent process owns the store; producers across the codebase +// (websocket, lifecycle, IPC handlers) push to it via Append; the IPC server +// exposes the snapshot to the desktop UI. +// +// Persistence is a single JSON file rewritten on each Append. That sounds +// wasteful but the buffer is tiny (~200 events, one or two KB) and these +// events don't fire often enough for the rewrite cost to matter. Trade-off: +// crash-during-write is a non-issue since each rewrite is the entire state. +package events + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" +) + +// Kind is a stable identifier for the event type. Frontends use it to pick +// icons and colours; storage uses it for filtering. +type Kind string + +const ( + KindServiceStart Kind = "service_start" + KindServiceStop Kind = "service_stop" + KindRestartRequested Kind = "restart_requested" + KindWSConnected Kind = "ws_connected" + KindWSDisconnected Kind = "ws_disconnected" + KindWSReconnecting Kind = "ws_reconnecting" + KindAuthFailed Kind = "auth_failed" + KindError Kind = "error" + KindInfo Kind = "info" +) + +// Severity controls visual emphasis in the UI. Use sparingly — Info is the +// default; Warn for transient issues; Error for things the user should act on. +type Severity string + +const ( + SeverityInfo Severity = "info" + SeverityWarn Severity = "warn" + SeverityError Severity = "error" +) + +// Event is one entry in the activity log. Keep the JSON keys short and +// stable — they're a public-ish wire format consumed by the desktop UI. +type Event struct { + ID int64 `json:"id"` + Time int64 `json:"t"` + Kind Kind `json:"kind"` + Severity Severity `json:"sev"` + Message string `json:"msg"` + Metadata map[string]interface{} `json:"meta,omitempty"` +} + +// Store is a bounded ring buffer of events with optional disk persistence. +// Methods are safe for concurrent callers. +type Store struct { + mu sync.Mutex + events []Event + cap int + nextID int64 + persist string // file path; empty = in-memory only +} + +// NewStore returns a new event store with the given capacity. If +// persistPath is non-empty, the store loads events from that file on +// startup and rewrites it on every Append. Pass "" to skip persistence +// (e.g. tests). +func NewStore(capacity int, persistPath string) *Store { + s := &Store{ + events: make([]Event, 0, capacity), + cap: capacity, + persist: persistPath, + } + s.load() + return s +} + +// Append records an event. Caller doesn't need to set ID or Time — the +// store assigns them. Persistence errors are swallowed (best-effort) since +// the in-memory buffer is the source of truth at runtime. +func (s *Store) Append(kind Kind, sev Severity, message string, meta map[string]interface{}) Event { + s.mu.Lock() + defer s.mu.Unlock() + + s.nextID++ + ev := Event{ + ID: s.nextID, + Time: time.Now().UnixMilli(), + Kind: kind, + Severity: sev, + Message: message, + Metadata: meta, + } + + if len(s.events) >= s.cap { + copy(s.events, s.events[1:]) + s.events = s.events[:len(s.events)-1] + } + s.events = append(s.events, ev) + + s.saveLocked() + return ev +} + +// Snapshot returns a copy of all events in chronological order. Optional +// since=unix-ms returns only events newer than that; pass 0 to get all. +func (s *Store) Snapshot(since int64) []Event { + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]Event, 0, len(s.events)) + for _, ev := range s.events { + if ev.Time > since { + out = append(out, ev) + } + } + return out +} + +// Clear drops all events and rewrites the persisted file. +func (s *Store) Clear() { + s.mu.Lock() + defer s.mu.Unlock() + s.events = s.events[:0] + s.saveLocked() +} + +// load reads persisted events from disk. Silent on missing file (first run) +// and swallows malformed-file errors so a corrupt persist file can never +// take down the agent — we'd rather lose history than fail to start. +func (s *Store) load() { + if s.persist == "" { + return + } + data, err := os.ReadFile(s.persist) + if err != nil { + return + } + var events []Event + if err := json.Unmarshal(data, &events); err != nil { + return + } + if len(events) > s.cap { + events = events[len(events)-s.cap:] + } + s.events = events + for _, ev := range events { + if ev.ID > s.nextID { + s.nextID = ev.ID + } + } +} + +// saveLocked persists the current buffer. Must be called with s.mu held. +// Writes to a tempfile and renames so a partial write can't corrupt the +// canonical file. +func (s *Store) saveLocked() { + if s.persist == "" { + return + } + dir := filepath.Dir(s.persist) + if err := os.MkdirAll(dir, 0700); err != nil { + return + } + tmp, err := os.CreateTemp(dir, "events-*.tmp") + if err != nil { + return + } + enc := json.NewEncoder(tmp) + if err := enc.Encode(s.events); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return + } + tmp.Close() + _ = os.Rename(tmp.Name(), s.persist) +} + +// DefaultPath returns the canonical event-log file location next to the +// agent's other data files. +func DefaultPath(dataDir string) string { + if dataDir == "" { + return "" + } + return filepath.Join(dataDir, "events.json") +} diff --git a/agent/internal/gui/gui.go b/agent/internal/gui/gui.go new file mode 100644 index 00000000..f8ea4ea0 --- /dev/null +++ b/agent/internal/gui/gui.go @@ -0,0 +1,123 @@ +// Package gui implements the agent's GUI/desktop capability bridge. +// +// This is a deliberately small SDK that any panel-side extension can lean on +// without forcing the agent to fork: the agent owns *how* a screenshot is +// captured on each platform, and exposes a stable JSON contract through the +// gui:screenshot and gui:capabilities actions. Plugins (e.g. serverkit-gui) +// only deal with the contract — they never ship binaries to the agent. +// +// Implementation note: the platform paths shell out to OS-built-in tools +// (PowerShell's System.Drawing on Windows, scrot/grim/import on Linux). This +// keeps the agent dep-free and dodges cgo, at the cost of ~200ms per frame on +// Windows. A native fast path (kbinani/screenshot) is a future optimization; +// at the 1–2 fps the streaming UI runs at, shell-out is comfortably fast +// enough. +package gui + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/serverkit/agent/internal/logger" +) + +// Capabilities describes what the host can do for desktop capture. +type Capabilities struct { + // Capability is one of: "windows-gdi", "linux-x11", "linux-wayland", + // "macos-quartz", "none". Plugins gate behavior on this string. + Capability string `json:"capability"` + + // Resolution is "WxH" of the primary display, when known. + Resolution string `json:"resolution,omitempty"` + + // MaxFPS is what the agent recommends as a sane upper bound. The + // panel may run slower; it should not run faster. + MaxFPS int `json:"max_fps"` + + // SyntheticFallback hints to the panel that no real capture is + // possible and it should render its synthetic UI mode. + SyntheticFallback bool `json:"synthetic_fallback"` + + // Reason explains why Capability is "none", when applicable. + Reason string `json:"reason,omitempty"` +} + +// ScreenshotParams are the per-frame knobs the panel can dial. +type ScreenshotParams struct { + // Scale 0 < s <= 1 — server-side downscale before encoding. + Scale float64 `json:"scale"` + // Quality 10..95 for JPEG; ignored for PNG. + Quality int `json:"quality"` + // Format "jpeg" or "png". JPEG is smaller; PNG preserves text. + Format string `json:"format"` +} + +// Frame is what we hand back. ImageBase64 is the encoded image; the rest is +// metadata so the UI can size and timestamp the frame without decoding. +type Frame struct { + ImageBase64 string `json:"image_base64"` + Format string `json:"format"` + Width int `json:"width"` + Height int `json:"height"` + CapturedAt string `json:"captured_at"` +} + +// SDK is the agent-side capability bridge. One instance per agent process. +type SDK struct { + log *logger.Logger +} + +// New returns an initialized SDK. +func New(log *logger.Logger) *SDK { + return &SDK{log: log} +} + +// HandleCapabilities — wired to protocol.ActionGUICapabilities. +func (s *SDK) HandleCapabilities(_ context.Context, _ json.RawMessage) (interface{}, error) { + c, err := detectCapabilities() + if err != nil { + // Capability detection isn't supposed to fail loudly; downgrade + // to a synthetic-fallback response so the UI degrades gracefully. + return Capabilities{ + Capability: "none", + SyntheticFallback: true, + Reason: err.Error(), + }, nil + } + return c, nil +} + +// HandleScreenshot — wired to protocol.ActionGUIScreenshot. +func (s *SDK) HandleScreenshot(ctx context.Context, raw json.RawMessage) (interface{}, error) { + params := ScreenshotParams{Scale: 0.75, Quality: 70, Format: "jpeg"} + if len(raw) > 0 { + if err := json.Unmarshal(raw, ¶ms); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + } + if params.Scale <= 0 || params.Scale > 1 { + params.Scale = 0.75 + } + if params.Quality < 10 || params.Quality > 95 { + params.Quality = 70 + } + if params.Format != "png" && params.Format != "jpeg" { + params.Format = "jpeg" + } + + frame, err := captureFrame(ctx, params) + if err != nil { + return nil, fmt.Errorf("capture: %w", err) + } + + if s.log != nil { + s.log.Info("gui:screenshot", + "format", frame.Format, + "width", frame.Width, + "height", frame.Height, + "bytes_b64", len(frame.ImageBase64), + ) + } + return frame, nil +} diff --git a/agent/internal/gui/gui_linux.go b/agent/internal/gui/gui_linux.go new file mode 100644 index 00000000..14427021 --- /dev/null +++ b/agent/internal/gui/gui_linux.go @@ -0,0 +1,191 @@ +//go:build linux + +package gui + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "image" + "image/jpeg" + "image/png" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +func detectCapabilities() (Capabilities, error) { + if os.Getenv("WAYLAND_DISPLAY") != "" { + if hasOnPath("grim") { + return Capabilities{ + Capability: "linux-wayland", + MaxFPS: 3, + SyntheticFallback: false, + }, nil + } + } + if os.Getenv("DISPLAY") != "" { + for _, bin := range []string{"scrot", "import", "gnome-screenshot"} { + if hasOnPath(bin) { + return Capabilities{ + Capability: "linux-x11", + MaxFPS: 3, + SyntheticFallback: false, + }, nil + } + } + return Capabilities{ + Capability: "none", + SyntheticFallback: true, + Reason: "X11 detected but no scrot/import/gnome-screenshot installed", + }, nil + } + return Capabilities{ + Capability: "none", + SyntheticFallback: true, + Reason: "no display server (headless)", + }, nil +} + +func captureFrame(ctx context.Context, p ScreenshotParams) (*Frame, error) { + raw, capturedFormat, err := captureRawPNG(ctx) + if err != nil { + return nil, err + } + + // Decode → optionally downscale → re-encode to requested format. + // We pay the decode cost so we can guarantee Scale + Quality work + // regardless of which capture tool we used. + src, _, err := image.Decode(bytes.NewReader(raw)) + if err != nil { + // Some tools occasionally emit non-PNG; if the panel asked for + // the same format we already have, pass it through verbatim. + if capturedFormat == p.Format { + return &Frame{ + ImageBase64: base64.StdEncoding.EncodeToString(raw), + Format: capturedFormat, + CapturedAt: time.Now().UTC().Format(time.RFC3339), + }, nil + } + return nil, fmt.Errorf("decode capture: %w", err) + } + + if p.Scale > 0 && p.Scale < 1.0 { + src = nearestScale(src, p.Scale) + } + + var buf bytes.Buffer + switch p.Format { + case "png": + if err := png.Encode(&buf, src); err != nil { + return nil, fmt.Errorf("png encode: %w", err) + } + default: + if err := jpeg.Encode(&buf, src, &jpeg.Options{Quality: p.Quality}); err != nil { + return nil, fmt.Errorf("jpeg encode: %w", err) + } + } + + b := src.Bounds() + return &Frame{ + ImageBase64: base64.StdEncoding.EncodeToString(buf.Bytes()), + Format: p.Format, + Width: b.Dx(), + Height: b.Dy(), + CapturedAt: time.Now().UTC().Format(time.RFC3339), + }, nil +} + +// captureRawPNG returns raw bytes from whichever capture tool is available. +// Format is always "png" today, but returned explicitly to leave room for +// JPEG-native captures in the future. +func captureRawPNG(ctx context.Context) ([]byte, string, error) { + switch { + case os.Getenv("WAYLAND_DISPLAY") != "" && hasOnPath("grim"): + out, err := run(ctx, "grim", "-") + return out, "png", err + + case hasOnPath("scrot"): + // scrot can write PNG to a file; it can't write to stdout in + // every version, so we take the file route via /dev/stdout + // where supported. + out, err := run(ctx, "scrot", "-o", "/dev/stdout") + if err != nil { + // Older scrot — fall back to a temp file. + return scrotViaTemp(ctx) + } + return out, "png", nil + + case hasOnPath("import"): + out, err := run(ctx, "import", "-window", "root", "png:-") + return out, "png", err + } + return nil, "", errors.New("no screenshot tool on PATH (install scrot, grim, or imagemagick)") +} + +func scrotViaTemp(ctx context.Context) ([]byte, string, error) { + f, err := os.CreateTemp("", "sk-scrot-*.png") + if err != nil { + return nil, "", err + } + path := f.Name() + f.Close() + defer os.Remove(path) + + if _, err := run(ctx, "scrot", "-o", path); err != nil { + return nil, "", err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, "", err + } + return data, "png", nil +} + +func run(ctx context.Context, name string, args ...string) ([]byte, error) { + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + cmd := exec.CommandContext(cctx, name, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("%s: %w (%s)", name, err, strings.TrimSpace(stderr.String())) + } + return stdout.Bytes(), nil +} + +func hasOnPath(name string) bool { + _, err := exec.LookPath(name) + return err == nil +} + +// nearestScale is a stdlib-only scaler. Quality is fine for streaming +// previews at 50–75%; we don't need bilinear/bicubic here. +func nearestScale(src image.Image, scale float64) image.Image { + b := src.Bounds() + dstW := int(float64(b.Dx()) * scale) + dstH := int(float64(b.Dy()) * scale) + if dstW < 1 { + dstW = 1 + } + if dstH < 1 { + dstH = 1 + } + dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) + for y := 0; y < dstH; y++ { + sy := b.Min.Y + int(float64(y)/scale) + for x := 0; x < dstW; x++ { + sx := b.Min.X + int(float64(x)/scale) + dst.Set(x, y, src.At(sx, sy)) + } + } + return dst +} + +// Avoid an unused-import warning when strconv isn't used in a build variant. +var _ = strconv.Itoa diff --git a/agent/internal/gui/gui_other.go b/agent/internal/gui/gui_other.go new file mode 100644 index 00000000..26e3116e --- /dev/null +++ b/agent/internal/gui/gui_other.go @@ -0,0 +1,23 @@ +//go:build !windows && !linux + +package gui + +import "context" + +func detectCapabilities() (Capabilities, error) { + return Capabilities{ + Capability: "none", + SyntheticFallback: true, + Reason: "platform not yet supported by gui SDK", + }, nil +} + +func captureFrame(_ context.Context, _ ScreenshotParams) (*Frame, error) { + return nil, errNotImplemented +} + +var errNotImplemented = &platformErr{"gui capture not implemented on this platform"} + +type platformErr struct{ msg string } + +func (e *platformErr) Error() string { return e.msg } diff --git a/agent/internal/gui/gui_windows.go b/agent/internal/gui/gui_windows.go new file mode 100644 index 00000000..e4509555 --- /dev/null +++ b/agent/internal/gui/gui_windows.go @@ -0,0 +1,174 @@ +//go:build windows + +package gui + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "os/exec" + "strconv" + "strings" + "time" +) + +// PowerShell snippet: capture the primary screen with System.Drawing, +// optionally downscale, encode to JPEG/PNG, base64-emit to stdout. We +// pass scale and quality as args; format is selected by name. +// +// Why PowerShell instead of a Go-native call: System.Drawing ships in every +// Windows install since the dawn of time, and shelling out keeps the agent +// free of GDI cgo wrappers. Slow, but at 1–2 fps it's invisible. +const psCaptureScript = ` +param( + [double]$Scale = 0.75, + [int]$Quality = 70, + [string]$Format = "jpeg" +) +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms + +$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds +$srcW = $bounds.Width +$srcH = $bounds.Height + +$bmp = New-Object System.Drawing.Bitmap $srcW, $srcH +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bounds.Size) + +if ($Scale -lt 1.0 -and $Scale -gt 0) { + $dstW = [int]([Math]::Round($srcW * $Scale)) + $dstH = [int]([Math]::Round($srcH * $Scale)) + $resized = New-Object System.Drawing.Bitmap $dstW, $dstH + $rg = [System.Drawing.Graphics]::FromImage($resized) + $rg.InterpolationMode = "HighQualityBicubic" + $rg.DrawImage($bmp, 0, 0, $dstW, $dstH) + $rg.Dispose() + $bmp.Dispose() + $bmp = $resized +} + +$ms = New-Object System.IO.MemoryStream +if ($Format -eq "png") { + $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) +} else { + $codecs = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() + $jpegCodec = $codecs | Where-Object { $_.MimeType -eq "image/jpeg" } | Select-Object -First 1 + $params = New-Object System.Drawing.Imaging.EncoderParameters 1 + $params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter ([System.Drawing.Imaging.Encoder]::Quality), [long]$Quality + $bmp.Save($ms, $jpegCodec, $params) +} + +$bytes = $ms.ToArray() +$ms.Dispose() +$bmp.Dispose() + +# Emit a single line: WIDTH HEIGHT BASE64 +[Console]::Out.Write("$($bmp.Width) $($bmp.Height) ") +[Console]::Out.WriteLine([Convert]::ToBase64String($bytes)) +` + +func detectCapabilities() (Capabilities, error) { + res, err := runPowerShell(context.Background(), ` +$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds 2>$null +if ($b) { Write-Output ("{0}x{1}" -f $b.Width, $b.Height) } +`, 5*time.Second) + if err != nil || strings.TrimSpace(res) == "" { + // Most common cause: agent running as a Windows service in + // session 0 with no logged-in user. The bitmap will exist but + // we won't capture anything visible. + return Capabilities{ + Capability: "none", + SyntheticFallback: true, + Reason: "no active user session", + }, nil + } + return Capabilities{ + Capability: "windows-gdi", + Resolution: strings.TrimSpace(res), + MaxFPS: 5, + SyntheticFallback: false, + }, nil +} + +func captureFrame(ctx context.Context, p ScreenshotParams) (*Frame, error) { + args := []string{ + "-Scale", strconv.FormatFloat(p.Scale, 'f', 3, 64), + "-Quality", strconv.Itoa(p.Quality), + "-Format", p.Format, + } + out, err := runPowerShellWithArgs(ctx, psCaptureScript, args, 15*time.Second) + if err != nil { + return nil, err + } + // stdout is "WIDTH HEIGHT BASE64\n" + line := strings.TrimSpace(out) + parts := strings.SplitN(line, " ", 3) + if len(parts) != 3 { + return nil, fmt.Errorf("malformed capture output: %q", truncate(line, 120)) + } + w, err1 := strconv.Atoi(parts[0]) + h, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil { + return nil, errors.New("malformed dimensions") + } + // Sanity-check the base64 payload but don't decode it; the panel does that. + if _, err := base64.StdEncoding.DecodeString(parts[2][:min(64, len(parts[2]))]); err != nil { + return nil, fmt.Errorf("invalid base64 prefix: %w", err) + } + return &Frame{ + ImageBase64: parts[2], + Format: p.Format, + Width: w, + Height: h, + CapturedAt: time.Now().UTC().Format(time.RFC3339), + }, nil +} + +func runPowerShell(ctx context.Context, script string, timeout time.Duration) (string, error) { + return runPowerShellWithArgs(ctx, script, nil, timeout) +} + +func runPowerShellWithArgs(ctx context.Context, script string, args []string, timeout time.Duration) (string, error) { + cctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + bin := "powershell" + if p, err := exec.LookPath("pwsh"); err == nil { + bin = p + } + + all := []string{ + "-NoProfile", "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-Command", script, + } + all = append(all, args...) + + cmd := exec.CommandContext(cctx, bin, all...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/agent/internal/ipc/auth_test.go b/agent/internal/ipc/auth_test.go new file mode 100644 index 00000000..5b696c5c --- /dev/null +++ b/agent/internal/ipc/auth_test.go @@ -0,0 +1,142 @@ +package ipc + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestBearerToken(t *testing.T) { + cases := []struct { + name string + header string + query string + want string + }{ + {"no auth header, no query", "", "", ""}, + {"valid bearer", "Bearer abc123", "", "abc123"}, + {"case-insensitive scheme", "bearer abc123", "", "abc123"}, + {"trims whitespace", "Bearer abc123 ", "", "abc123"}, + {"non-bearer scheme", "Basic abc123", "", ""}, + {"query string fallback", "", "tok-from-query", "tok-from-query"}, + {"header wins over query", "Bearer header-tok", "query-tok", "header-tok"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/x", nil) + if tc.header != "" { + r.Header.Set("Authorization", tc.header) + } + if tc.query != "" { + q := r.URL.Query() + q.Set("token", tc.query) + r.URL.RawQuery = q.Encode() + } + if got := bearerToken(r); got != tc.want { + t.Fatalf("bearerToken: got %q, want %q", got, tc.want) + } + }) + } +} + +func TestAuthMiddleware(t *testing.T) { + const tok = "secret-token-value" + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + mw := authMiddleware(tok, inner) + + t.Run("health is exempt", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + mw.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("/health blocked: got %d", w.Code) + } + }) + + t.Run("missing token rejected", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/status", nil) + w := httptest.NewRecorder() + mw.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } + }) + + t.Run("wrong token rejected", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/status", nil) + r.Header.Set("Authorization", "Bearer wrong-token") + w := httptest.NewRecorder() + mw.ServeHTTP(w, r) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } + }) + + t.Run("correct token allowed", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/status", nil) + r.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + mw.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + }) + + t.Run("OPTIONS bypasses auth (CORS preflight)", func(t *testing.T) { + r := httptest.NewRequest(http.MethodOptions, "/status", nil) + w := httptest.NewRecorder() + mw.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("CORS preflight blocked: got %d", w.Code) + } + }) +} + +func TestLoadOrGenerateToken(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ipc.token") + + // First call creates the file with a fresh token. + tok1, err := loadOrGenerateToken(path) + if err != nil { + t.Fatalf("first generate: %v", err) + } + if len(tok1) < 32 { + t.Fatalf("token too short: %q", tok1) + } + + // Second call reuses the same token (so a tray app already running + // keeps working through agent restarts). + tok2, err := loadOrGenerateToken(path) + if err != nil { + t.Fatalf("second load: %v", err) + } + if tok1 != tok2 { + t.Fatalf("token regenerated unexpectedly: %q vs %q", tok1, tok2) + } +} + +func TestLoadOrGenerateTokenRegeneratesOnTooShort(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "ipc.token") + + // Pre-seed with a short value (eg. truncated file). loadOrGenerateToken + // should treat it as corrupted and write a fresh one rather than honour + // the stub credential. + if err := os.WriteFile(path, []byte("short"), 0o600); err != nil { + t.Fatal(err) + } + + tok, err := loadOrGenerateToken(path) + if err != nil { + t.Fatalf("regen: %v", err) + } + if tok == "short" || len(tok) < 32 { + t.Fatalf("expected regenerated token, got %q", tok) + } +} diff --git a/agent/internal/ipc/handlers.go b/agent/internal/ipc/handlers.go index c01bac6e..e5aabaab 100644 --- a/agent/internal/ipc/handlers.go +++ b/agent/internal/ipc/handlers.go @@ -49,6 +49,60 @@ func (h *Handlers) HandleMetrics(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, metrics) } +// HandleMetricsHistory returns the recent CPU/memory ring buffer used by +// the agent console to render sparklines. The buffer is bounded (5 minutes +// at 1 Hz) so the response is always tiny. +func (h *Handlers) HandleMetricsHistory(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + samples := h.provider.GetMetricsHistory() + h.writeJSON(w, map[string]interface{}{ + "samples": samples, + }) +} + +// HandleLogsClear rotates the agent log so the live tail in the desktop +// console starts fresh. Existing entries move to a timestamped backup file +// (lumberjack handles the rename) and the agent continues writing to a +// brand-new agent.log without re-opening or interrupting any goroutine. +func (h *Handlers) HandleLogsClear(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if err := h.provider.ClearLogs(); err != nil { + h.writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + h.writeJSON(w, map[string]bool{"success": true}) +} + +// HandleEvents returns the agent's recent activity events. Optional `since` +// query param (unix milliseconds) makes the endpoint cheap to poll: clients +// pass the timestamp of the most recent event they've seen and only get +// new ones back. +func (h *Handlers) HandleEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + var since int64 + if s := r.URL.Query().Get("since"); s != "" { + if parsed, err := strconv.ParseInt(s, 10, 64); err == nil { + since = parsed + } + } + evs := h.provider.GetEvents(since) + h.writeJSON(w, map[string]interface{}{ + "events": evs, + }) +} + // HandleConnection returns WebSocket connection information func (h *Handlers) HandleConnection(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/agent/internal/ipc/server.go b/agent/internal/ipc/server.go index bd1d1970..1f0dfef3 100644 --- a/agent/internal/ipc/server.go +++ b/agent/internal/ipc/server.go @@ -2,12 +2,19 @@ package ipc import ( "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" "fmt" "net" "net/http" + "os" + "path/filepath" + "strings" "time" "github.com/serverkit/agent/internal/config" + "github.com/serverkit/agent/internal/events" "github.com/serverkit/agent/internal/logger" ) @@ -15,8 +22,11 @@ import ( type StatusProvider interface { GetStatus() AgentStatus GetDetailedMetrics() *DetailedMetrics + GetMetricsHistory() []MetricSample GetConnectionInfo() ConnectionInfo GetRecentLogs(lines int) []string + ClearLogs() error + GetEvents(since int64) []events.Event Restart() error } @@ -30,6 +40,11 @@ type AgentStatus struct { ServerURL string `json:"server_url"` Uptime int64 `json:"uptime_seconds"` Version string `json:"version"` + // Transport reports which link the agent is on right now: "ws" for + // the primary Socket.IO link, "poll" when WS-incompatible tunnels + // forced a fallback to the REST polling transport. UI uses this to + // surface a "limited mode" badge — streams are unavailable in poll. + Transport string `json:"transport,omitempty"` CPUPercent float64 `json:"cpu_percent"` MemPercent float64 `json:"mem_percent"` DiskPercent float64 `json:"disk_percent"` @@ -75,6 +90,14 @@ type NetworkMetrics struct { PacketsRecv uint64 `json:"packets_recv"` } +// MetricSample is one entry in the agent's CPU/memory ring buffer used by +// the desktop console to render sparklines. Timestamp is unix milliseconds. +type MetricSample struct { + Timestamp int64 `json:"t"` + CPU float64 `json:"cpu"` + Mem float64 `json:"mem"` +} + // ConnectionInfo contains WebSocket connection details type ConnectionInfo struct { Connected bool `json:"connected"` @@ -91,6 +114,14 @@ type Server struct { server *http.Server provider StatusProvider startTime time.Time + // token is the bearer credential that gates every endpoint except + // /health. Loaded from disk if present (so a tray app already + // running survives an agent restart) or generated on first start. + // Localhost binding alone isn't enough — any local process on the + // box (browser tab, malicious npm postinstall, low-priv service + // account) could otherwise read panel URL / agent ID / logs and + // trigger /restart without authorisation. + token string } // NewServer creates a new IPC server @@ -110,12 +141,36 @@ func (s *Server) Start(ctx context.Context) error { return nil } + // Load or generate the bearer token before binding the listener. + // Token failures are fatal — running the IPC API without auth + // would expose /restart and /logs to any local process. + // + // SERVERKIT_IPC_NO_AUTH=true disables the bearer check entirely. + // This exists for dev environments where the desktop console + // bundle, asset server, and agent service can't always be + // rebuilt in lockstep — the auth requires all three to ship + // together. Logged at WARN every startup so it can't be set + // silently in production. + noAuth := os.Getenv("SERVERKIT_IPC_NO_AUTH") == "true" + if noAuth { + s.log.Warn("SERVERKIT_IPC_NO_AUTH=true: IPC bearer-token check is DISABLED; only use this for development") + } else { + tok, err := loadOrGenerateToken(config.IPCTokenPath()) + if err != nil { + return fmt.Errorf("ipc token: %w", err) + } + s.token = tok + } + mux := http.NewServeMux() // Register handlers handlers := NewHandlers(s.provider, s.log) mux.HandleFunc("/status", handlers.HandleStatus) mux.HandleFunc("/metrics", handlers.HandleMetrics) + mux.HandleFunc("/metrics/history", handlers.HandleMetricsHistory) + mux.HandleFunc("/events", handlers.HandleEvents) + mux.HandleFunc("/logs/clear", handlers.HandleLogsClear) mux.HandleFunc("/connection", handlers.HandleConnection) mux.HandleFunc("/logs", handlers.HandleLogs) mux.HandleFunc("/restart", handlers.HandleRestart) @@ -130,9 +185,13 @@ func (s *Server) Start(ctx context.Context) error { addr = fmt.Sprintf("127.0.0.1:%d", s.cfg.Port) } + var handler http.Handler = mux + if !noAuth { + handler = authMiddleware(s.token, handler) + } s.server = &http.Server{ Addr: addr, - Handler: corsMiddleware(mux), + Handler: corsMiddleware(handler), ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, @@ -179,7 +238,15 @@ func (s *Server) Stop() error { return s.server.Shutdown(ctx) } -// corsMiddleware adds CORS headers for local development +// corsMiddleware adds CORS headers for local development. +// +// The desktop console's React UI runs on the asset server (127.0.0.1:) +// and calls the IPC server (127.0.0.1:19780) — different ports means CORS +// applies. Authorization must be in Allow-Headers, otherwise the browser +// preflight refuses to forward the bearer token and every IPC request +// arrives unauthenticated → 401 storm. The previous list (just Content-Type) +// was OK before IPC auth existed; with auth required, it silently broke +// the entire desktop console. func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Only allow requests from localhost @@ -187,7 +254,7 @@ func corsMiddleware(next http.Handler) http.Handler { if origin == "" || isLocalhost(origin) { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") } if r.Method == "OPTIONS" { @@ -199,14 +266,94 @@ func corsMiddleware(next http.Handler) http.Handler { }) } -// isLocalhost checks if the origin is from localhost +// isLocalhost checks if the origin is from localhost. Strict suffix +// matching against a small allowlist keeps this honest — earlier +// substring slicing was easy to mis-edit and survives only because +// browsers send a fixed-format Origin header. func isLocalhost(origin string) bool { - return origin == "http://localhost" || - origin == "https://localhost" || - origin == "http://127.0.0.1" || - origin == "https://127.0.0.1" || - len(origin) > 17 && origin[:17] == "http://localhost:" || - len(origin) > 18 && origin[:18] == "https://localhost:" || - len(origin) > 17 && origin[:17] == "http://127.0.0.1:" || - len(origin) > 18 && origin[:18] == "https://127.0.0.1:" + switch origin { + case "http://localhost", "https://localhost", + "http://127.0.0.1", "https://127.0.0.1": + return true + } + for _, prefix := range []string{ + "http://localhost:", "https://localhost:", + "http://127.0.0.1:", "https://127.0.0.1:", + } { + if strings.HasPrefix(origin, prefix) { + return true + } + } + return false +} + +// authMiddleware enforces a constant-time bearer-token check on every +// request. /health is exempt so external probes (systemd watchdog, +// external monitoring) can verify the agent is reachable without +// being trusted with the token. Every other endpoint exposes data +// (panel URL, agent ID, logs, metrics) or actions (restart, log +// rotation) that a malicious local process must not reach. +func authMiddleware(expected string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Always allow CORS preflight to pass through; the actual + // request that follows still has to authenticate. + if r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + got := bearerToken(r) + if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 { + w.Header().Set("WWW-Authenticate", `Bearer realm="serverkit-agent"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if h == "" { + // Fallback to query string for places where setting headers + // is awkward (eg. EventSource URLs in older browsers). The + // token is only valid on 127.0.0.1 anyway, so this isn't a + // new exposure. + return r.URL.Query().Get("token") + } + const prefix = "Bearer " + if len(h) > len(prefix) && strings.EqualFold(h[:len(prefix)], prefix) { + return strings.TrimSpace(h[len(prefix):]) + } + return "" +} + +// loadOrGenerateToken returns a per-host IPC bearer token. The file is +// reused across runs so a tray app that picked up the token on first +// launch keeps working through agent restarts; only when the file is +// missing or unreadable do we generate a fresh one. +func loadOrGenerateToken(path string) (string, error) { + if data, err := os.ReadFile(path); err == nil { + tok := strings.TrimSpace(string(data)) + if len(tok) >= 32 { + return tok, nil + } + // File exists but contents are too short — treat as + // corrupted and regenerate. + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", fmt.Errorf("create token dir: %w", err) + } + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate token: %w", err) + } + tok := hex.EncodeToString(buf) + if err := os.WriteFile(path, []byte(tok), 0o600); err != nil { + return "", fmt.Errorf("write token: %w", err) + } + return tok, nil } diff --git a/agent/internal/jobs/jobs.go b/agent/internal/jobs/jobs.go new file mode 100644 index 00000000..e87b5ff2 --- /dev/null +++ b/agent/internal/jobs/jobs.go @@ -0,0 +1,208 @@ +// Package jobs tracks long-running operations (package installs, image +// builds, pyenv installs) so the panel can subscribe to a streaming +// channel and receive progress events as they happen, plus a replay of +// recent events when subscribing mid-flight. +// +// A Job is created when a handler kicks off async work (e.g. +// packages:install) and returns {job_id, channel} to the caller. The +// handler then emits Event values via Job.Push, which: +// - sends the event to the agent's stream transport (so any active +// subscriber sees it live), AND +// - records the event in a bounded ring buffer (so a late subscriber +// can be caught up via Replay). +// +// The package is small on purpose — no goroutines, no timers, no I/O. +// Callers own concurrency. The only synchronization is a mutex around +// the registry map and per-job buffer mutex. +package jobs + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + "time" +) + +// newID returns a 16-byte hex string suitable as a job identifier. +// Falls back to a timestamp if /dev/urandom is unavailable, matching +// the agent's auth.GenerateNonce style without taking a dep on it. +func newID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%d-%d", time.Now().UnixNano(), time.Now().UnixMilli()) + } + return hex.EncodeToString(b) +} + +// Phase enumerates lifecycle phases emitted by streaming jobs. The +// panel uses these to drive its progress UI. +const ( + PhaseStart = "start" + PhaseLog = "log" + PhaseStatus = "status" + PhaseDone = "done" +) + +// Event is the wire shape pushed onto a job's channel. Fields are kept +// minimal and JSON-tagged so the panel can render terminal-style output +// without per-handler parsers. Free-form Extra is for handlers that +// want to attach structured data (e.g. installed package list) to the +// final event. +type Event struct { + Phase string `json:"phase"` + Lines []string `json:"lines,omitempty"` + Message string `json:"message,omitempty"` + Percent *int `json:"percent,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Error string `json:"error,omitempty"` + Extra map[string]interface{} `json:"extra,omitempty"` +} + +// Job is one in-flight long-running operation. It owns a ring buffer +// of recent events so late subscribers can replay missed history. +type Job struct { + ID string + Channel string + + mu sync.Mutex + events []Event // ring buffer + cap int + head int // index of the next slot to write + full bool // whether we've wrapped past cap + + doneOnce sync.Once + done chan struct{} +} + +// Streamer is the slice of the agent transport that jobs need. Wraps +// SendStream so handlers don't have to import the transport package +// directly. +type Streamer interface { + SendStream(channel string, data interface{}) error +} + +// Registry tracks active and recently-completed jobs by ID. The bounded +// retention means a panel that subscribes to a job channel a few +// seconds after it completed still sees the final event. After enough +// new jobs land, the old job ages out and Replay returns nothing — at +// which point the panel knows the job is gone and should show "no +// progress available." +type Registry struct { + mu sync.Mutex + jobs map[string]*Job + // keep the last MaxJobs jobs in memory so a brief subscribe-after-finish + // race still serves the final "done" event. + maxJobs int + order []string // FIFO of job IDs; trimmed when len > maxJobs +} + +// NewRegistry returns a Registry retaining up to maxJobs jobs. +func NewRegistry(maxJobs int) *Registry { + if maxJobs <= 0 { + maxJobs = 64 + } + return &Registry{ + jobs: make(map[string]*Job), + maxJobs: maxJobs, + } +} + +// New creates and registers a new Job. bufferCap is the ring-buffer +// size for replay (suggested 200). +func (r *Registry) New(bufferCap int) *Job { + if bufferCap <= 0 { + bufferCap = 200 + } + id := newID() + j := &Job{ + ID: id, + Channel: "job:" + id, + events: make([]Event, bufferCap), + cap: bufferCap, + done: make(chan struct{}), + } + r.mu.Lock() + r.jobs[id] = j + r.order = append(r.order, id) + // Trim oldest jobs if we're over capacity. + for len(r.order) > r.maxJobs { + oldest := r.order[0] + r.order = r.order[1:] + delete(r.jobs, oldest) + } + r.mu.Unlock() + return j +} + +// Get returns the job with the given ID, or nil if unknown. +func (r *Registry) Get(id string) *Job { + r.mu.Lock() + defer r.mu.Unlock() + return r.jobs[id] +} + +// LookupByChannel returns the job whose Channel matches the given +// channel name (e.g. "job:"), or nil. Used by Subscribe handlers +// to decide whether to replay buffered events. +func (r *Registry) LookupByChannel(channel string) *Job { + if len(channel) <= 4 || channel[:4] != "job:" { + return nil + } + return r.Get(channel[4:]) +} + +// Push records an event in the ring buffer and pushes it on the +// transport's stream channel. Panic-safe: a nil streamer just records +// the event, useful for tests. +func (j *Job) Push(s Streamer, ev Event) error { + j.mu.Lock() + j.events[j.head] = ev + j.head = (j.head + 1) % j.cap + if j.head == 0 { + j.full = true + } + j.mu.Unlock() + + if ev.Phase == PhaseDone { + j.doneOnce.Do(func() { close(j.done) }) + } + + if s == nil { + return nil + } + return s.SendStream(j.Channel, ev) +} + +// Replay returns a copy of the buffered events in chronological order. +// Used when a late subscriber wants to catch up. +func (j *Job) Replay() []Event { + j.mu.Lock() + defer j.mu.Unlock() + if !j.full { + out := make([]Event, j.head) + copy(out, j.events[:j.head]) + return out + } + // Ring is full: oldest is at head, newest is just before head. + out := make([]Event, 0, j.cap) + out = append(out, j.events[j.head:]...) + out = append(out, j.events[:j.head]...) + return out +} + +// Done returns a channel closed when the job emits a final event with +// Phase == PhaseDone. Useful for tests. +func (j *Job) Done() <-chan struct{} { return j.done } + +// HasTerminated reports whether a Done event has already been emitted. +// Useful for handler shutdown paths that want to avoid double-emitting +// a final terminator if their primary loop already pushed one. +func (j *Job) HasTerminated() bool { + select { + case <-j.done: + return true + default: + return false + } +} diff --git a/agent/internal/jobs/jobs_test.go b/agent/internal/jobs/jobs_test.go new file mode 100644 index 00000000..aee2b9fb --- /dev/null +++ b/agent/internal/jobs/jobs_test.go @@ -0,0 +1,114 @@ +package jobs + +import ( + "strconv" + "sync" + "testing" +) + +type recorder struct { + mu sync.Mutex + sent []Event +} + +func (r *recorder) SendStream(channel string, data interface{}) error { + ev, _ := data.(Event) + r.mu.Lock() + r.sent = append(r.sent, ev) + r.mu.Unlock() + return nil +} + +func TestRegistryNewAssignsUniqueChannel(t *testing.T) { + r := NewRegistry(8) + a := r.New(0) + b := r.New(0) + if a.ID == b.ID { + t.Fatal("expected unique IDs") + } + if a.Channel != "job:"+a.ID || b.Channel != "job:"+b.ID { + t.Fatalf("unexpected channels: %q %q", a.Channel, b.Channel) + } + if r.LookupByChannel(a.Channel) != a { + t.Fatal("LookupByChannel did not round-trip") + } + if r.LookupByChannel("metrics") != nil { + t.Fatal("non-job channel should return nil") + } +} + +func TestPushAndReplayPreserveOrder(t *testing.T) { + r := NewRegistry(8) + j := r.New(4) + rec := &recorder{} + + for i := 0; i < 3; i++ { + if err := j.Push(rec, Event{Phase: PhaseLog, Message: strconv.Itoa(i)}); err != nil { + t.Fatal(err) + } + } + got := j.Replay() + if len(got) != 3 { + t.Fatalf("want 3 events, got %d", len(got)) + } + for i, ev := range got { + if ev.Message != strconv.Itoa(i) { + t.Fatalf("event %d out of order: %q", i, ev.Message) + } + } + if len(rec.sent) != 3 { + t.Fatalf("transport got %d events, want 3", len(rec.sent)) + } +} + +func TestRingBufferDropsOldest(t *testing.T) { + r := NewRegistry(8) + j := r.New(3) + rec := &recorder{} + for i := 0; i < 5; i++ { + _ = j.Push(rec, Event{Phase: PhaseLog, Message: strconv.Itoa(i)}) + } + got := j.Replay() + if len(got) != 3 { + t.Fatalf("ring should hold 3 events, got %d", len(got)) + } + // Oldest two events (0,1) should have been dropped; expect 2,3,4. + for i, ev := range got { + want := strconv.Itoa(i + 2) + if ev.Message != want { + t.Fatalf("ring order broken at %d: got %q want %q", i, ev.Message, want) + } + } +} + +func TestDoneClosedOnTerminalEvent(t *testing.T) { + r := NewRegistry(8) + j := r.New(4) + select { + case <-j.Done(): + t.Fatal("done before terminal event") + default: + } + exit := 0 + _ = j.Push(nil, Event{Phase: PhaseDone, ExitCode: &exit}) + select { + case <-j.Done(): + default: + t.Fatal("done not closed after terminal event") + } + // Idempotent: second done event must not panic. + _ = j.Push(nil, Event{Phase: PhaseDone, ExitCode: &exit}) +} + +func TestRegistryEvictsOldestJobs(t *testing.T) { + r := NewRegistry(2) + j1 := r.New(0) + j2 := r.New(0) + j3 := r.New(0) + if r.Get(j1.ID) != nil { + t.Fatal("j1 should have been evicted") + } + if r.Get(j2.ID) == nil || r.Get(j3.ID) == nil { + t.Fatal("j2/j3 should still be present") + } +} diff --git a/agent/internal/logger/logger.go b/agent/internal/logger/logger.go index d2ed06bc..79d1eb56 100644 --- a/agent/internal/logger/logger.go +++ b/agent/internal/logger/logger.go @@ -1,18 +1,51 @@ package logger import ( + "fmt" "io" "os" "path/filepath" + "sync" "github.com/serverkit/agent/internal/config" "gopkg.in/natefinch/lumberjack.v2" "log/slog" ) +// resilientWriter wraps multiple io.Writers and writes to all of them +// independently, swallowing per-writer errors. The standard library's +// io.MultiWriter aborts the entire write chain on the first error, +// which is a real bug on Windows services: a single transient stdout +// write failure (closed handle, ERROR_NO_DATA, etc.) blocks the +// lumberjack file writer from ever getting subsequent log records. +// That's how agent.log ended up at 0 bytes while events.json worked +// fine — events.json uses a direct os.File handle, not slog's +// handler chain. +type resilientWriter struct { + mu sync.Mutex + writers []io.Writer +} + +func (r *resilientWriter) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, w := range r.writers { + // Best-effort: write to each writer, but never let one writer's + // failure cascade to another. Returns len(p), nil unconditionally + // so slog never sees a write error and disables itself. + _, _ = w.Write(p) + } + return len(p), nil +} + // Logger wraps slog.Logger with additional context type Logger struct { *slog.Logger + // rotator is the lumberjack writer when file logging is enabled. Nil + // when only stdout is configured. Exposed via Rotate() so the desktop + // console's Logs-tab "Clear" button can roll the file without racing + // the live writer. + rotator *lumberjack.Logger } // New creates a new logger with the given configuration @@ -36,39 +69,77 @@ func New(cfg config.LoggingConfig) *Logger { } var writers []io.Writer + // Capture init errors so we can write them somewhere visible after + // the logger is constructed — silently swallowing them was how we + // got 0-byte agent.log files in the first place. + var initErrs []string - // Always write to stdout + // Always write to stdout (services on Windows: still safe even if + // the write goes nowhere — resilientWriter swallows per-writer + // errors). writers = append(writers, os.Stdout) - // Also write to file if configured + // Also write to file if configured. + var rotator *lumberjack.Logger if cfg.File != "" { - // Ensure log directory exists dir := filepath.Dir(cfg.File) - if err := os.MkdirAll(dir, 0755); err == nil { - // Use lumberjack for log rotation - fileWriter := &lumberjack.Logger{ - Filename: cfg.File, - MaxSize: cfg.MaxSize, // megabytes - MaxBackups: cfg.MaxBackups, - MaxAge: cfg.MaxAge, // days - Compress: cfg.Compress, + if err := os.MkdirAll(dir, 0755); err != nil { + initErrs = append(initErrs, fmt.Sprintf("MkdirAll(%q): %v", dir, err)) + } else { + // Probe file writability before handing the path to lumberjack + // so a permission error gets surfaced at boot instead of + // silently disabling file logging. + if f, perr := os.OpenFile(cfg.File, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644); perr != nil { + initErrs = append(initErrs, fmt.Sprintf("OpenFile(%q): %v", cfg.File, perr)) + } else { + _ = f.Close() + rotator = &lumberjack.Logger{ + Filename: cfg.File, + MaxSize: cfg.MaxSize, // megabytes + MaxBackups: cfg.MaxBackups, + MaxAge: cfg.MaxAge, // days + Compress: cfg.Compress, + } + writers = append(writers, rotator) } - writers = append(writers, fileWriter) } } - // Create multi-writer - multiWriter := io.MultiWriter(writers...) - - handler := slog.NewJSONHandler(multiWriter, opts) + multi := &resilientWriter{writers: writers} + handler := slog.NewJSONHandler(multi, opts) logger := slog.New(handler) - return &Logger{Logger: logger} + // If rotator wired up successfully, drop a startup marker so the + // file is never confused with "logger broken" — empty file is the + // failure mode we just fixed, so always emit one explicit line. + if rotator != nil { + logger.Info("logger initialized", + "file", cfg.File, "level", cfg.Level, + "max_size_mb", cfg.MaxSize, "max_backups", cfg.MaxBackups) + } + for _, e := range initErrs { + // Visible on stdout AND via slog (which still has rotator==nil + // in this branch — best-effort visibility either way). + logger.Warn("logger init issue", "error", e) + } + + return &Logger{Logger: logger, rotator: rotator} +} + +// Rotate triggers a manual log rotation. No-op when file logging is +// disabled. Used by the desktop console's "Clear logs" button so the live +// writer flushes to a backup before the in-memory tail clears. +func (l *Logger) Rotate() error { + if l.rotator == nil { + return nil + } + return l.rotator.Rotate() } -// With returns a new logger with additional attributes +// With returns a new logger with additional attributes. Carries the rotator +// reference so component loggers can also trigger rotation if needed. func (l *Logger) With(args ...any) *Logger { - return &Logger{Logger: l.Logger.With(args...)} + return &Logger{Logger: l.Logger.With(args...), rotator: l.rotator} } // WithComponent returns a logger with a component name diff --git a/agent/internal/metrics/collector.go b/agent/internal/metrics/collector.go index e0d53f9e..10e52e9b 100644 --- a/agent/internal/metrics/collector.go +++ b/agent/internal/metrics/collector.go @@ -194,7 +194,10 @@ func (c *Collector) GetSystemInfo(ctx context.Context) (*SystemInfo, error) { info.KernelVersion = hostInfo.KernelVersion } - // CPU info + // CPU info — gopsutil's WMI/sysctl calls fail intermittently on + // some Windows service contexts, so fall back to runtime.NumCPU() + // for the count (always non-zero) and leave the model empty + // rather than reporting nonsense. cpuInfo, err := cpu.InfoWithContext(ctx) if err == nil && len(cpuInfo) > 0 { info.CPUModel = cpuInfo[0].ModelName @@ -210,6 +213,14 @@ func (c *Collector) GetSystemInfo(ctx context.Context) (*SystemInfo, error) { if err == nil { info.CPUCores = cores } + // Last-resort fallback: runtime.NumCPU is always >= 1 on a + // running process. Better than reporting 0 cores. + if info.CPUCores == 0 { + info.CPUCores = runtime.NumCPU() + } + if info.CPUThreads == 0 { + info.CPUThreads = runtime.NumCPU() + } // Memory memInfo, err := mem.VirtualMemoryWithContext(ctx) diff --git a/agent/internal/pairdriver/driver.go b/agent/internal/pairdriver/driver.go new file mode 100644 index 00000000..b6c23d99 --- /dev/null +++ b/agent/internal/pairdriver/driver.go @@ -0,0 +1,172 @@ +// Package pairdriver runs the agent's pairing protocol: enroll with the +// panel, wait for the operator to claim, persist the resulting credentials. +// It's UI-agnostic — both the legacy walk wizard (setupui) and the +// React-based console wizard (agentui) drive it via callbacks. +package pairdriver + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/serverkit/agent/internal/config" + "github.com/serverkit/agent/internal/logger" + "github.com/serverkit/agent/internal/metrics" + "github.com/serverkit/agent/internal/pairing" +) + +// passphraseAlphabet excludes confusable characters (0/o/1/i/l) for +// readability. Lowercase so the operator's natural typing matches the +// displayed value — no shift-dance, no caps-lock surprises. +const passphraseAlphabet = "23456789abcdefghjkmnpqrstuvwxyz" + +// GeneratePassphrase returns a fresh 8-char passphrase for an enroll +// request. 8 chars from this 30-char alphabet ≈ 39 bits of entropy, which +// is overkill for a one-shot value that becomes useless once the panel +// claims the agent. +func GeneratePassphrase() (string, error) { + const n = 8 + max := big.NewInt(int64(len(passphraseAlphabet))) + buf := make([]byte, n) + for i := 0; i < n; i++ { + idx, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + buf[i] = passphraseAlphabet[idx.Int64()] + } + return string(buf), nil +} + +// Callbacks lets the UI observe state transitions in the pairing driver. +// All callbacks fire on a background goroutine; UI code is responsible for +// marshalling them onto its own thread when relevant. +type Callbacks struct { + OnEnrolled func(code, formatted string) + OnClaimed func(serverName string) + OnError func(err error) +} + +// Run performs the full enroll → wait-for-claim → save-credentials flow. +// It returns when pairing succeeds, the user cancels (ctx done), or an +// error occurs. All progress is reported via cb. +func Run( + ctx context.Context, + log *logger.Logger, + configPath, panelURL, passphrase, displayName string, + cb Callbacks, +) { + panelURL = NormalizePanelURL(panelURL) + + kp, err := pairing.LoadOrCreate(pairing.DefaultKeyPath()) + if err != nil { + cb.OnError(fmt.Errorf("load keypair: %w", err)) + return + } + + collector := metrics.NewCollector(config.MetricsConfig{}, log) + infoCtx, infoCancel := context.WithTimeout(ctx, 8*time.Second) + sysInfo, _ := collector.GetSystemInfo(infoCtx) + infoCancel() + + sysMap := buildSystemInfo(sysInfo, displayName) + + client := pairing.NewClient(panelURL, log) + enrollCtx, enrollCancel := context.WithTimeout(ctx, 30*time.Second) + enrollResp, err := client.Enroll(enrollCtx, pairing.EnrollRequest{ + Pubkey: kp.PublicKeyHex(), + Passphrase: passphrase, + MachineID: config.MachineID(), + SystemInfo: sysMap, + }) + enrollCancel() + if err != nil { + cb.OnError(fmt.Errorf("enroll: %w", err)) + return + } + + cb.OnEnrolled(enrollResp.PairCode, enrollResp.PairCodeFormatted) + + creds, err := client.WaitForClaim(ctx, func(code, formatted, _ string) { + cb.OnEnrolled(code, formatted) + }) + if err != nil { + if ctx.Err() != nil { + return // user cancelled + } + cb.OnError(fmt.Errorf("waiting for claim: %w", err)) + return + } + + if err := saveCredentials(configPath, panelURL, creds); err != nil { + cb.OnError(fmt.Errorf("save credentials: %w", err)) + return + } + + cb.OnClaimed(creds.Name) +} + +// NormalizePanelURL strips trailing slashes and adds an https:// prefix +// when the user types a bare hostname. +func NormalizePanelURL(u string) string { + u = strings.TrimSpace(u) + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + u = "https://" + u + } + return strings.TrimSuffix(u, "/") +} + +func buildSystemInfo(sysInfo *metrics.SystemInfo, displayName string) map[string]interface{} { + m := map[string]interface{}{} + if sysInfo != nil { + m["hostname"] = sysInfo.Hostname + m["os"] = sysInfo.OS + m["platform"] = sysInfo.Platform + m["platform_version"] = sysInfo.PlatformVersion + m["architecture"] = sysInfo.Architecture + m["cpu_cores"] = sysInfo.CPUCores + m["total_memory"] = sysInfo.TotalMemory + m["total_disk"] = sysInfo.TotalDisk + } else { + hostname, _ := os.Hostname() + m["hostname"] = hostname + m["os"] = runtime.GOOS + m["architecture"] = runtime.GOARCH + } + if displayName = strings.TrimSpace(displayName); displayName != "" { + m["display_name"] = displayName + } + return m +} + +func saveCredentials(configPath, panelURL string, creds *pairing.Credentials) error { + cfg, err := config.Load(configPath) + if err != nil { + cfg = config.Default() + } + wsURL := strings.TrimSuffix(panelURL, "/") + wsURL = strings.Replace(wsURL, "https://", "wss://", 1) + wsURL = strings.Replace(wsURL, "http://", "ws://", 1) + cfg.Server.URL = wsURL + "/agent" + cfg.Agent.ID = creds.AgentID + cfg.Agent.Name = creds.Name + cfg.Auth.APIKey = creds.APIKey + cfg.Auth.APISecret = creds.APISecret + + if configPath == "" { + configPath = config.DefaultConfigPath() + } + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { + return err + } + if err := cfg.Save(configPath); err != nil { + return err + } + return cfg.SaveCredentials() +} diff --git a/agent/internal/pairing/client.go b/agent/internal/pairing/client.go new file mode 100644 index 00000000..c79c65ae --- /dev/null +++ b/agent/internal/pairing/client.go @@ -0,0 +1,242 @@ +package pairing + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/serverkit/agent/internal/logger" +) + +// EnrollRequest is the body of POST /api/v1/pairing/enroll. +type EnrollRequest struct { + Pubkey string `json:"pubkey"` + Passphrase string `json:"passphrase"` + MachineID string `json:"machine_id,omitempty"` + SystemInfo map[string]interface{} `json:"system_info,omitempty"` +} + +// EnrollResponse is what the panel returns on successful enroll. +type EnrollResponse struct { + EnrollmentID string `json:"enrollment_id"` + EnrollmentSecret string `json:"enrollment_secret"` + PairCode string `json:"pair_code"` + PairCodeFormatted string `json:"pair_code_formatted"` + PairCodeExpiresAt string `json:"pair_code_expires_at"` + ExpiresAt string `json:"expires_at"` + PubkeyFingerprint string `json:"pubkey_fpr"` +} + +// PollResponse is the body of GET /api/v1/pairing/poll. +type PollResponse struct { + Status string `json:"status"` // pending | claimed + PairCode string `json:"pair_code,omitempty"` + PairCodeExpiresAt string `json:"pair_code_expires_at,omitempty"` + Credentials *Credentials `json:"credentials,omitempty"` +} + +// Credentials are returned to the agent once an operator claims it. +type Credentials struct { + AgentID string `json:"agent_id"` + ServerID string `json:"server_id"` + Name string `json:"name"` + APIKey string `json:"api_key"` + APISecret string `json:"api_secret"` +} + +// RefreshResponse is returned from /code/refresh and /code/freeze. +type RefreshResponse struct { + PairCode string `json:"pair_code"` + PairCodeFormatted string `json:"pair_code_formatted"` + PairCodeExpiresAt string `json:"pair_code_expires_at"` + PairCodeFrozen bool `json:"pair_code_frozen"` +} + +// Client talks to the panel pairing API. +type Client struct { + BaseURL string + HTTP *http.Client + UserAgent string + EnrollmentID string + EnrollmentSecret string + log *logger.Logger +} + +// NewClient constructs a pairing client. baseURL must be the panel HTTPS root. +func NewClient(baseURL string, log *logger.Logger) *Client { + baseURL = strings.TrimSuffix(baseURL, "/") + return &Client{ + BaseURL: baseURL, + HTTP: &http.Client{ + Timeout: 60 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: os.Getenv("SERVERKIT_INSECURE_TLS") == "true", + }, + }, + }, + UserAgent: "ServerKit-Agent-Pairing/1.0", + log: log.WithComponent("pairing"), + } +} + +// Enroll submits the agent's pubkey + passphrase to obtain an enrollment + pair code. +func (c *Client) Enroll(ctx context.Context, req EnrollRequest) (*EnrollResponse, error) { + var resp EnrollResponse + if err := c.do(ctx, "POST", "/api/v1/pairing/enroll", req, nil, &resp); err != nil { + return nil, err + } + c.EnrollmentID = resp.EnrollmentID + c.EnrollmentSecret = resp.EnrollmentSecret + return &resp, nil +} + +// RotateCode requests a new pair code (no-op if frozen unless force=true). +func (c *Client) RotateCode(ctx context.Context, force bool) (*RefreshResponse, error) { + body := map[string]bool{"force": force} + var resp RefreshResponse + if err := c.do(ctx, "POST", "/api/v1/pairing/code/refresh", body, c.enrollHeaders(), &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// SetFreeze freezes or unfreezes the rotating pair code. +func (c *Client) SetFreeze(ctx context.Context, frozen bool) (*RefreshResponse, error) { + body := map[string]bool{"frozen": frozen} + var resp RefreshResponse + if err := c.do(ctx, "POST", "/api/v1/pairing/code/freeze", body, c.enrollHeaders(), &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// Poll waits up to ~30s for an operator to claim this enrollment. +// Returns (claimed, credentials, err). If err is nil and claimed is false the +// caller should reconnect immediately for another long-poll. +func (c *Client) Poll(ctx context.Context) (bool, *Credentials, error) { + var resp PollResponse + if err := c.do(ctx, "GET", "/api/v1/pairing/poll", nil, c.enrollHeaders(), &resp); err != nil { + return false, nil, err + } + if resp.Status == "claimed" && resp.Credentials != nil { + return true, resp.Credentials, nil + } + return false, nil, nil +} + +// WaitForClaim blocks until the agent is claimed, retrying long-polls and +// occasionally rotating the pair code (every ~5 minutes) so codes never get stale. +// +// onCode is invoked whenever the visible pair code changes — typically wired +// into the tray UI. It may be nil for headless operation. +func (c *Client) WaitForClaim(ctx context.Context, onCode func(code, formatted string, expiresAt string)) (*Credentials, error) { + codeRotateTicker := time.NewTicker(4 * time.Minute) + defer codeRotateTicker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-codeRotateTicker.C: + if rr, err := c.RotateCode(ctx, false); err == nil && onCode != nil { + onCode(rr.PairCode, formatPairCode(rr.PairCode), rr.PairCodeExpiresAt) + } + default: + } + + pollCtx, cancel := context.WithTimeout(ctx, 35*time.Second) + claimed, creds, err := c.Poll(pollCtx) + cancel() + if err != nil { + c.log.Warn("pairing poll failed; retrying", "error", err) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(5 * time.Second): + } + continue + } + if claimed { + return creds, nil + } + } +} + +// ----- internals ----- + +func (c *Client) enrollHeaders() http.Header { + h := http.Header{} + if c.EnrollmentID != "" { + h.Set("X-Enrollment-Id", c.EnrollmentID) + } + if c.EnrollmentSecret != "" { + h.Set("X-Enrollment-Secret", c.EnrollmentSecret) + } + return h +} + +func (c *Client) do(ctx context.Context, method, path string, body interface{}, extraHeaders http.Header, out interface{}) error { + url := c.BaseURL + path + + var bodyReader io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + bodyReader = bytes.NewReader(buf) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return fmt.Errorf("new request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.UserAgent) + for k, v := range extraHeaders { + for _, vv := range v { + req.Header.Add(k, vv) + } + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + var er struct { + Error string `json:"error"` + } + _ = json.Unmarshal(respBody, &er) + if er.Error != "" { + return fmt.Errorf("%s %s: %s (status %d)", method, path, er.Error, resp.StatusCode) + } + return fmt.Errorf("%s %s: status %d", method, path, resp.StatusCode) + } + if out != nil && len(respBody) > 0 { + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} + +func formatPairCode(code string) string { + if len(code) < 2 { + return code + } + half := len(code) / 2 + return code[:half] + "-" + code[half:] +} diff --git a/agent/internal/pairing/keypair.go b/agent/internal/pairing/keypair.go new file mode 100644 index 00000000..9f638bcd --- /dev/null +++ b/agent/internal/pairing/keypair.go @@ -0,0 +1,108 @@ +// Package pairing implements the RustDesk-style short-code agent pairing flow. +package pairing + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/serverkit/agent/internal/config" +) + +// KeyPair holds an Ed25519 keypair plus a short fingerprint string. +type KeyPair struct { + Public ed25519.PublicKey + Private ed25519.PrivateKey + Fingerprint string // matches panel format: uppercase hex, first 16 chars of SHA-256(pubkey_hex). +} + +// PublicKeyHex returns the public key as lowercase hex (64 chars). +func (k *KeyPair) PublicKeyHex() string { + return hex.EncodeToString(k.Public) +} + +// DefaultKeyPath returns the OS-specific default path for the pairing keyfile. +func DefaultKeyPath() string { + if runtime.GOOS == "windows" { + return filepath.Join(os.Getenv("ProgramData"), "ServerKit", "Agent", "pairing.key") + } + return "/etc/serverkit-agent/pairing.key" +} + +// LoadOrCreate returns the keypair at path, generating + persisting one if +// no file exists. The file is encrypted with the machine-derived key. +func LoadOrCreate(path string) (*KeyPair, error) { + if path == "" { + path = DefaultKeyPath() + } + if _, err := os.Stat(path); err == nil { + return Load(path) + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("stat key file: %w", err) + } + kp, err := Generate() + if err != nil { + return nil, err + } + if err := Save(kp, path); err != nil { + return nil, err + } + return kp, nil +} + +// Generate creates a new random Ed25519 keypair. +func Generate() (*KeyPair, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate keypair: %w", err) + } + return &KeyPair{Public: pub, Private: priv, Fingerprint: fingerprint(pub)}, nil +} + +// Load reads + decrypts a keypair file. +func Load(path string) (*KeyPair, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read key file: %w", err) + } + plain, err := config.DecryptBytes(data) + if err != nil { + return nil, fmt.Errorf("decrypt key file: %w", err) + } + if len(plain) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid key file size: %d", len(plain)) + } + priv := ed25519.PrivateKey(plain) + pub := priv.Public().(ed25519.PublicKey) + return &KeyPair{Public: pub, Private: priv, Fingerprint: fingerprint(pub)}, nil +} + +// Save encrypts and writes the keypair to disk with restricted permissions. +func Save(kp *KeyPair, path string) error { + if path == "" { + path = DefaultKeyPath() + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("create key dir: %w", err) + } + encrypted, err := config.EncryptBytes(kp.Private) + if err != nil { + return fmt.Errorf("encrypt key: %w", err) + } + if err := os.WriteFile(path, encrypted, 0600); err != nil { + return fmt.Errorf("write key file: %w", err) + } + return nil +} + +func fingerprint(pub ed25519.PublicKey) string { + pubHex := hex.EncodeToString(pub) + digest := sha256.Sum256([]byte(pubHex)) + return strings.ToUpper(hex.EncodeToString(digest[:8])) +} diff --git a/agent/internal/setupui/darkmode_windows.go b/agent/internal/setupui/darkmode_windows.go new file mode 100644 index 00000000..08ccf12c --- /dev/null +++ b/agent/internal/setupui/darkmode_windows.go @@ -0,0 +1,40 @@ +//go:build windows + +package setupui + +import ( + "syscall" + "unsafe" + + "github.com/lxn/walk" +) + +// enableDarkMode flips the window's title bar to the dark colour scheme on +// Windows 10/11 and registers the process as dark-mode aware so native +// controls (Edit, Button, ScrollBar) follow suit on Win11. +// +// Belt-and-braces: try the documented Win11 attribute (20), fall back to the +// undocumented Win10 1903+ value (19); call SetPreferredAppMode via its +// uxtheme ordinal export. All calls are best-effort and silently ignored on +// older builds where the API isn't present. +func enableDarkMode(form walk.Form) { + if form == nil { + return + } + hwnd := form.Handle() + if hwnd == 0 { + return + } + + dwmapi := syscall.NewLazyDLL("dwmapi.dll") + setAttr := dwmapi.NewProc("DwmSetWindowAttribute") + var enable int32 = 1 + setAttr.Call(uintptr(hwnd), 20, uintptr(unsafe.Pointer(&enable)), 4) + setAttr.Call(uintptr(hwnd), 19, uintptr(unsafe.Pointer(&enable)), 4) + + uxtheme := syscall.NewLazyDLL("uxtheme.dll") + // SetPreferredAppMode(AllowDark = 1). Ordinal #135 on Win10 1903+. + if proc := uxtheme.NewProc("SetPreferredAppMode"); proc != nil && proc.Find() == nil { + proc.Call(1) + } +} diff --git a/agent/internal/setupui/foreground_windows.go b/agent/internal/setupui/foreground_windows.go new file mode 100644 index 00000000..5535833f --- /dev/null +++ b/agent/internal/setupui/foreground_windows.go @@ -0,0 +1,124 @@ +//go:build windows + +package setupui + +import ( + "syscall" + "time" + "unsafe" + + "github.com/lxn/walk" +) + +// forceForegroundWindow uses the documented AttachThreadInput recipe to bring +// a freshly-created walk window to the front, working around Windows' +// anti-focus-stealing rules. +// +// The plain TopMost-toggle trick is unreliable: it changes the Z-order but +// Windows still treats the activation as a focus-steal attempt and silently +// drops it on Windows 10/11 if our process isn't already in the foreground. +// AttachThreadInput briefly merges our input queue with the current +// foreground thread's, which Windows treats as the same UI session — and so +// SetForegroundWindow goes through. +func forceForegroundWindow(form walk.Form) { + if form == nil { + return + } + hwnd := uintptr(form.Handle()) + if hwnd == 0 { + return + } + + user32 := syscall.NewLazyDLL("user32.dll") + kernel32 := syscall.NewLazyDLL("kernel32.dll") + + showWindow := user32.NewProc("ShowWindow") + setForegroundWindow := user32.NewProc("SetForegroundWindow") + setFocus := user32.NewProc("SetFocus") + setActiveWindow := user32.NewProc("SetActiveWindow") + bringWindowToTop := user32.NewProc("BringWindowToTop") + getForegroundWindow := user32.NewProc("GetForegroundWindow") + getWindowThreadProcessId := user32.NewProc("GetWindowThreadProcessId") + getCurrentThreadId := kernel32.NewProc("GetCurrentThreadId") + attachThreadInput := user32.NewProc("AttachThreadInput") + setWindowPos := user32.NewProc("SetWindowPos") + keybdEvent := user32.NewProc("keybd_event") + allowSetForegroundWindow := user32.NewProc("AllowSetForegroundWindow") + + const ( + swRestore = 9 + hwndTopmost = ^uintptr(0) // -1 + hwndNotTopmost = ^uintptr(0) - 1 // -2 + swpNoSize = 0x0001 + swpNoMove = 0x0002 + swpShowWindow = 0x0040 + keyEventfKeyUp = 0x0002 + ASFW_ANY = ^uint32(0) // -1 + ) + + // (1) Allow ourselves to be the foreground for the next operation, + // regardless of who's calling. -1 = ASFW_ANY. + allowSetForegroundWindow.Call(uintptr(ASFW_ANY)) + + // (2) Make sure the window is at least restored from any minimized state. + showWindow.Call(hwnd, swRestore) + + // (3) Simulate a no-op keypress. Windows treats keyboard input as user + // activity and grants the receiving thread foreground privilege — + // even on Win10/11 where SetForegroundWindow is otherwise denied. + keybdEvent.Call(0, 0, 0, 0) + keybdEvent.Call(0, 0, keyEventfKeyUp, 0) + + // (4) AttachThreadInput trick: temporarily fuse our input queue with the + // current foreground thread's so SetForegroundWindow is treated as + // coming from the same UI session. + fgHwnd, _, _ := getForegroundWindow.Call() + var fgPid uint32 + fgThread, _, _ := getWindowThreadProcessId.Call(fgHwnd, uintptr(unsafe.Pointer(&fgPid))) + ourThread, _, _ := getCurrentThreadId.Call() + + attached := false + if fgThread != 0 && fgThread != ourThread { + r, _, _ := attachThreadInput.Call(fgThread, ourThread, 1) + attached = r != 0 + } + + // (5) The full activation sequence: Z-order, foreground, active, focus. + bringWindowToTop.Call(hwnd) + setWindowPos.Call(hwnd, hwndTopmost, 0, 0, 0, 0, + uintptr(swpNoMove|swpNoSize|swpShowWindow)) + setForegroundWindow.Call(hwnd) + setActiveWindow.Call(hwnd) + setFocus.Call(hwnd) + setWindowPos.Call(hwnd, hwndNotTopmost, 0, 0, 0, 0, + uintptr(swpNoMove|swpNoSize|swpShowWindow)) + + if attached { + attachThreadInput.Call(fgThread, ourThread, 0) + } +} + +// scheduleForceForeground fires the activation recipe several times across +// the first ~1.5 seconds of window life. The first attempt usually races +// the message loop start; later attempts win once the pump is servicing. +// Cheap and idempotent. +func scheduleForceForeground(form walk.Form) { + if form == nil { + return + } + go func() { + for _, d := range []time.Duration{ + 0, + 50 * time.Millisecond, + 150 * time.Millisecond, + 400 * time.Millisecond, + 900 * time.Millisecond, + 1500 * time.Millisecond, + } { + time.Sleep(d) + form.Synchronize(func() { + forceForegroundWindow(form) + }) + } + }() +} diff --git a/agent/internal/setupui/serverkit.ico b/agent/internal/setupui/serverkit.ico new file mode 100644 index 00000000..e6f126db Binary files /dev/null and b/agent/internal/setupui/serverkit.ico differ diff --git a/agent/internal/setupui/serverkit_header.png b/agent/internal/setupui/serverkit_header.png new file mode 100644 index 00000000..d3484310 Binary files /dev/null and b/agent/internal/setupui/serverkit_header.png differ diff --git a/agent/internal/setupui/service_other.go b/agent/internal/setupui/service_other.go new file mode 100644 index 00000000..1ce2fb5e --- /dev/null +++ b/agent/internal/setupui/service_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package setupui + +func startServiceIfInstalled() {} diff --git a/agent/internal/setupui/service_windows.go b/agent/internal/setupui/service_windows.go new file mode 100644 index 00000000..daf57be7 --- /dev/null +++ b/agent/internal/setupui/service_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package setupui + +import ( + "os/exec" + "syscall" +) + +// startServiceIfInstalled flips the ServerKitAgent service to auto-start +// and starts it. Best-effort: silently ignores errors (e.g. service not +// installed, no admin rights). +func startServiceIfInstalled() { + run := func(args ...string) { + cmd := exec.Command("sc.exe", args...) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + _ = cmd.Run() + } + run("config", "ServerKitAgent", "start=", "auto") + run("start", "ServerKitAgent") +} diff --git a/agent/internal/setupui/setupui.go b/agent/internal/setupui/setupui.go new file mode 100644 index 00000000..5fdbc545 --- /dev/null +++ b/agent/internal/setupui/setupui.go @@ -0,0 +1,20 @@ +// Package setupui shows the legacy native-walk pairing wizard. The pairing +// protocol itself moved to internal/pairdriver so both this wizard and the +// React-based agentui console can drive it. +package setupui + +import ( + "context" + + "github.com/serverkit/agent/internal/config" + "github.com/serverkit/agent/internal/logger" +) + +// Run shows the pairing wizard and blocks until the user closes it. +// configPath may be empty to use the default location. +func Run(ctx context.Context, log *logger.Logger, configPath string) error { + if configPath == "" { + configPath = config.DefaultConfigPath() + } + return runWindow(ctx, log.WithComponent("setupui"), configPath) +} diff --git a/agent/internal/setupui/theme_windows.go b/agent/internal/setupui/theme_windows.go new file mode 100644 index 00000000..21d25eab --- /dev/null +++ b/agent/internal/setupui/theme_windows.go @@ -0,0 +1,99 @@ +//go:build windows + +package setupui + +import ( + "github.com/lxn/walk" + dec "github.com/lxn/walk/declarative" + "golang.org/x/sys/windows/registry" +) + +// palette holds the colour tokens used by the wizard. We carry separate +// light/dark instances and pick at startup based on the user's Windows +// theme, mirroring the panel's own light/dark behaviour. +type palette struct { + dark bool + + bgDeep uint32 // outer window background + bgCard uint32 // card surface + textHeading uint32 // section titles + textBody uint32 // descriptive body + textLabel uint32 // form labels + textMuted uint32 // subtitles + textHelper uint32 // helper text under inputs / footer + indigo uint32 // brand accent (button text + pair code) + errorRed uint32 + successGr uint32 + + // Brushes derived from the colours above. Cached to avoid recomputing + // the same SolidColorBrush thousands of times during widget construction. + brushBgDeep dec.Brush + brushBgCard dec.Brush +} + +func newPalette(dark bool) palette { + if dark { + p := palette{ + dark: true, + bgDeep: 0x09090B, + bgCard: 0x121214, + textHeading: 0xFFFFFF, + textBody: 0xEDEDED, + textLabel: 0xE4E4E7, + textMuted: 0xA1A1AA, + textHelper: 0x71717A, + indigo: 0x818CF8, // slightly lifted for contrast on dark bg + errorRed: 0xF87171, + successGr: 0x34D399, + } + p.brushBgDeep = dec.SolidColorBrush{Color: rgbHex(p.bgDeep)} + p.brushBgCard = dec.SolidColorBrush{Color: rgbHex(p.bgCard)} + return p + } + p := palette{ + dark: false, + bgDeep: 0xF1F5F9, // slate-100, gentler than pure white + bgCard: 0xFFFFFF, + textHeading: 0x111827, + textBody: 0x1F2937, + textLabel: 0x374151, + textMuted: 0x6B7280, + textHelper: 0x9CA3AF, + indigo: 0x4F46E5, + errorRed: 0xDC2626, + successGr: 0x059669, + } + p.brushBgDeep = dec.SolidColorBrush{Color: rgbHex(p.bgDeep)} + p.brushBgCard = dec.SolidColorBrush{Color: rgbHex(p.bgCard)} + return p +} + +// detectThemePalette currently always returns the light palette regardless +// of the user's Windows theme preference. Walk's native LineEdit and +// PushButton controls don't reliably respect dark mode (the white-input-on- +// dark-card contrast looks broken), so until the wizard is ported to Wails +// we keep a single, consistent light palette. The registry-reading path is +// preserved below in case we want to flip this back when the controls +// behave. +func detectThemePalette() palette { + return newPalette(false) +} + +// systemPrefersDark is the would-be feature toggle we'd hand detectThemePalette +// once we actually have themable inputs (i.e. after the Wails port). +func systemPrefersDark() bool { + k, err := registry.OpenKey(registry.CURRENT_USER, + `Software\Microsoft\Windows\CurrentVersion\Themes\Personalize`, + registry.QUERY_VALUE) + if err != nil { + return false + } + defer k.Close() + v, _, err := k.GetIntegerValue("AppsUseLightTheme") + if err != nil { + return false + } + return v == 0 +} + +func (p palette) Color(c uint32) walk.Color { return rgbHex(c) } diff --git a/agent/internal/setupui/window_other.go b/agent/internal/setupui/window_other.go new file mode 100644 index 00000000..27f13217 --- /dev/null +++ b/agent/internal/setupui/window_other.go @@ -0,0 +1,16 @@ +//go:build !windows + +package setupui + +import ( + "context" + "fmt" + + "github.com/serverkit/agent/internal/logger" +) + +// runWindow returns an error directing non-Windows users to the CLI command. +// The desktop wizard is Windows-only because it uses native Win32 controls. +func runWindow(_ context.Context, _ *logger.Logger, _ string) error { + return fmt.Errorf("the graphical setup wizard is only available on Windows; on this platform run: serverkit-agent pair") +} diff --git a/agent/internal/setupui/window_windows.go b/agent/internal/setupui/window_windows.go new file mode 100644 index 00000000..e079cd3d --- /dev/null +++ b/agent/internal/setupui/window_windows.go @@ -0,0 +1,698 @@ +//go:build windows + +package setupui + +import ( + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "image/png" + "os" + "runtime" + "strings" + "sync" + "time" + + "github.com/lxn/walk" + dec "github.com/lxn/walk/declarative" + + "github.com/serverkit/agent/internal/logger" + "github.com/serverkit/agent/internal/pairdriver" +) + +//go:embed serverkit.ico +var iconBytes []byte + +//go:embed serverkit_header.png +var headerPNG []byte + +// stage names (used as panel keys for clarity) +const ( + stageForm = "form" + stagePairing = "pairing" + stageDone = "done" +) + +// runWindow shows the native pairing wizard. It blocks until the user closes +// the window (success, cancel, or X-button). Returns nil on graceful close. +// +// Walk's MainWindow event loop must run on the OS main thread; the caller +// (cmd/agent.runDesktop) is responsible for runtime.LockOSThread before us. +func runWindow(ctx context.Context, log *logger.Logger, configPath string) (retErr error) { + runtime.LockOSThread() + + // Surface panics as a real error dialog so a misconfigured widget can + // never produce the silent-failure mode that motivated this rewrite. + defer func() { + if r := recover(); r != nil { + retErr = fmt.Errorf("setup wizard panic: %v", r) + walk.MsgBox(nil, "ServerKit Agent", retErr.Error(), walk.MsgBoxIconError) + } + }() + + w := &wizardUI{ + log: log, + configPath: configPath, + } + + // Hook ctx cancellation into the wizard: if the parent ctx is cancelled + // (e.g. SIGINT) we close the window from a goroutine. + w.runCtx, w.cancel = context.WithCancel(ctx) + defer w.cancel() + + go func() { + <-w.runCtx.Done() + if w.mw != nil { + w.mw.Synchronize(func() { + if w.mw != nil { + w.mw.Close() + } + }) + } + }() + + return w.show() +} + +type wizardUI struct { + log *logger.Logger + configPath string + + runCtx context.Context + cancel context.CancelFunc + + mu sync.Mutex + pairCancel context.CancelFunc + + mw *walk.MainWindow + + headerImage *walk.ImageView + + // stage 1 widgets + urlEdit *walk.LineEdit + nameEdit *walk.LineEdit + formErr *walk.Label + startBtn *walk.PushButton + + // stage 2 widgets + codeLabel *walk.Label + passLabel *walk.Label + copyCodeBtn *walk.PushButton + copyPassBtn *walk.PushButton + statusLabel *walk.Label + pairErrLabel *walk.Label + cancelBtn *walk.PushButton + + // raw values backing the copy buttons (codeLabel renders a spaced version) + rawCode string + rawPass string + + // stage 3 widgets + doneTitle *walk.Label + doneSub *walk.Label + + // stage panels + formPanel *walk.Composite + pairPanel *walk.Composite + donePanel *walk.Composite +} + +func (w *wizardUI) show() error { + hostname, _ := os.Hostname() + + // Match the user's Windows theme so the wizard sits naturally on top of + // whatever the rest of their desktop looks like. The panel's own UI is + // light/dark adaptive; the wizard mirrors that. + pal := detectThemePalette() + + const ( + titleSize = 14 + bodySize = 9 + smallSize = 8 + codeSize = 32 + ) + + bgDeepBrush := pal.brushBgDeep + bgCardBrush := pal.brushBgCard + + textWhite := pal.textHeading + textPrimary := pal.textBody + textLabel := pal.textLabel + textMuted := pal.textMuted + textHelper := pal.textHelper + indigo := pal.indigo + errorRed := pal.errorRed + successGr := pal.successGr + + err := (dec.MainWindow{ + AssignTo: &w.mw, + Title: "ServerKit Agent", + MinSize: dec.Size{Width: 460, Height: 600}, + Size: dec.Size{Width: 460, Height: 600}, + Layout: dec.VBox{MarginsZero: false, Margins: dec.Margins{Left: 24, Top: 24, Right: 24, Bottom: 24}, Spacing: 0}, + Background: bgDeepBrush, + Children: []dec.Widget{ + // ── Card surface (lighter than outer bg, simulates the .agent-window block) ── + dec.Composite{ + Layout: dec.VBox{MarginsZero: false, Margins: dec.Margins{Left: 24, Top: 24, Right: 24, Bottom: 24}, Spacing: 0}, + Background: bgCardBrush, + Children: []dec.Widget{ + // ── Brand header ───────────────────────────────────────── + dec.Composite{ + Layout: dec.HBox{MarginsZero: true, Spacing: 16}, + Background: bgCardBrush, + Children: []dec.Widget{ + dec.ImageView{ + AssignTo: &w.headerImage, + MinSize: dec.Size{Width: 48, Height: 48}, + MaxSize: dec.Size{Width: 48, Height: 48}, + Mode: dec.ImageViewModeZoom, + Background: bgCardBrush, + }, + dec.Composite{ + Layout: dec.VBox{MarginsZero: true, Spacing: 4}, + Background: bgCardBrush, + Children: []dec.Widget{ + dec.VSpacer{}, + dec.Label{ + Text: "Pair this server", + Font: dec.Font{PointSize: titleSize, Bold: true, Family: "Segoe UI"}, + TextColor: rgbHex(textWhite), + Background: bgCardBrush, + }, + dec.Label{ + Text: "Connect this machine to your ServerKit panel.", + Font: dec.Font{PointSize: bodySize, Family: "Segoe UI"}, + TextColor: rgbHex(textMuted), + Background: bgCardBrush, + }, + dec.VSpacer{}, + }, + }, + }, + }, + + dec.VSpacer{Size: 28}, + + // ── Stage 1: connection form ───────────────────────────── + dec.Composite{ + AssignTo: &w.formPanel, + Layout: dec.VBox{MarginsZero: true, Spacing: 0}, + Background: bgCardBrush, + Children: []dec.Widget{ + // Panel URL + dec.Label{ + Text: "Panel URL", + Font: dec.Font{Bold: true, PointSize: bodySize, Family: "Segoe UI"}, + TextColor: rgbHex(textLabel), + Background: bgCardBrush, + }, + dec.VSpacer{Size: 6}, + dec.LineEdit{ + AssignTo: &w.urlEdit, + CueBanner: "https://panel.example.com", + MaxLength: 500, + MinSize: dec.Size{Height: 28}, + }, + dec.VSpacer{Size: 6}, + dec.Label{ + Text: "The full URL of your ServerKit control panel.", + Font: dec.Font{PointSize: smallSize, Family: "Segoe UI"}, + TextColor: rgbHex(textHelper), + Background: bgCardBrush, + }, + + dec.VSpacer{Size: 20}, + + // Server name + dec.Label{ + Text: "Server name (optional)", + Font: dec.Font{Bold: true, PointSize: bodySize, Family: "Segoe UI"}, + TextColor: rgbHex(textLabel), + Background: bgCardBrush, + }, + dec.VSpacer{Size: 6}, + dec.LineEdit{ + AssignTo: &w.nameEdit, + Text: hostname, + CueBanner: "Defaults to hostname", + MinSize: dec.Size{Height: 28}, + }, + dec.VSpacer{Size: 6}, + dec.Label{ + Text: "How this machine appears in your panel.", + Font: dec.Font{PointSize: smallSize, Family: "Segoe UI"}, + TextColor: rgbHex(textHelper), + Background: bgCardBrush, + }, + + dec.VSpacer{Size: 20}, + + dec.Label{ + AssignTo: &w.formErr, + TextColor: rgbHex(errorRed), + Font: dec.Font{PointSize: bodySize, Family: "Segoe UI"}, + Background: bgCardBrush, + Text: "", + }, + dec.PushButton{ + AssignTo: &w.startBtn, + Text: "Connect →", + MinSize: dec.Size{Height: 36}, + Font: dec.Font{PointSize: bodySize, Bold: true, Family: "Segoe UI"}, + OnClicked: w.handleConnect, + }, + }, + }, + + // ── Stage 2: pairing code (hidden initially) ────────────── + // Mirrors the panel's "Add Server → Pair existing agent" drawer: + // the user reads the code here and types it into that field. + dec.Composite{ + AssignTo: &w.pairPanel, + Visible: false, + Layout: dec.VBox{MarginsZero: true, Spacing: 0}, + Background: bgCardBrush, + Children: []dec.Widget{ + dec.Label{ + Text: "On your panel, open Add Server → Pair existing agent.", + Font: dec.Font{PointSize: bodySize, Family: "Segoe UI"}, + TextColor: rgbHex(textPrimary), + Background: bgCardBrush, + }, + dec.VSpacer{Size: 4}, + dec.Label{ + Text: "Enter both values in the Pair code and Passphrase fields:", + Font: dec.Font{PointSize: bodySize, Family: "Segoe UI"}, + TextColor: rgbHex(textMuted), + Background: bgCardBrush, + }, + dec.VSpacer{Size: 20}, + dec.Label{ + Text: "Pair code", + Font: dec.Font{Bold: true, PointSize: smallSize, Family: "Segoe UI"}, + TextColor: rgbHex(textLabel), + Background: bgCardBrush, + TextAlignment: dec.AlignCenter, + }, + dec.VSpacer{Size: 6}, + // Code "field" — visually echoes the panel's letter-spaced + // input. We can't render a real bordered box, but a + // centered Composite with the card colour at least + // gives the code its own dedicated zone. + dec.Composite{ + Layout: dec.HBox{MarginsZero: true}, + Background: bgCardBrush, + Children: []dec.Widget{ + dec.HSpacer{}, + dec.Label{ + AssignTo: &w.codeLabel, + Text: "", + Font: dec.Font{PointSize: codeSize, Bold: true, Family: "Consolas"}, + TextColor: rgbHex(indigo), + Background: bgCardBrush, + TextAlignment: dec.AlignCenter, + }, + dec.HSpacer{}, + }, + }, + dec.VSpacer{Size: 8}, + dec.Composite{ + Layout: dec.HBox{MarginsZero: true}, + Background: bgCardBrush, + Children: []dec.Widget{ + dec.HSpacer{}, + dec.PushButton{ + AssignTo: &w.copyCodeBtn, + Text: "Copy", + MinSize: dec.Size{Width: 90, Height: 26}, + MaxSize: dec.Size{Width: 90, Height: 26}, + Font: dec.Font{PointSize: smallSize, Family: "Segoe UI"}, + OnClicked: w.copyCode, + }, + dec.HSpacer{}, + }, + }, + dec.VSpacer{Size: 18}, + dec.Label{ + Text: "Passphrase", + Font: dec.Font{Bold: true, PointSize: smallSize, Family: "Segoe UI"}, + TextColor: rgbHex(textLabel), + Background: bgCardBrush, + TextAlignment: dec.AlignCenter, + }, + dec.VSpacer{Size: 6}, + dec.Composite{ + Layout: dec.HBox{MarginsZero: true}, + Background: bgCardBrush, + Children: []dec.Widget{ + dec.HSpacer{}, + dec.Label{ + AssignTo: &w.passLabel, + Text: "", + Font: dec.Font{PointSize: 18, Bold: true, Family: "Consolas"}, + TextColor: rgbHex(textPrimary), + Background: bgCardBrush, + TextAlignment: dec.AlignCenter, + }, + dec.HSpacer{}, + }, + }, + dec.VSpacer{Size: 8}, + dec.Composite{ + Layout: dec.HBox{MarginsZero: true}, + Background: bgCardBrush, + Children: []dec.Widget{ + dec.HSpacer{}, + dec.PushButton{ + AssignTo: &w.copyPassBtn, + Text: "Copy", + MinSize: dec.Size{Width: 90, Height: 26}, + MaxSize: dec.Size{Width: 90, Height: 26}, + Font: dec.Font{PointSize: smallSize, Family: "Segoe UI"}, + OnClicked: w.copyPass, + }, + dec.HSpacer{}, + }, + }, + dec.VSpacer{Size: 20}, + dec.Label{ + AssignTo: &w.statusLabel, + Text: "Waiting for the panel to claim this server…", + Font: dec.Font{PointSize: bodySize, Family: "Segoe UI"}, + TextColor: rgbHex(textHelper), + Background: bgCardBrush, + TextAlignment: dec.AlignCenter, + }, + dec.Label{ + AssignTo: &w.pairErrLabel, + TextColor: rgbHex(errorRed), + Font: dec.Font{PointSize: bodySize, Family: "Segoe UI"}, + Background: bgCardBrush, + Text: "", + }, + dec.VSpacer{Size: 20}, + dec.PushButton{ + AssignTo: &w.cancelBtn, + Text: "Cancel", + MinSize: dec.Size{Height: 34}, + Font: dec.Font{PointSize: bodySize, Family: "Segoe UI"}, + OnClicked: w.handleCancel, + }, + }, + }, + + // ── Stage 3: success ───────────────────────────────────── + dec.Composite{ + AssignTo: &w.donePanel, + Visible: false, + Layout: dec.VBox{MarginsZero: true, Spacing: 10}, + Background: bgCardBrush, + Children: []dec.Widget{ + dec.Label{ + AssignTo: &w.doneTitle, + Text: "Successfully paired", + Font: dec.Font{PointSize: titleSize, Bold: true, Family: "Segoe UI"}, + TextColor: rgbHex(successGr), + Background: bgCardBrush, + }, + dec.Label{ + AssignTo: &w.doneSub, + Text: "", + Font: dec.Font{PointSize: bodySize, Family: "Segoe UI"}, + TextColor: rgbHex(textPrimary), + Background: bgCardBrush, + }, + dec.VSpacer{Size: 8}, + dec.PushButton{ + Text: "Close", + MinSize: dec.Size{Height: 36}, + Font: dec.Font{PointSize: bodySize, Bold: true, Family: "Segoe UI"}, + OnClicked: func() { w.mw.Close() }, + }, + }, + }, + + dec.VSpacer{}, + + // Subtle in-card footer mirrors the panel's brand strip. + dec.Composite{ + Layout: dec.HBox{MarginsZero: true}, + Background: bgCardBrush, + Children: []dec.Widget{ + dec.HSpacer{}, + dec.Label{ + Text: "ServerKit • Pairing wizard", + Font: dec.Font{PointSize: smallSize, Family: "Segoe UI"}, + TextColor: rgbHex(textHelper), + Background: bgCardBrush, + }, + dec.HSpacer{}, + }, + }, + }, + }, + }, + }).Create() + if err != nil { + return fmt.Errorf("create window: %w", err) + } + + if icon, err := loadAppIcon(); err == nil && icon != nil { + _ = w.mw.SetIcon(icon) + } + if bmp, err := loadHeaderBitmap(); err == nil && bmp != nil && w.headerImage != nil { + _ = w.headerImage.SetImage(bmp) + } + + // Title-bar chrome stays system-default; we're light-themed everywhere + // for now to avoid the dark-card / white-input contrast clash. + w.mw.Show() + // Windows' anti-focus-stealing rule will leave the window invisible behind + // the desktop when the agent is launched from the Start menu shortcut. + // scheduleForceForeground retries the AttachThreadInput-based activation + // recipe several times across the first ~1.5s of window life so we win + // regardless of when walk's message pump actually starts servicing. + scheduleForceForeground(w.mw) + w.mw.Run() + + w.mu.Lock() + if w.pairCancel != nil { + w.pairCancel() + } + w.mu.Unlock() + + return nil +} + +// handleConnect is fired by the "Connect" button. Validates inputs, kicks off +// pairing in a goroutine, and transitions to the pairing stage on success. +func (w *wizardUI) handleConnect() { + url := w.urlEdit.Text() + name := w.nameEdit.Text() + + if url == "" { + w.formErr.SetText("Panel URL is required.") + return + } + pass, err := pairdriver.GeneratePassphrase() + if err != nil { + w.formErr.SetText("Could not generate a passphrase: " + err.Error()) + return + } + w.rawPass = pass + w.formErr.SetText("") + w.startBtn.SetEnabled(false) + w.startBtn.SetText("Connecting…") + + pairCtx, cancel := context.WithCancel(w.runCtx) + w.mu.Lock() + w.pairCancel = cancel + w.mu.Unlock() + + cb := pairdriver.Callbacks{ + OnEnrolled: func(code, formatted string) { + w.mw.Synchronize(func() { + w.rawCode = code + w.codeLabel.SetText(displayCode(code, formatted)) + w.passLabel.SetText(pass) + w.showStage(stagePairing) + }) + }, + OnClaimed: func(serverName string) { + w.mw.Synchronize(func() { + if serverName == "" { + serverName = "this server" + } + w.doneSub.SetText(fmt.Sprintf("This machine is now connected to ServerKit as %q.\n\nThe agent service has been started in the background. You can close this window.", serverName)) + w.showStage(stageDone) + }) + startServiceIfInstalled() + }, + OnError: func(err error) { + w.mw.Synchronize(func() { + msg := errorPretty(err) + if w.pairPanel.Visible() { + w.pairErrLabel.SetText(msg) + w.cancelBtn.SetText("Try again") + } else { + w.formErr.SetText(msg) + w.startBtn.SetEnabled(true) + w.startBtn.SetText("Connect") + } + }) + }, + } + + go pairdriver.Run(pairCtx, w.log, w.configPath, url, pass, name, cb) +} + +// handleCancel rolls back to the form stage and cancels any in-flight pairing. +func (w *wizardUI) handleCancel() { + w.mu.Lock() + if w.pairCancel != nil { + w.pairCancel() + w.pairCancel = nil + } + w.mu.Unlock() + + w.formErr.SetText("") + w.pairErrLabel.SetText("") + w.codeLabel.SetText("") + w.passLabel.SetText("") + w.copyCodeBtn.SetText("Copy") + w.copyPassBtn.SetText("Copy") + w.rawCode = "" + w.rawPass = "" + w.cancelBtn.SetText("Cancel") + w.startBtn.SetEnabled(true) + w.startBtn.SetText("Connect") + w.showStage(stageForm) +} + +// copyCode / copyPass place the raw value on the clipboard and flash the +// button label so the user has visual confirmation that the click landed. +func (w *wizardUI) copyCode() { w.copyToClipboard(w.rawCode, w.copyCodeBtn) } +func (w *wizardUI) copyPass() { w.copyToClipboard(w.rawPass, w.copyPassBtn) } + +func (w *wizardUI) copyToClipboard(value string, btn *walk.PushButton) { + if value == "" || btn == nil { + return + } + if cb := walk.Clipboard(); cb != nil { + _ = cb.SetText(value) + } + btn.SetText("Copied!") + time.AfterFunc(1500*time.Millisecond, func() { + if w.mw == nil { + return + } + w.mw.Synchronize(func() { + if btn != nil { + btn.SetText("Copy") + } + }) + }) +} + +func (w *wizardUI) showStage(stage string) { + w.formPanel.SetVisible(stage == stageForm) + w.pairPanel.SetVisible(stage == stagePairing) + w.donePanel.SetVisible(stage == stageDone) +} + +// errorPretty turns errors from runPairing into a user-friendly one-liner. +func errorPretty(err error) string { + if err == nil { + return "" + } + if errors.Is(err, context.Canceled) { + return "Cancelled." + } + return err.Error() +} + +// displayCode renders the pair code with letter-spacing, mirroring the +// panel's "Add Server" input field which uses CSS letter-spacing: 0.5em on +// the same code. Walk Labels don't support letter-spacing as a property, so +// we fake it by inserting two-space gaps between glyphs of the unformatted +// code. Result for code "AB12CD" looks like "A B 1 2 C D". +func displayCode(code, formatted string) string { + raw := code + if raw == "" { + raw = formatted + } + clean := strings.ReplaceAll(raw, "-", "") + clean = strings.ReplaceAll(clean, " ", "") + if clean == "" { + return "" + } + var b strings.Builder + for i, r := range clean { + if i > 0 { + b.WriteString(" ") + } + b.WriteRune(r) + } + return b.String() +} + +func rgbHex(rgb uint32) walk.Color { + return walk.RGB(byte(rgb>>16), byte(rgb>>8), byte(rgb)) +} + +// loadAppIcon writes the embedded .ico to a temp file (walk doesn't load icons +// from raw bytes) and returns it. The temp file lingers until process exit; +// Windows handles cleanup of %TEMP%. +var ( + iconOnce sync.Once + iconRef *walk.Icon +) + +func loadAppIcon() (*walk.Icon, error) { + iconOnce.Do(func() { + f, err := os.CreateTemp("", "serverkit-*.ico") + if err != nil { + return + } + _, _ = f.Write(iconBytes) + _ = f.Close() + ic, err := walk.NewIconFromFile(f.Name()) + if err != nil { + return + } + iconRef = ic + }) + if iconRef == nil { + return nil, fmt.Errorf("icon not loaded") + } + return iconRef, nil +} + +// loadHeaderBitmap decodes the embedded brand PNG into a walk Bitmap suitable +// for an ImageView. Must be called after the walk runtime is up (i.e. after +// MainWindow.Create), otherwise the underlying GDI calls return errors. +var ( + headerOnce sync.Once + headerBmp *walk.Bitmap +) + +func loadHeaderBitmap() (*walk.Bitmap, error) { + headerOnce.Do(func() { + img, err := png.Decode(bytes.NewReader(headerPNG)) + if err != nil { + return + } + bmp, err := walk.NewBitmapFromImageForDPI(img, 96) + if err != nil { + return + } + headerBmp = bmp + }) + if headerBmp == nil { + return nil, fmt.Errorf("header bitmap not loaded") + } + return headerBmp, nil +} diff --git a/agent/internal/transport/manager.go b/agent/internal/transport/manager.go new file mode 100644 index 00000000..2dc48198 --- /dev/null +++ b/agent/internal/transport/manager.go @@ -0,0 +1,282 @@ +package transport + +import ( + "context" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/serverkit/agent/internal/auth" + "github.com/serverkit/agent/internal/logger" + "github.com/serverkit/agent/pkg/protocol" +) + +// wsBackend is the slice of ws.Client functionality the manager needs. +// We avoid importing ws here to keep the dependency graph clean — the +// agent package wires concrete clients in via NewManager. +type wsBackend interface { + Transport + LastError() error + EverConnected() bool +} + +// Manager wraps two Transports (WS primary, poll fallback) and presents a +// single Transport surface to the agent. It runs WS first; if WS doesn't +// authenticate within the upgrade deadline OR errors out with a tunnel- +// incompat signature (RSV1, "engine.io handshake failed"), it shuts WS +// down and starts the polling client. The active transport is what every +// Send/SendHeartbeat/etc. call goes to. +// +// Once we've fallen back to polling, we stay there until the process +// restarts. Toggling back and forth would burn through reconnect/auth +// cycles and the whole point of falling back is "this network can't do +// WS." Restart-to-recover is fine — agents reconnect on service start +// and try WS again. +type Manager struct { + primary wsBackend + fallback Transport + log *logger.Logger + handler MessageHandler + + mu sync.RWMutex + active Transport + mode atomic.Value // Mode + + // Upgrade deadline: WS gets this long to complete a handshake before + // we declare it dead and switch to poll. + upgradeDeadline time.Duration +} + +// NewManager constructs a transport manager. Pass the WS client as the +// primary and a poll client as the fallback. +func NewManager(primary wsBackend, fallback Transport, log *logger.Logger) *Manager { + m := &Manager{ + primary: primary, + fallback: fallback, + log: log.WithComponent("transport"), + upgradeDeadline: 30 * time.Second, + } + m.active = primary + m.mode.Store(primary.Mode()) + return m +} + +// SetHandler installs the inbound dispatcher on both backends so a +// transport switch doesn't lose the binding. +func (m *Manager) SetHandler(h MessageHandler) { + m.handler = h + m.primary.SetHandler(h) + m.fallback.SetHandler(h) +} + +// Mode reports which transport is currently active. +func (m *Manager) Mode() Mode { + if v, ok := m.mode.Load().(Mode); ok { + return v + } + return ModeWS +} + +func (m *Manager) IsConnected() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.active.IsConnected() +} + +func (m *Manager) Send(msg interface{}) error { + m.mu.RLock() + defer m.mu.RUnlock() + return m.active.Send(msg) +} + +func (m *Manager) SendHeartbeat(metrics protocol.HeartbeatMetrics) error { + m.mu.RLock() + defer m.mu.RUnlock() + return m.active.SendHeartbeat(metrics) +} + +func (m *Manager) SendCommandResult(commandID string, success bool, data interface{}, errMsg string, duration time.Duration) error { + m.mu.RLock() + defer m.mu.RUnlock() + return m.active.SendCommandResult(commandID, success, data, errMsg, duration) +} + +func (m *Manager) SendStream(channel string, data interface{}) error { + m.mu.RLock() + defer m.mu.RUnlock() + return m.active.SendStream(channel, data) +} + +func (m *Manager) SendError(code, details string) error { + m.mu.RLock() + defer m.mu.RUnlock() + return m.active.SendError(code, details) +} + +func (m *Manager) Session() *auth.SessionToken { + m.mu.RLock() + defer m.mu.RUnlock() + return m.active.Session() +} + +func (m *Manager) Close() error { + m.mu.RLock() + defer m.mu.RUnlock() + return m.active.Close() +} + +// Stability thresholds for the WS-flap detector. CF tunnels and other +// proxies can let WS connect successfully but kill it every couple of +// minutes — the symptom your events.json shows is "ws_connected → +// ws_disconnected" cycling forever, which generates pointless reconnect +// traffic without ever delivering live streams. Once we see the +// connection prove itself unstable, drop to polling permanently and +// stop wasting bandwidth on retries. +const ( + wsFlapWindow = 10 * time.Minute + wsFlapThreshold = 3 // disconnects within wsFlapWindow that trigger fallback +) + +// Run executes the WS transport and watches for tunnel-incompat +// failures. When detected, it cancels WS and runs the poll transport. +// Blocks until ctx is cancelled. +func (m *Manager) Run(ctx context.Context) error { + wsCtx, cancelWS := context.WithCancel(ctx) + defer cancelWS() + + wsDone := make(chan error, 1) + go func() { + wsDone <- m.primary.Run(wsCtx) + }() + + // Watch WS for tunnel-incompat failure within the upgrade deadline. + // If detected, kill WS and run poll for the rest of ctx's lifetime. + deadline := time.NewTimer(m.upgradeDeadline) + defer deadline.Stop() + + check := time.NewTicker(2 * time.Second) + defer check.Stop() + + // Stability monitor: ticks at 1Hz to sample IsConnected and record + // transitions. When we see wsFlapThreshold disconnects within + // wsFlapWindow, give up on WS and run the fallback. + stabilityTicker := time.NewTicker(1 * time.Second) + defer stabilityTicker.Stop() + wasConnected := false + disconnectTimes := make([]time.Time, 0, wsFlapThreshold+1) + + for { + select { + case <-ctx.Done(): + cancelWS() + <-wsDone + return ctx.Err() + + case err := <-wsDone: + // WS exited on its own (rare — its loop runs until ctx is + // done). If ctx isn't cancelled, treat this as a permanent + // WS failure and fall back. + if ctx.Err() != nil { + return ctx.Err() + } + m.log.Warn("WS transport exited; falling back to polling", "error", err) + return m.runFallback(ctx) + + case <-deadline.C: + // Soft deadline: WS hasn't authenticated yet. If it never has + // AND the last error looks like a tunnel issue, fall back. + // If WS just hasn't connected yet for a benign reason + // (panel slow to start), give it more rope. + if !m.primary.EverConnected() { + lastErr := m.primary.LastError() + if isTunnelIncompat(lastErr) { + m.log.Warn("WS handshake failed with a tunnel-incompat signature; falling back to polling", + "error", lastErr) + cancelWS() + <-wsDone + return m.runFallback(ctx) + } + m.log.Info("WS not yet connected after upgrade deadline; continuing to retry", + "last_error", lastErr) + } + // Stop checking after the soft deadline; if WS is still + // trying it'll either succeed eventually or stay broken + // quietly. The user can restart the service to retry from + // scratch. + + case <-check.C: + // While WS is still inside the upgrade window, check + // proactively for unambiguous failure modes that warrant a + // fast switch instead of waiting out the deadline. + if !m.primary.EverConnected() { + if isTunnelIncompat(m.primary.LastError()) { + m.log.Warn("WS reports tunnel-incompat error; falling back early to polling", + "error", m.primary.LastError()) + cancelWS() + <-wsDone + return m.runFallback(ctx) + } + } else { + // WS got through. Stop the deadline timer — we're good. + deadline.Stop() + } + + case now := <-stabilityTicker.C: + // Sample the WS connection state and detect flap patterns: + // repeatedly connecting then dropping within minutes + // indicates an intermediary (CF tunnel, ngrok, NAT timeout) + // that won't keep a long-lived WS open. Counting drops in a + // sliding window catches both rapid churn and the slower + // "drops every couple minutes" pattern reported in + // events.json. + isConn := m.primary.IsConnected() + if !isConn && wasConnected { + disconnectTimes = append(disconnectTimes, now) + // Trim drops outside the window. + cutoff := now.Add(-wsFlapWindow) + kept := disconnectTimes[:0] + for _, t := range disconnectTimes { + if t.After(cutoff) { + kept = append(kept, t) + } + } + disconnectTimes = kept + m.log.Info("WS disconnected", "drops_in_window", len(disconnectTimes)) + if len(disconnectTimes) >= wsFlapThreshold { + m.log.Warn("WS connection unstable — falling back to polling permanently", + "drops", len(disconnectTimes), + "window_minutes", int(wsFlapWindow.Minutes())) + cancelWS() + <-wsDone + return m.runFallback(ctx) + } + } + wasConnected = isConn + } + } +} + +func (m *Manager) runFallback(ctx context.Context) error { + m.mu.Lock() + m.active = m.fallback + m.mu.Unlock() + m.mode.Store(m.fallback.Mode()) + m.log.Info("Active transport switched to polling (streams unavailable in this mode)") + return m.fallback.Run(ctx) +} + +// isTunnelIncompat reports whether err carries one of the known +// signatures of a tunnel intermediary mangling WebSocket frames. +// Matches both "RSV1" frame errors and engine.io handshake failures +// after a successful TCP/TLS dial — both indicate the connection is +// reaching the panel but the WS layer is unusable. +func isTunnelIncompat(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "RSV1 set") || + strings.Contains(msg, "engine.io handshake failed") || + strings.Contains(msg, "websocket: close 1006") // abnormal closure mid-handshake +} diff --git a/agent/internal/transport/poll/client.go b/agent/internal/transport/poll/client.go new file mode 100644 index 00000000..976d16d4 --- /dev/null +++ b/agent/internal/transport/poll/client.go @@ -0,0 +1,437 @@ +// Package poll implements the REST long-poll fallback transport. +// +// Used when WS-incompatible tunnels (free-tier ngrok, Cloudflare quick +// tunnels) corrupt WebSocket frames. Endpoints (panel side): +// +// POST /api/v1/agent/connect — HMAC auth, returns session_token +// POST /api/v1/agent/poll — heartbeat + long-poll for commands +// POST /api/v1/agent/result — command result +// POST /api/v1/agent/disconnect — clean shutdown +// +// Streams (logs/metrics fan-out, terminal) are intentionally dropped — +// streaming features degrade to "view recent only" via the panel REST API. +package poll + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/serverkit/agent/internal/auth" + "github.com/serverkit/agent/internal/config" + "github.com/serverkit/agent/internal/logger" + "github.com/serverkit/agent/internal/transport" + "github.com/serverkit/agent/pkg/protocol" +) + +// Version is the agent version string surfaced in the User-Agent +// header. Defaults to "dev"; main.go's init() mirrors the ldflags-set +// main.Version into this on release builds. Was previously a hardcoded +// "dev" literal which made the panel display "ServerKit-Agent-Poll/dev" +// regardless of the actual built version. +var Version = "dev" + +// Client is the polling-mode transport. Mirrors the surface of ws.Client +// so agent.Agent doesn't need to know which transport is wired up. +type Client struct { + cfg config.ServerConfig + auth *auth.Authenticator + log *logger.Logger + http *http.Client + handler transport.MessageHandler + + mu sync.Mutex + session *auth.SessionToken + connected atomic.Bool + + // Heartbeat metrics buffered between /poll requests. Updated when + // SendHeartbeat is called; read and cleared by the poll loop. + hbMu sync.Mutex + hbMetrics *protocol.HeartbeatMetrics + // One-shot system info, sent on the first /poll after it's queued. + sysInfo map[string]interface{} + // One-shot capabilities, sent on the first /poll after it's queued. + // Re-queued on every reconnect by agent.connectionWatcher so the + // panel's record stays current after a panel restart. + caps map[string]interface{} + + baseURL string +} + +// NewClient constructs a poll-mode client. baseURL is derived from the +// configured server URL — wss://host/agent → https://host. The agent +// already gets an http(s) scheme out of buildBaseURL. +func NewClient(cfg config.ServerConfig, authenticator *auth.Authenticator, log *logger.Logger) *Client { + c := &Client{ + cfg: cfg, + auth: authenticator, + log: log.WithComponent("poll"), + http: &http.Client{ + Timeout: 35 * time.Second, // > server long-poll window (25s) + }, + } + if os.Getenv("SERVERKIT_INSECURE_TLS") == "true" { + c.http.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + c.baseURL = buildBaseURL(cfg.URL) + return c +} + +// buildBaseURL converts the configured ws(s) server URL to its http(s) +// equivalent and strips the namespace path. Examples: +// wss://panel.example.com/agent → https://panel.example.com +// ws://localhost:5000/agent → http://localhost:5000 +func buildBaseURL(raw string) string { + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return "" + } + switch u.Scheme { + case "wss": + u.Scheme = "https" + case "ws": + u.Scheme = "http" + } + u.Path = "" + u.RawQuery = "" + return strings.TrimRight(u.String(), "/") +} + +func (c *Client) SetHandler(h transport.MessageHandler) { c.handler = h } +func (c *Client) Mode() transport.Mode { return transport.ModePoll } +func (c *Client) Session() *auth.SessionToken { + c.mu.Lock() + defer c.mu.Unlock() + return c.session +} +func (c *Client) IsConnected() bool { return c.connected.Load() } + +// Run authenticates, then runs the long-poll loop until ctx is cancelled +// or auth fails irrecoverably. Reconnects on transient errors. +func (c *Client) Run(ctx context.Context) error { + if err := c.connect(ctx); err != nil { + return fmt.Errorf("poll connect: %w", err) + } + c.log.Info("Connected via polling transport", "base", c.baseURL) + defer c.disconnect() + + backoff := 1 * time.Second + const backoffMax = 30 * time.Second + + for { + if ctx.Err() != nil { + return ctx.Err() + } + err := c.pollOnce(ctx) + if err == nil { + backoff = 1 * time.Second + continue + } + if ctx.Err() != nil { + return ctx.Err() + } + // Transient — back off and retry. Fatal-class errors (401) + // are re-handled by re-authenticating. + c.log.Warn("Poll cycle failed", "error", err, "backoff", backoff) + c.connected.Store(false) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + } + if backoff < backoffMax { + backoff *= 2 + } + if isAuthError(err) { + if cerr := c.connect(ctx); cerr != nil { + c.log.Warn("Re-auth failed", "error", cerr) + } else { + c.log.Info("Re-authenticated polling session") + backoff = 1 * time.Second + } + } + } +} + +func (c *Client) connect(ctx context.Context) error { + timestamp := time.Now().UnixMilli() + nonce := auth.GenerateNonce() + signature := c.auth.SignMessageWithNonce(timestamp, nonce) + + body := map[string]interface{}{ + "agent_id": c.auth.AgentID(), + "api_key_prefix": c.auth.GetAPIKeyPrefix(), + "signature": signature, + "timestamp": timestamp, + "nonce": nonce, + } + var resp struct { + Success bool `json:"success"` + SessionToken string `json:"session_token"` + ServerID string `json:"server_id"` + PollInterval int `json:"poll_interval_s"` + Error string `json:"error"` + } + if err := c.postJSON(ctx, "/api/v1/agent/connect", "", body, &resp); err != nil { + return err + } + if !resp.Success { + return fmt.Errorf("auth rejected: %s", resp.Error) + } + c.mu.Lock() + c.session = &auth.SessionToken{ + Token: resp.SessionToken, + ExpiresAt: time.Now().Add(1 * time.Hour), + } + c.mu.Unlock() + c.connected.Store(true) + return nil +} + +func (c *Client) disconnect() { + c.mu.Lock() + tok := "" + if c.session != nil { + tok = c.session.Token + } + c.mu.Unlock() + if tok == "" { + return + } + // Best-effort, don't block — server cleans up on heartbeat timeout + // regardless. Use a short context so shutdown isn't held up. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = c.postJSON(ctx, "/api/v1/agent/disconnect", tok, nil, nil) + c.connected.Store(false) +} + +// pollOnce executes a single /poll request, dispatches any returned +// commands through the handler, and sends a one-shot system_info +// payload if queued. Returns the request error, if any. +func (c *Client) pollOnce(ctx context.Context) error { + c.mu.Lock() + tok := "" + if c.session != nil { + tok = c.session.Token + } + c.mu.Unlock() + if tok == "" { + return fmt.Errorf("no session") + } + + // Don't drain the system_info / capabilities buffers here. They + // get refreshed by the agent every 5–60s anyway, and if /poll + // fails (tunnel reconnect, transient 5xx) the previous behaviour + // was to silently lose the payload — the panel would then show + // "none reported" until the next sendCapabilities tick. Re-sending + // the same payload on every /poll is cheap and the panel's + // update_capabilities is idempotent (pure overwrite). + c.hbMu.Lock() + body := map[string]interface{}{} + if c.hbMetrics != nil { + body["metrics"] = c.hbMetrics + } + if c.sysInfo != nil { + body["system_info"] = c.sysInfo + } + if c.caps != nil { + body["capabilities"] = c.caps + } + c.hbMu.Unlock() + + var resp struct { + Commands []json.RawMessage `json:"commands"` + Ack bool `json:"ack"` + } + if err := c.postJSON(ctx, "/api/v1/agent/poll", tok, body, &resp); err != nil { + return err + } + c.connected.Store(true) + + for _, cmd := range resp.Commands { + c.dispatchCommand(cmd) + } + return nil +} + +// dispatchCommand routes a server-issued command to the agent's +// MessageHandler. The wire format mirrors what AgentNamespace emits. +func (c *Client) dispatchCommand(payload json.RawMessage) { + if c.handler == nil { + c.log.Warn("Dropping command — no handler installed") + return + } + c.handler(protocol.TypeCommand, []byte(payload)) +} + +// SendHeartbeat buffers metrics for the next /poll request. +func (c *Client) SendHeartbeat(metrics protocol.HeartbeatMetrics) error { + c.hbMu.Lock() + defer c.hbMu.Unlock() + m := metrics + c.hbMetrics = &m + return nil +} + +// SendCommandResult posts the outcome of a server-issued command. +func (c *Client) SendCommandResult(commandID string, success bool, data interface{}, errMsg string, duration time.Duration) error { + c.mu.Lock() + tok := "" + if c.session != nil { + tok = c.session.Token + } + c.mu.Unlock() + body := map[string]interface{}{ + "command_id": commandID, + "success": success, + "data": data, + "error": errMsg, + "duration": int64(duration.Milliseconds()), + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return c.postJSON(ctx, "/api/v1/agent/result", tok, body, nil) +} + +// SendStream is a no-op in poll mode — streaming features degrade to +// "view recent only" via the panel's REST API. We log at debug because +// the agent emits these often (system metrics fan-out) and warning-level +// would flood desktop.log. +func (c *Client) SendStream(channel string, data interface{}) error { + c.log.Debug("Dropping stream in poll mode", "channel", channel) + return nil +} + +// SendError is best-effort — embed in next /poll body. Acceptable to +// drop because errors are also persisted server-side via heartbeat +// metadata in most cases. +func (c *Client) SendError(code, details string) error { + c.log.Warn("Agent error in poll mode", "code", code, "details", details) + return nil +} + +// Send is the catch-all for protocol packets that don't fit the +// dedicated methods. The polling protocol only handles the explicit +// methods, so we surface a soft warning rather than fail. +func (c *Client) Send(msg interface{}) error { + // Special-case system_info because the agent emits it on connect. + if m, ok := msg.(map[string]interface{}); ok { + if t, _ := m["type"].(string); t == string(protocol.TypeSystemInfo) { + info, _ := m["info"].(map[string]interface{}) + c.hbMu.Lock() + c.sysInfo = info + c.hbMu.Unlock() + return nil + } + } + // Typed SystemInfoMessage path — the agent ships these on connect + // and on a periodic cadence so the panel can persist CPU/memory/ + // disk/docker info. Round-trip through JSON to extract the inner + // info field without a struct dependency in this layer. + if sm, ok := msg.(protocol.SystemInfoMessage); ok { + buf, err := json.Marshal(sm.Info) + if err != nil { + return err + } + var raw map[string]interface{} + if err := json.Unmarshal(buf, &raw); err != nil { + return err + } + c.hbMu.Lock() + c.sysInfo = raw + c.hbMu.Unlock() + return nil + } + // Capabilities arrive as a typed struct (CapabilitiesMessage), not + // a map — round-trip through JSON to lift the inner fields out + // without growing this layer a Go-typed dependency on the + // protocol message shape. + if cm, ok := msg.(protocol.CapabilitiesMessage); ok { + buf, err := json.Marshal(cm) + if err != nil { + return err + } + var raw map[string]interface{} + if err := json.Unmarshal(buf, &raw); err != nil { + return err + } + // Strip the envelope (type/id/timestamp/signature) — the panel + // poll endpoint cares about the inner payload. + delete(raw, "type") + delete(raw, "id") + delete(raw, "timestamp") + delete(raw, "signature") + c.hbMu.Lock() + c.caps = raw + c.hbMu.Unlock() + return nil + } + c.log.Debug("Dropping unsupported Send in poll mode") + return nil +} + +func (c *Client) Close() error { + c.disconnect() + return nil +} + +// postJSON sends a JSON body and decodes a JSON response. Empty body or +// out is allowed for endpoints that don't have one. +func (c *Client) postJSON(ctx context.Context, path, token string, body interface{}, out interface{}) error { + var rdr io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal body: %w", err) + } + rdr = bytes.NewReader(buf) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, rdr) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "ServerKit-Agent-Poll/"+Version) + if token != "" { + req.Header.Set("X-Session-Token", token) + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + return errAuth + } + if resp.StatusCode >= 400 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b)) + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +var errAuth = fmt.Errorf("session unauthorized") + +func isAuthError(err error) bool { + return err != nil && (err == errAuth || strings.Contains(err.Error(), "session unauthorized")) +} diff --git a/agent/internal/transport/transport.go b/agent/internal/transport/transport.go new file mode 100644 index 00000000..1c43f8d2 --- /dev/null +++ b/agent/internal/transport/transport.go @@ -0,0 +1,84 @@ +// Package transport abstracts the agent ↔ panel link. Two implementations +// share the same surface: +// +// - ws (internal/ws.Client) — primary, long-lived Socket.IO connection. +// - poll (internal/transport/poll.Client) — REST long-poll fallback used +// when WS-incompatible tunnels (free-tier ngrok, cf quick tunnels) +// mangle WebSocket frames. +// +// agent.Agent talks to a Transport, not a concrete client, and a small +// fallback Manager swaps which one is wired up when WS connectivity +// keeps failing. +package transport + +import ( + "context" + "time" + + "github.com/serverkit/agent/internal/auth" + "github.com/serverkit/agent/pkg/protocol" +) + +// MessageHandler is invoked when an inbound packet (typically a command) +// arrives from the panel. data is the raw JSON payload. +type MessageHandler func(msgType protocol.MessageType, data []byte) + +// Mode names a transport implementation. Surfaced through IPC so the UI +// can show "Connected via polling" when the agent has fallen back. +type Mode string + +const ( + ModeWS Mode = "ws" + ModePoll Mode = "poll" +) + +// Transport is the shared surface between WS and polling. +// +// Streaming methods (SendStream) are best-effort — the polling +// implementation drops them silently. Callers must not rely on stream +// delivery for correctness. +type Transport interface { + // SetHandler installs the inbound message dispatcher. + SetHandler(MessageHandler) + + // Run blocks until ctx is cancelled or the transport gives up. WS + // runs the read/write/ping/reconnect loops; poll runs the + // long-poll loop. Returns the reason it exited so the manager can + // decide whether to retry, fall back, or surface the error. + Run(ctx context.Context) error + + // IsConnected reports whether the transport is currently live (WS: + // authenticated socket open; poll: at least one /poll round-trip + // succeeded recently). + IsConnected() bool + + // Send pushes a generic JSON-able message; only meaningful for WS. + // Polling silently no-ops on these unless they map to a known + // command result. + Send(msg interface{}) error + + // SendHeartbeat dispatches metrics. WS sends an event; polling + // piggybacks on the next /poll request. + SendHeartbeat(metrics protocol.HeartbeatMetrics) error + + // SendCommandResult delivers the outcome of a command. Both + // transports route this synchronously. + SendCommandResult(commandID string, success bool, data interface{}, errMsg string, duration time.Duration) error + + // SendStream pushes streaming data (logs, container output, real-time + // metrics fan-out). Polling drops these — streaming features + // degrade naturally to "view recent only" via the panel's REST API. + SendStream(channel string, data interface{}) error + + // SendError surfaces an error to the panel. Best-effort. + SendError(code, details string) error + + // Session returns the current session token, or nil if unauthenticated. + Session() *auth.SessionToken + + // Close terminates the transport. + Close() error + + // Mode reports which implementation this is. + Mode() Mode +} diff --git a/agent/internal/tray/client.go b/agent/internal/tray/client.go index c3398287..67e18c1b 100644 --- a/agent/internal/tray/client.go +++ b/agent/internal/tray/client.go @@ -4,8 +4,11 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "strings" "time" + "github.com/serverkit/agent/internal/config" "github.com/serverkit/agent/internal/ipc" ) @@ -13,6 +16,13 @@ import ( type Client struct { baseURL string httpClient *http.Client + // token is the bearer credential the IPC server requires on every + // endpoint except /health. Loaded lazily from disk on each request + // so the tray picks up token rotations without restart. An empty + // token still lets /health probes work (the only endpoint the + // IPC server leaves unauthenticated), but every other call will + // 401 — IsAgentRunning() therefore stays accurate. + token string } // NewClient creates a new IPC client @@ -25,9 +35,38 @@ func NewClient(address string, port int) *Client { } } +// loadToken reads the IPC token from the well-known path the agent +// service writes on startup. Best-effort: a missing file just means +// "agent never finished initialising the IPC server" — the caller +// gets a 401 and surfaces it as an unreachable agent. +func (c *Client) loadToken() string { + if data, err := os.ReadFile(config.IPCTokenPath()); err == nil { + return strings.TrimSpace(string(data)) + } + return "" +} + +// authedRequest builds a GET/POST with the Authorization header set. +// Centralising the header lets us swap to a different scheme later +// without touching every endpoint method. +func (c *Client) authedRequest(method, url string) (*http.Request, error) { + req, err := http.NewRequest(method, url, nil) + if err != nil { + return nil, err + } + if tok := c.loadToken(); tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + return req, nil +} + // GetStatus fetches the agent status func (c *Client) GetStatus() (*ipc.AgentStatus, error) { - resp, err := c.httpClient.Get(c.baseURL + "/status") + req, err := c.authedRequest(http.MethodGet, c.baseURL+"/status") + if err != nil { + return nil, err + } + resp, err := c.httpClient.Do(req) if err != nil { return nil, err } @@ -47,7 +86,11 @@ func (c *Client) GetStatus() (*ipc.AgentStatus, error) { // GetMetrics fetches detailed system metrics func (c *Client) GetMetrics() (*ipc.DetailedMetrics, error) { - resp, err := c.httpClient.Get(c.baseURL + "/metrics") + req, err := c.authedRequest(http.MethodGet, c.baseURL+"/metrics") + if err != nil { + return nil, err + } + resp, err := c.httpClient.Do(req) if err != nil { return nil, err } @@ -67,7 +110,11 @@ func (c *Client) GetMetrics() (*ipc.DetailedMetrics, error) { // GetConnection fetches WebSocket connection info func (c *Client) GetConnection() (*ipc.ConnectionInfo, error) { - resp, err := c.httpClient.Get(c.baseURL + "/connection") + req, err := c.authedRequest(http.MethodGet, c.baseURL+"/connection") + if err != nil { + return nil, err + } + resp, err := c.httpClient.Do(req) if err != nil { return nil, err } @@ -87,7 +134,11 @@ func (c *Client) GetConnection() (*ipc.ConnectionInfo, error) { // GetLogs fetches recent log lines func (c *Client) GetLogs(lines int) ([]string, error) { - resp, err := c.httpClient.Get(fmt.Sprintf("%s/logs?lines=%d", c.baseURL, lines)) + req, err := c.authedRequest(http.MethodGet, fmt.Sprintf("%s/logs?lines=%d", c.baseURL, lines)) + if err != nil { + return nil, err + } + resp, err := c.httpClient.Do(req) if err != nil { return nil, err } @@ -109,7 +160,12 @@ func (c *Client) GetLogs(lines int) ([]string, error) { // Restart requests agent restart func (c *Client) Restart() error { - resp, err := c.httpClient.Post(c.baseURL+"/restart", "application/json", nil) + req, err := c.authedRequest(http.MethodPost, c.baseURL+"/restart") + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) if err != nil { return err } diff --git a/agent/internal/tray/icons/connected.ico b/agent/internal/tray/icons/connected.ico index c388bd93..8d1b44a4 100644 Binary files a/agent/internal/tray/icons/connected.ico and b/agent/internal/tray/icons/connected.ico differ diff --git a/agent/internal/tray/icons/disconnected.ico b/agent/internal/tray/icons/disconnected.ico index c388bd93..6c21cc3d 100644 Binary files a/agent/internal/tray/icons/disconnected.ico and b/agent/internal/tray/icons/disconnected.ico differ diff --git a/agent/internal/tray/icons/error.ico b/agent/internal/tray/icons/error.ico index c388bd93..565eeab5 100644 Binary files a/agent/internal/tray/icons/error.ico and b/agent/internal/tray/icons/error.ico differ diff --git a/agent/internal/tray/icons/stopped.ico b/agent/internal/tray/icons/stopped.ico index c388bd93..435cb151 100644 Binary files a/agent/internal/tray/icons/stopped.ico and b/agent/internal/tray/icons/stopped.ico differ diff --git a/agent/internal/tray/tray.go b/agent/internal/tray/tray.go index 17967bc2..a7d9ea1b 100644 --- a/agent/internal/tray/tray.go +++ b/agent/internal/tray/tray.go @@ -8,6 +8,8 @@ import ( "time" "fyne.io/systray" + + "github.com/serverkit/agent/internal/config" ) // AppConfig holds tray app configuration @@ -18,6 +20,12 @@ type AppConfig struct { ServerURL string DashboardURL string LogFile string + // NeedsSetup is set by runDesktop when the agent has no pairing config + // yet. The tray shows a prominent "Open setup wizard…" item in that case + // so the user can recover from a closed-without-pairing wizard window. + NeedsSetup bool + OnOpenSetup func() + OnOpenLogs func() } // App is the system tray application @@ -34,16 +42,17 @@ type App struct { memPercent float64 // Menu items - menuStatus *systray.MenuItem - menuCPU *systray.MenuItem - menuMem *systray.MenuItem - menuStartAgent *systray.MenuItem - menuStopAgent *systray.MenuItem + menuSetup *systray.MenuItem + menuStatus *systray.MenuItem + menuCPU *systray.MenuItem + menuMem *systray.MenuItem + menuStartAgent *systray.MenuItem + menuStopAgent *systray.MenuItem menuRestartAgent *systray.MenuItem - menuViewLogs *systray.MenuItem - menuDashboard *systray.MenuItem - menuAbout *systray.MenuItem - menuQuit *systray.MenuItem + menuViewLogs *systray.MenuItem + menuDashboard *systray.MenuItem + menuAbout *systray.MenuItem + menuQuit *systray.MenuItem // Control channels quitCh chan struct{} @@ -73,7 +82,14 @@ func (a *App) onReady() { // Set initial icon (gray/stopped) systray.SetIcon(GetIcon(IconStateStopped)) systray.SetTitle("ServerKit") - systray.SetTooltip("ServerKit Agent - Checking...") + systray.SetTooltip("ServerKit Agent") + + // Always create the setup item — its title flips between "Open setup + // wizard…" (unconfigured) and "Re-pair this server…" (configured) based + // on what refresh() observes. Putting it first means the user can always + // reach pairing from the tray, which is the user-visible recovery surface. + a.menuSetup = systray.AddMenuItem("Open setup wizard…", "Pair this server with a ServerKit panel") + systray.AddSeparator() // Create menu items a.menuStatus = systray.AddMenuItem("Status: Checking...", "Agent status") @@ -135,6 +151,39 @@ func (a *App) refreshLoop() { } func (a *App) refresh() { + // Re-load config every refresh so the tray picks up freshly-saved + // credentials from a separate setup-wizard process the moment they + // land. Cheap; the file is tiny. + cfg, _ := config.Load("") + configured := cfg != nil && cfg.Agent.ID != "" + + if a.menuSetup != nil { + if configured { + a.menuSetup.SetTitle("Re-pair this server…") + a.menuSetup.SetTooltip("Replace the current pairing with a fresh one") + } else { + a.menuSetup.SetTitle("Open setup wizard…") + a.menuSetup.SetTooltip("Pair this server with a ServerKit panel") + } + } + + if !configured { + a.mu.Lock() + defer a.mu.Unlock() + a.agentRunning = false + a.connected = false + a.lastStatus = "Not configured" + systray.SetIcon(GetIcon(IconStateStopped)) + systray.SetTooltip("ServerKit Agent - Setup required") + a.menuStatus.SetTitle("Status: Not configured") + a.menuCPU.SetTitle("CPU: --") + a.menuMem.SetTitle("Memory: --") + a.menuStartAgent.Disable() + a.menuStopAgent.Disable() + a.menuRestartAgent.Disable() + return + } + status, err := a.client.GetStatus() a.mu.Lock() @@ -199,6 +248,10 @@ func (a *App) handleMenuClicks() { select { case <-a.quitCh: return + case <-a.menuSetup.ClickedCh: + if a.config.OnOpenSetup != nil { + a.config.OnOpenSetup() + } case <-a.menuStartAgent.ClickedCh: a.startAgent() case <-a.menuStopAgent.ClickedCh: @@ -237,17 +290,10 @@ func (a *App) stopAgent() { } func (a *App) restartAgent() { - // Try IPC restart first (graceful) - if a.client.IsAgentRunning() { - if err := a.client.Restart(); err == nil { - a.showNotification("Agent Restarting", "ServerKit agent is restarting") - time.Sleep(2 * time.Second) - a.refresh() - return - } - } - - // Fall back to service restart + // IPC "restart" is actually just a graceful stop — SCM doesn't + // auto-relaunch a service that exits cleanly, so the previous + // IPC-first path silently left the user with a stopped agent and + // required a second click. Always do sc stop + sc start instead. if err := restartService(); err != nil { a.showNotification("Failed to Restart", err.Error()) return diff --git a/agent/internal/ws/client.go b/agent/internal/ws/client.go index 47854491..3fe21e03 100644 --- a/agent/internal/ws/client.go +++ b/agent/internal/ws/client.go @@ -16,11 +16,21 @@ import ( "github.com/serverkit/agent/internal/auth" "github.com/serverkit/agent/internal/config" "github.com/serverkit/agent/internal/logger" + "github.com/serverkit/agent/internal/transport" "github.com/serverkit/agent/pkg/protocol" ) -// MessageHandler is called when a message is received -type MessageHandler func(msgType protocol.MessageType, data []byte) +// Version is the agent version string surfaced in the User-Agent +// header. Defaults to "dev"; main.go's init() mirrors the ldflags-set +// main.Version into this when a release build runs. +var Version = "dev" + +func agentVersion() string { return Version } + +// MessageHandler is the callback fired on inbound packets. Aliased to the +// transport package so ws.Client implements transport.Transport without +// adapter glue. +type MessageHandler = transport.MessageHandler // Client is a Socket.IO client with auto-reconnect type Client struct { @@ -34,6 +44,8 @@ type Client struct { mu sync.RWMutex connected bool reconnecting bool + lastErr error + everConnected bool sendCh chan []byte doneCh chan struct{} @@ -105,8 +117,18 @@ func (c *Client) Connect(ctx context.Context) error { return err } + // Compression default: off. Some tunnel intermediaries (free-tier ngrok, + // trycloudflare.com quick tunnels) corrupt compressed WebSocket frames — + // gorilla then rejects them with "RSV1 set, FIN not set on control". The + // env var override exists for users behind a *permanent* cf tunnel or a + // reverse proxy that handles WS compression cleanly: setting + // SERVERKIT_WS_COMPRESSION=true makes the agent negotiate permessage- + // deflate, which is necessary if the panel/proxy unconditionally sends + // compressed frames regardless of negotiation. + enableCompression := os.Getenv("SERVERKIT_WS_COMPRESSION") == "true" dialer := websocket.Dialer{ - HandshakeTimeout: 10 * time.Second, + HandshakeTimeout: 10 * time.Second, + EnableCompression: enableCompression, } // Only allow insecure TLS when explicitly set via environment variable @@ -118,7 +140,10 @@ func (c *Client) Connect(ctx context.Context) error { headers := http.Header{} headers.Set("X-Agent-ID", c.auth.AgentID()) headers.Set("X-API-Key-Prefix", c.auth.GetAPIKeyPrefix()) - headers.Set("User-Agent", fmt.Sprintf("ServerKit-Agent/%s", "dev")) + headers.Set("User-Agent", fmt.Sprintf("ServerKit-Agent/%s", agentVersion())) + // Bypass ngrok's interstitial / browser-warning behaviour for free-tier + // tunnels. Harmless when the panel isn't behind ngrok. + headers.Set("ngrok-skip-browser-warning", "true") c.log.Debug("Connecting to Socket.IO", "url", sioURL) @@ -140,6 +165,17 @@ func (c *Client) Connect(ctx context.Context) error { // Step 1: Read Engine.IO OPEN packet if err := c.handleEngineIOOpen(); err != nil { conn.Close() + // "RSV1 set, FIN not set on control" is the classic signature of a + // tunnel intermediary (cf quick tunnels, free-tier ngrok) injecting + // or corrupting WebSocket compression. The user can't fix the panel + // from the agent side — the only useful action is "try a different + // transport." Surface that explicitly so the error stops being + // cryptic. + if strings.Contains(err.Error(), "RSV1 set") { + c.log.Error("WebSocket frame compression conflict — likely caused by the tunnel between agent and panel", + "hint", "try SERVERKIT_WS_COMPRESSION=true, or switch from a Cloudflare quick tunnel (*.trycloudflare.com) to a permanent named tunnel, or front the panel with a direct HTTPS reverse proxy", + ) + } return fmt.Errorf("engine.io handshake failed: %w", err) } @@ -393,39 +429,79 @@ func (c *Client) Run(ctx context.Context) error { if !connected { if err := c.Connect(ctx); err != nil { c.log.Warn("Connection failed", "error", err) + c.mu.Lock() + c.lastErr = err + c.mu.Unlock() c.handleReconnect(ctx) continue } + c.mu.Lock() + c.everConnected = true + c.lastErr = nil + c.mu.Unlock() } - // Start read/write/ping loops + // Per-iteration ctx so we can cancel readLoop/writeLoop/pingLoop + // together when any one of them errors. The previous code only + // read the first error off errCh and let the other two + // goroutines leak until the next reconnect's conn.Close + // happened to cause a read error in them — every flap leaked + // goroutines that, in tunnel-flap scenarios, accumulated + // quickly. With per-iteration cancellation each connection + // owns exactly three goroutines for its lifetime. + loopCtx, cancelLoop := context.WithCancel(ctx) errCh := make(chan error, 3) + var wg sync.WaitGroup + wg.Add(3) go func() { - errCh <- c.readLoop(ctx) + defer wg.Done() + errCh <- c.readLoop(loopCtx) }() - go func() { - errCh <- c.writeLoop(ctx) + defer wg.Done() + errCh <- c.writeLoop(loopCtx) }() - go func() { - errCh <- c.pingLoop(ctx) + defer wg.Done() + errCh <- c.pingLoop(loopCtx) }() // Wait for error err := <-errCh c.log.Warn("Connection loop ended", "error", err) - // Mark as disconnected + // Mark as disconnected before draining sendCh so any + // concurrent Send() callers see the disconnected state and + // fail fast rather than queueing onto a buffer about to + // be flushed. c.mu.Lock() c.connected = false c.mu.Unlock() - // Close connection + // Cancel siblings and close the conn so readLoop unblocks + // from its read deadline. Then wait for both siblings to + // drain their error onto errCh — leaving them running was + // the goroutine leak. + cancelLoop() if c.conn != nil { c.conn.Close() } + wg.Wait() + // Drain the remaining errCh entries so the channel doesn't + // retain references to err values. + for len(errCh) > 0 { + <-errCh + } + + // Drain stale messages buffered while the previous connection + // was already failing. Replaying these on a fresh session + // would ship heartbeats/results from a dead WS to a new auth + // context — and worse, if the buffer ever filled mid-flap, + // every subsequent Send() would return "send channel full" + // permanently. Replace the channel with a fresh one so we + // also drop any blocked senders cleanly. + c.drainSendCh() // Check if context is cancelled select { @@ -437,8 +513,44 @@ func (c *Client) Run(ctx context.Context) error { } } +// drainSendCh empties any pending outbound messages from the previous +// connection. Called after a disconnect so we don't ship stale frames +// (heartbeats, command results) on the next session — they were +// computed against the old auth context and the panel will either +// reject them or attribute them to the wrong session window. +func (c *Client) drainSendCh() { + for { + select { + case <-c.sendCh: + default: + return + } + } +} + // readLoop reads Socket.IO messages from the WebSocket func (c *Client) readLoop(ctx context.Context) error { + // Read deadline = pingInterval * 2.5 + 5s grace. Any inbound message + // (server ping, app frame, WS-level pong) resets the deadline. If + // nothing arrives within this window, the connection is presumed + // dead at the network layer and the read errors out, which is what + // the Manager's flap detector relies on. Without this, CF/ngrok + // killing the connection at the network layer (without sending a + // WS close frame) makes ReadMessage hang for minutes — exactly the + // "ws_disconnected every 2-3 min" pattern in the user's events.json. + readWait := c.pingInterval*5/2 + 5*time.Second + if readWait < 30*time.Second { + readWait = 30 * time.Second + } + resetDeadline := func() { + _ = c.conn.SetReadDeadline(time.Now().Add(readWait)) + } + resetDeadline() + c.conn.SetPongHandler(func(string) error { + resetDeadline() + return nil + }) + for { select { case <-ctx.Done(): @@ -450,6 +562,8 @@ func (c *Client) readLoop(ctx context.Context) error { if err != nil { return fmt.Errorf("read error: %w", err) } + // Any successful read = liveness signal. Reset the window. + resetDeadline() msgStr := string(msg) @@ -595,6 +709,16 @@ func (c *Client) handleReconnect(ctx context.Context) { // Send queues a message for sending func (c *Client) Send(msg interface{}) error { + c.mu.RLock() + connected := c.connected + c.mu.RUnlock() + if !connected { + // Refuse to queue when there's nothing draining the channel. + // Otherwise a backlog accumulates while the agent is offline + // and floods the panel on reconnect with stale heartbeats. + return fmt.Errorf("not connected") + } + data, err := json.Marshal(msg) if err != nil { return fmt.Errorf("failed to marshal message: %w", err) @@ -691,6 +815,30 @@ func (c *Client) Close() error { } // Session returns the current session token +// Mode reports this transport as WS so consumers (UI, log lines) can +// distinguish from the polling fallback. +func (c *Client) Mode() transport.Mode { return transport.ModeWS } + +// LastError returns the most recent connect or runloop error, or nil +// when healthy. Used by the transport manager to decide whether to fall +// back to polling — specifically, RSV1/handshake errors indicate a +// tunnel intermediary that won't ever let WS through and we shouldn't +// keep retrying the same dial. +func (c *Client) LastError() error { + c.mu.RLock() + defer c.mu.RUnlock() + return c.lastErr +} + +// EverConnected reports whether the client has successfully completed a +// handshake at least once during this Run. False after several seconds +// is the strongest signal that WS won't ever work for this network. +func (c *Client) EverConnected() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.everConnected +} + func (c *Client) Session() *auth.SessionToken { return c.session } diff --git a/agent/packaging/icons/generate_icons.py b/agent/packaging/icons/generate_icons.py new file mode 100644 index 00000000..1c326dfb --- /dev/null +++ b/agent/packaging/icons/generate_icons.py @@ -0,0 +1,86 @@ +"""Generate ServerKit icon set from the brand SVG. + +Outputs: + agent/internal/setupui/serverkit.ico — main app/installer icon (embedded into the exe) + agent/internal/tray/icons/connected.ico — tray (full color) + agent/internal/tray/icons/disconnected.ico — tray (yellow tint) + agent/internal/tray/icons/error.ico — tray (red tint) + agent/internal/tray/icons/stopped.ico — tray (gray) + +Run from repo root: + python agent/packaging/icons/generate_icons.py +""" +from __future__ import annotations + +import io +from pathlib import Path + +import cairosvg +from PIL import Image, ImageEnhance + +REPO = Path(__file__).resolve().parents[3] +SVG = REPO / "frontend" / "src" / "assets" / "ServerKitLogo.svg" +SETUPUI_DIR = REPO / "agent" / "internal" / "setupui" +TRAY_DIR = REPO / "agent" / "internal" / "tray" / "icons" + +SETUPUI_DIR.mkdir(parents=True, exist_ok=True) +TRAY_DIR.mkdir(parents=True, exist_ok=True) + +APP_SIZES = [16, 24, 32, 48, 64, 128, 256] +TRAY_SIZES = [16, 24, 32, 48, 64] + + +def render_png(size: int) -> Image.Image: + png_bytes = cairosvg.svg2png( + url=str(SVG), output_width=size, output_height=size + ) + return Image.open(io.BytesIO(png_bytes)).convert("RGBA") + + +def tint(img: Image.Image, rgb: tuple[int, int, int], strength: float = 0.65) -> Image.Image: + """Apply a hue shift by blending non-transparent pixels toward `rgb`.""" + base = img.copy() + overlay = Image.new("RGBA", base.size, rgb + (0,)) + alpha = base.split()[3] + overlay.putalpha(alpha) + blended = Image.blend(base, overlay, strength) + blended.putalpha(alpha) + return blended + + +def desaturate(img: Image.Image) -> Image.Image: + enhancer = ImageEnhance.Color(img) + return enhancer.enhance(0.0) + + +def write_ico(images: list[Image.Image], out: Path) -> None: + primary = images[-1] + primary.save(out, format="ICO", sizes=[(im.width, im.height) for im in images]) + print(f" wrote {out.relative_to(REPO)} ({len(images)} sizes)") + + +def main() -> None: + print("Rendering brand PNGs…") + app_pngs = [render_png(s) for s in APP_SIZES] + tray_pngs = [render_png(s) for s in TRAY_SIZES] + + print("Building serverkit.ico…") + write_ico(app_pngs, SETUPUI_DIR / "serverkit.ico") + + print("Building wizard header PNG…") + header = render_png(96) + header_path = SETUPUI_DIR / "serverkit_header.png" + header.save(header_path, format="PNG") + print(f" wrote {header_path.relative_to(REPO)}") + + print("Building tray icons…") + write_ico(tray_pngs, TRAY_DIR / "connected.ico") + write_ico([tint(p, (245, 158, 11)) for p in tray_pngs], TRAY_DIR / "disconnected.ico") + write_ico([tint(p, (220, 38, 38)) for p in tray_pngs], TRAY_DIR / "error.ico") + write_ico([desaturate(p) for p in tray_pngs], TRAY_DIR / "stopped.ico") + + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/agent/packaging/msi/Product.wxs b/agent/packaging/msi/Product.wxs index 200ccac5..42b53923 100644 --- a/agent/packaging/msi/Product.wxs +++ b/agent/packaging/msi/Product.wxs @@ -7,7 +7,8 @@ 1. Install WiX Toolset v4 2. Run: wix build Product.wxs -o serverkit-agent.msi -define Version=1.0.0 --> - + - + + + + + + + + + @@ -34,9 +61,12 @@ + + + @@ -52,6 +82,10 @@ + + + + @@ -61,7 +95,10 @@ - + + Arguments="start"> + + + - + - + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/agent/packaging/msi/build.ps1 b/agent/packaging/msi/build.ps1 index 279a6eff..3e160f55 100644 --- a/agent/packaging/msi/build.ps1 +++ b/agent/packaging/msi/build.ps1 @@ -4,14 +4,17 @@ param( [string]$Version = "1.0.0", [string]$BinaryPath = "..\..\dist\serverkit-agent-windows-amd64.exe", - [string]$OutputDir = ".\output" + [string]$OutputDir = ".\output", + [ValidateSet("x64", "arm64")] + [string]$Arch = "x64" ) $ErrorActionPreference = "Stop" Write-Host "Building MSI installer..." Write-Host " Version: $Version" -Write-Host " Binary: $BinaryPath" +Write-Host " Arch: $Arch" +Write-Host " Binary: $BinaryPath" # Check for WiX Toolset $wixPath = Get-Command wix -ErrorAction SilentlyContinue @@ -30,6 +33,17 @@ try { # Copy binary Copy-Item $BinaryPath "$buildDir\serverkit-agent.exe" + # Copy brand icon (used by the installer banner / Add-Remove Programs entry) + $iconSource = Join-Path $PSScriptRoot "..\..\internal\setupui\serverkit.ico" + if (-not (Test-Path $iconSource)) { + Write-Host "Brand icon not found at $iconSource - run agent/packaging/icons/generate_icons.py first." + exit 1 + } + Copy-Item $iconSource "$buildDir\serverkit.ico" + + # Copy SmartScreen-bypass launcher script (see launcher.vbs for rationale). + Copy-Item (Join-Path $PSScriptRoot "launcher.vbs") "$buildDir\launcher.vbs" + # Create default config file $configContent = @" # ServerKit Agent Configuration @@ -105,14 +119,15 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\par # Copy WiX source Copy-Item "Product.wxs" "$buildDir\" - # Create output directory + # Create output directory (resolve to absolute path so it survives Push-Location) New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + $absOutputDir = (Resolve-Path $OutputDir).Path # Build MSI Push-Location $buildDir try { Write-Host "Running WiX build..." - wix build Product.wxs -o "$OutputDir\serverkit-agent-$Version-x64.msi" -define Version=$Version + wix build -arch $Arch Product.wxs -ext WixToolset.Util.wixext -o "$absOutputDir\serverkit-agent-$Version-$Arch.msi" -define Version=$Version } finally { Pop-Location @@ -120,7 +135,7 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\par Write-Host "" Write-Host "MSI built successfully!" - Write-Host "Output: $OutputDir\serverkit-agent-$Version-x64.msi" + Write-Host "Output: $absOutputDir\serverkit-agent-$Version-$Arch.msi" } finally { # Cleanup diff --git a/agent/packaging/msi/launcher.vbs b/agent/packaging/msi/launcher.vbs new file mode 100644 index 00000000..25b9b285 --- /dev/null +++ b/agent/packaging/msi/launcher.vbs @@ -0,0 +1,40 @@ +' ServerKit Agent launcher +' +' Why this exists: Windows SmartScreen silently terminates the unsigned +' serverkit-agent.exe when it is launched directly from Explorer / the +' Start menu / Search. Launching PowerShell or cmd from those same places +' works, because the SmartScreen reputation check applies to the *initial* +' user-clicked process, not its children. +' +' wscript.exe is signed by Microsoft and always trusted. By having the +' shortcut launch wscript with this script, the agent inherits a clean +' trust chain and starts reliably regardless of code-signing state. +' +' wscript runs without a console window, so there is no visible flash. + +Option Explicit + +Dim shell, fso, scriptPath, installDir, exePath, args, i + +Set shell = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") + +scriptPath = WScript.ScriptFullName +installDir = fso.GetParentFolderName(scriptPath) +exePath = fso.BuildPath(installDir, "serverkit-agent.exe") + +' Forward any arguments wscript was given (so e.g. --repair still works +' if a shortcut or another launcher passes flags through). +args = "" +If WScript.Arguments.Count > 0 Then + For i = 0 To WScript.Arguments.Count - 1 + If args <> "" Then args = args & " " + args = args & """" & WScript.Arguments(i) & """" + Next +End If + +If args = "" Then + shell.Run """" & exePath & """", 0, False +Else + shell.Run """" & exePath & """ " & args, 0, False +End If diff --git a/agent/packaging/msi/output/serverkit-agent-1.4.17-x64.msi b/agent/packaging/msi/output/serverkit-agent-1.4.17-x64.msi new file mode 100644 index 00000000..a109af8c Binary files /dev/null and b/agent/packaging/msi/output/serverkit-agent-1.4.17-x64.msi differ diff --git a/agent/packaging/msi/output/serverkit-agent-1.4.17-x64.wixpdb b/agent/packaging/msi/output/serverkit-agent-1.4.17-x64.wixpdb new file mode 100644 index 00000000..9006dcb1 Binary files /dev/null and b/agent/packaging/msi/output/serverkit-agent-1.4.17-x64.wixpdb differ diff --git a/agent/pkg/protocol/messages.go b/agent/pkg/protocol/messages.go index db07f43f..d029bba4 100644 --- a/agent/pkg/protocol/messages.go +++ b/agent/pkg/protocol/messages.go @@ -33,6 +33,11 @@ const ( // System TypeSystemInfo MessageType = "system_info" + // Capabilities — agent reports which feature surfaces it can drive + // (cron, docker, systemd, …) so the panel can gate target pickers + // without round-tripping each click. + TypeCapabilities MessageType = "capabilities" + // Discovery TypeDiscovery MessageType = "discovery" TypeDiscoveryRequest MessageType = "discovery_request" @@ -145,17 +150,69 @@ type SystemInfoMessage struct { Info SystemInfo `json:"info"` } +// Capabilities is a flat dict of feature → bool reported by the agent on +// connect. The panel uses this to gate target pickers: an agent shows up +// in the Cron page's picker only if Capabilities["cron"] is true. +// +// Format is intentionally a map[string]bool rather than a struct so new +// keys can be added on the agent side and ignored by older panels (and +// vice versa) without protocol breakage. +type Capabilities map[string]bool + +// CapabilitiesMessage is sent by the agent to advertise which feature +// surfaces it can drive. Panel echo-saves this on the in-memory +// ConnectedAgent record. +type CapabilitiesMessage struct { + Message + Capabilities Capabilities `json:"capabilities"` + Platform string `json:"platform"` // "linux", "windows", "darwin" + Distro string `json:"distro,omitempty"` // "ubuntu", "debian", "rhel", ... + DistroVersion string `json:"distro_version,omitempty"` // "22.04" + // Runtimes is a name → version map for language runtimes the agent + // detected at startup ("python": "3.11.4", "node": "20.10.0"). + // Missing keys mean "not installed"; an empty string value means + // "installed but the version probe failed." Forward-compatible — + // new keys just light up in the panel without protocol changes. + Runtimes map[string]string `json:"runtimes,omitempty"` + // AllowedPaths advertises the file-access roots the agent is willing + // to expose. Sent only when capabilities["files"] is true so the + // panel's file manager can render the available browse roots without + // guessing. Empty/missing => the panel must hide remote file features. + AllowedPaths []string `json:"allowed_paths,omitempty"` + // Sudo describes how the agent escalates privileges for handlers + // that need root (systemd, packages). Values: "root" (already root), + // "passwordless" (sudo -n works), "unavailable" (no escalation — + // privileged handlers will fail). Empty/missing means the panel + // should treat it as "unavailable" for safety. + Sudo string `json:"sudo,omitempty"` + // RuntimeManagers reports which version manager (if any) is + // installed for each runtime, e.g. {"python": "pyenv"} on Linux or + // {"python": "pyenv-win"} on Windows. Empty/missing key means no + // manager installed; the panel can offer the bootstrap action. + RuntimeManagers map[string]string `json:"runtime_managers,omitempty"` + // SystemdJSON indicates whether `systemctl --output=json` is + // supported (systemd >= 244). When false, list_units falls back to + // parsing the plain --no-legend table. + SystemdJSON bool `json:"systemd_json,omitempty"` + // ProbedAt is the unix milliseconds timestamp when the capabilities + // payload was computed. Lets the panel distinguish a fresh re-probe + // from a cached set replayed on reconnect. + ProbedAt int64 `json:"probed_at,omitempty"` +} + // SystemInfo contains detailed system information type SystemInfo struct { - Hostname string `json:"hostname"` - OS string `json:"os"` - OSVersion string `json:"os_version"` - Architecture string `json:"architecture"` - CPUCores int `json:"cpu_cores"` - TotalMemory uint64 `json:"total_memory"` - TotalDisk uint64 `json:"total_disk"` + Hostname string `json:"hostname"` + OS string `json:"os"` + OSVersion string `json:"os_version"` + Platform string `json:"platform,omitempty"` + Architecture string `json:"architecture"` + CPUModel string `json:"cpu_model,omitempty"` + CPUCores int `json:"cpu_cores"` + TotalMemory uint64 `json:"total_memory"` + TotalDisk uint64 `json:"total_disk"` DockerVersion string `json:"docker_version,omitempty"` - AgentVersion string `json:"agent_version"` + AgentVersion string `json:"agent_version"` } // Command actions @@ -203,6 +260,76 @@ const ( ActionSystemProcesses = "system:processes" ActionSystemExec = "system:exec" + // Package management actions — Phase 4 primitives the workflow + // engine sequences for "install Docker", "install LAMP" templates. + // Linux-only; the agent picks the right manager (apt/dnf/apk/…) + // based on what's on PATH. Calls are idempotent — installing an + // already-installed package is a no-op success. + // + // Install/Upgrade are long-running and stream progress on the + // returned job channel ("job:") rather than blocking — the + // command result returns {job_id, channel} immediately. + ActionPackagesInstall = "packages:install" + ActionPackagesRemove = "packages:remove" + ActionPackagesListInstalled = "packages:list_installed" + ActionPackagesUpdateCache = "packages:update_cache" + // ActionPackagesInstallAsync is the streaming variant of + // ActionPackagesInstall: takes names[] (and/or a single name), + // returns {job_id, channel} immediately, and emits per-line install + // progress on the channel. Use this for the Packages tab UI; the + // synchronous variant is retained for the workflow engine's + // agent_command nodes which expect a structured result. + ActionPackagesInstallAsync = "packages:install_async" + ActionPackagesUpgrade = "packages:upgrade" + ActionPackagesSearch = "packages:search" + ActionPackagesInfo = "packages:info" + + // Systemd unit actions. Linux-only and require systemd as PID 1 + // (the capability probe already gates this). + ActionSystemdStatus = "systemd:status" + ActionSystemdStart = "systemd:start" + ActionSystemdStop = "systemd:stop" + ActionSystemdRestart = "systemd:restart" + ActionSystemdEnable = "systemd:enable" + ActionSystemdDisable = "systemd:disable" + ActionSystemdDaemonReload = "systemd:daemon_reload" + ActionSystemdListUnits = "systemd:list_units" + ActionSystemdLogs = "systemd:logs" + ActionSystemdLogsFollow = "systemd:logs_follow" + + // Runtime version managers (Phase 5). Currently scoped to Python + // via pyenv on Linux and pyenv-win on Windows. Install/Bootstrap + // stream on a job channel like packages. + ActionRuntimesList = "runtimes:list" + ActionRuntimesPyenvBootstrap = "runtimes:pyenv:bootstrap" + ActionRuntimesPythonInstalled = "runtimes:python:installed" + ActionRuntimesPythonAvailable = "runtimes:python:available" + ActionRuntimesPythonInstall = "runtimes:python:install" + ActionRuntimesPythonUninstall = "runtimes:python:uninstall" + ActionRuntimesPythonSetGlobal = "runtimes:python:set_global" + ActionRuntimesPythonSetLocal = "runtimes:python:set_local" + ActionRuntimesPythonCurrent = "runtimes:python:current" + + // Cron actions — manage entries in the agent host's user crontab. + // Linux-only; non-Linux agents return an "unsupported" error. + ActionCronStatus = "cron:status" + ActionCronList = "cron:list" + ActionCronAdd = "cron:add" + ActionCronRemove = "cron:remove" + ActionCronToggle = "cron:toggle" + + // Cloudflared actions — manage Cloudflare named tunnels via the + // cloudflared CLI. The agent never stores Cloudflare API tokens; + // the user authenticates once on the host with + // `cloudflared tunnel login` (approach A from the design notes). + // Status reflects "is binary installed AND has cert.pem". + ActionCloudflaredStatus = "cloudflared:status" + ActionCloudflaredLogin = "cloudflared:login" + ActionCloudflaredTunnelList = "cloudflared:tunnel:list" + ActionCloudflaredTunnelCreate = "cloudflared:tunnel:create" + ActionCloudflaredTunnelRoute = "cloudflared:tunnel:route" + ActionCloudflaredTunnelDelete = "cloudflared:tunnel:delete" + // File actions ActionFileRead = "file:read" ActionFileWrite = "file:write" @@ -215,7 +342,16 @@ const ( ActionTerminalClose = "terminal:close" // Agent actions - ActionAgentUpdate = "agent:update" + ActionAgentUpdate = "agent:update" + ActionAgentRecapabilities = "agent:recapabilities" + + // GUI / desktop capture actions. These are the agent-side primitives + // that panel extensions (e.g. serverkit-gui) call into. Implementation + // lives in agent/internal/gui — kept minimal so plugins composing + // remote-desktop / synthetic-UI features don't have to fork the + // agent. + ActionGUICapabilities = "gui:capabilities" + ActionGUIScreenshot = "gui:screenshot" ) // Stream channels @@ -224,14 +360,36 @@ const ( ChannelContainerLogs = "container:%s:logs" ChannelContainerStats = "container:%s:stats" ChannelTerminal = "terminal:%s" + // ChannelSystemdLogs streams journalctl -fu events. Lifecycle + // mirrors ChannelContainerLogs: subscribe starts the follow, unsubscribe + // (or socket close) stops it. + ChannelSystemdLogs = "systemd:%s:logs" + // ChannelJob is used for long-running operations (package install, + // image build, pyenv install) that return {job_id, channel} from + // their command result and stream progress events on the channel. + // Late subscribers receive a replay of the last N buffered events. + ChannelJob = "job:%s" ) -// CredentialUpdateMessage is sent by server to rotate credentials +// CredentialUpdateMessage is sent by server to rotate credentials. +// +// HMACSig is computed by the panel as +// hex(HMAC-SHA256("rotation_id:agent_id:new_api_key:new_api_secret", +// current_api_secret)) +// where current_api_secret is the agent's secret prior to rotation. +// The agent recomputes this with its own current secret and rejects +// the rotation if they don't match. This prevents a panel session +// hijack from silently rotating an agent's credential set to ones an +// attacker controls — the existing socket-level auth is necessary but +// not sufficient because the panel has many ways to issue messages +// downward and we want defence-in-depth specifically on the rotation +// path. type CredentialUpdateMessage struct { Message RotationID string `json:"rotation_id"` APIKey string `json:"api_key"` APISecret string `json:"api_secret"` + HMACSig string `json:"hmac_sig"` } // CredentialUpdateAck is sent by agent after updating credentials diff --git a/agent/scripts/install.sh b/agent/scripts/install.sh index 872b1b1e..6dd4f97a 100644 --- a/agent/scripts/install.sh +++ b/agent/scripts/install.sh @@ -191,7 +191,11 @@ install_agent() { # Create directories mkdir -p "$CONFIG_DIR" mkdir -p "$LOG_DIR" + mkdir -p /var/lib/serverkit/apps + mkdir -p /var/serverkit/apps chown "$SERVICE_USER:$SERVICE_USER" "$LOG_DIR" + chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/serverkit + chown -R "$SERVICE_USER:$SERVICE_USER" /var/serverkit } # Register with ServerKit @@ -242,7 +246,7 @@ SyslogIdentifier=serverkit-agent NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes -ReadWritePaths=$LOG_DIR $CONFIG_DIR +ReadWritePaths=$LOG_DIR $CONFIG_DIR /var/lib/serverkit /var/serverkit PrivateTmp=yes [Install] diff --git a/agent/ui/.gitignore b/agent/ui/.gitignore new file mode 100644 index 00000000..a62c6b7a --- /dev/null +++ b/agent/ui/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.vite +*.log diff --git a/agent/ui/index.html b/agent/ui/index.html new file mode 100644 index 00000000..fc89d814 --- /dev/null +++ b/agent/ui/index.html @@ -0,0 +1,58 @@ + + + + + + ServerKit Agent + + + + +
+
+
+
Starting ServerKit Agent…
+
+
+ + + diff --git a/agent/ui/package-lock.json b/agent/ui/package-lock.json new file mode 100644 index 00000000..ff58ad8e --- /dev/null +++ b/agent/ui/package-lock.json @@ -0,0 +1,2534 @@ +{ + "name": "serverkit-agent-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "serverkit-agent-ui", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^0.363.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.22.3", + "recharts": "^2.12.3" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "sass": "^1.86.0", + "vite": "^5.4.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", + "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.363.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.363.0.tgz", + "integrity": "sha512-AlsfPCsXQyQx7wwsIgzcKOL9LwC498LIMAo+c0Es5PkHJa33xwmYAkkSoKoJWWWSYQEStqu58/jT4tL2gi32uQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/agent/ui/package.json b/agent/ui/package.json new file mode 100644 index 00000000..f36c4655 --- /dev/null +++ b/agent/ui/package.json @@ -0,0 +1,23 @@ +{ + "name": "serverkit-agent-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.363.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.22.3", + "recharts": "^2.12.3" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "sass": "^1.86.0", + "vite": "^5.4.1" + } +} diff --git a/agent/ui/src/App.jsx b/agent/ui/src/App.jsx new file mode 100644 index 00000000..be93969a --- /dev/null +++ b/agent/ui/src/App.jsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; +import Shell from './components/Shell.jsx'; +import Overview from './pages/Overview.jsx'; +import Activity from './pages/Activity.jsx'; +import Logs from './pages/Logs.jsx'; +import Actions from './pages/Actions.jsx'; +import About from './pages/About.jsx'; +import Pair from './pages/Pair.jsx'; +import { local } from './ipc/client.js'; + +// PairGate uses /local/status (authoritative — reads config.yaml in this +// process) as the single source of truth for registration. Earlier +// versions also consulted the agent service IPC, but that endpoint is +// silent on a fresh install (the service won't even start without a +// config), which let users wander into Overview / Actions before pairing +// — exactly the "let me click around an unconfigured app" report. +// +// We deliberately render NOTHING until the first /local/status resolves. +// The HTML splash from index.html stays on screen during that window, so +// the user sees a loading state instead of partial UI. +function PairGate({ children }) { + const [registered, setRegistered] = useState(null); // null=unknown + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + let cancelled = false; + async function tick() { + try { + const s = await local.status(); + if (!cancelled) setRegistered(Boolean(s && s.registered)); + } catch { + // Same-process call — if this fails the asset server is + // gone and the window is closing. + } + } + tick(); + const id = setInterval(tick, 2000); + return () => { cancelled = true; clearInterval(id); }; + }, []); + + useEffect(() => { + if (registered === null) return; // wait for first answer + const onPair = location.pathname === '/pair'; + if (!registered && !onPair) { + navigate('/pair', { replace: true }); + } + }, [registered, location.pathname, navigate]); + + // Hold render until we know — prevents the unpaired user from seeing + // Overview / Actions for a beat before being kicked to /pair. + if (registered === null) return null; + + // Hard-deny the non-pair routes when unregistered. The navigate() in + // the effect handles the redirect; this keeps the children blank in + // the same render so we don't flash the wrong page. + if (!registered && location.pathname !== '/pair') return null; + + return children; +} + +export default function App() { + return ( + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + + } /> + + + ); +} diff --git a/agent/ui/src/components/Shell.jsx b/agent/ui/src/components/Shell.jsx new file mode 100644 index 00000000..529f0da2 --- /dev/null +++ b/agent/ui/src/components/Shell.jsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router-dom'; +import Sidebar from './Sidebar.jsx'; + +export default function Shell() { + return ( +
+ +
+ +
+
+ ); +} diff --git a/agent/ui/src/components/Sidebar.jsx b/agent/ui/src/components/Sidebar.jsx new file mode 100644 index 00000000..086be7f9 --- /dev/null +++ b/agent/ui/src/components/Sidebar.jsx @@ -0,0 +1,61 @@ +import { NavLink } from 'react-router-dom'; +import { + LayoutDashboard, + History, + FileText, + Wrench, + Info, + Server, +} from 'lucide-react'; +import { useStatus } from '../ipc/hooks.js'; + +const NAV = [ + { to: '/overview', label: 'Overview', icon: LayoutDashboard }, + { to: '/activity', label: 'Activity', icon: History }, + { to: '/logs', label: 'Logs', icon: FileText }, + { to: '/actions', label: 'Actions', icon: Wrench }, + { to: '/about', label: 'About', icon: Info }, +]; + +export default function Sidebar() { + // Pulled lazily — sidebar renders on every page so this hook is a + // single shared subscription to /status. Errors are intentionally + // ignored; the sidebar shows '—' when the version isn't known yet + // rather than an error spinner. + const { status } = useStatus(5000); + return ( + + ); +} diff --git a/agent/ui/src/ipc/client.js b/agent/ui/src/ipc/client.js new file mode 100644 index 00000000..92e7fa53 --- /dev/null +++ b/agent/ui/src/ipc/client.js @@ -0,0 +1,171 @@ +// Tiny client for the agent's local IPC server. Always 127.0.0.1 — the IPC +// server refuses non-loopback binds — and gates every endpoint except +// /health behind a bearer token. We fetch the token from the in-process +// asset server (/local/ipc-token), which reads it off disk: the React UI +// can't read filesystem paths, but the Go console process running it can. + +const DEFAULT_PORT = 19780; + +// Resolve the IPC base URL. The agent UI runs in WebView2 served from +// 127.0.0.1:, so we can't get the IPC port from the document origin. +// We accept an override via an env var (Vite injects it at build time) and +// otherwise fall back to the default port baked into the agent's config. +const PORT = + Number(import.meta.env.VITE_AGENT_IPC_PORT) || + Number(window.__SERVERKIT_IPC_PORT__) || + DEFAULT_PORT; + +const BASE = `http://127.0.0.1:${PORT}`; + +// Cache the token in-module so we don't re-fetch on every IPC call. +// Refresh on 401 so a token rotation (rare; only if the file is wiped +// and the agent restarts) gets picked up without a UI reload. +let _tokenPromise = null; + +async function fetchToken() { + const res = await fetch('/local/ipc-token'); + if (!res.ok) { + // 503 means "agent service not running yet" — propagate so the + // hook can decide whether to retry. + const err = new Error(`token fetch failed: ${res.status}`); + err.status = res.status; + throw err; + } + const json = await res.json(); + return json.token; +} + +async function ipcToken() { + if (!_tokenPromise) { + _tokenPromise = fetchToken().catch((err) => { + // Don't cache failure — next call will retry. + _tokenPromise = null; + throw err; + }); + } + return _tokenPromise; +} + +function invalidateToken() { + _tokenPromise = null; +} + +async function authedHeaders(extra) { + let headers = { Accept: 'application/json', ...(extra || {}) }; + try { + const tok = await ipcToken(); + if (tok) headers.Authorization = `Bearer ${tok}`; + } catch { + // Fall through with no Authorization header. /health still works + // and any other endpoint will return 401, which the hooks already + // surface as "agent unreachable". + } + return headers; +} + +async function get(path) { + const headers = await authedHeaders(); + let res = await fetch(`${BASE}${path}`, { method: 'GET', headers }); + if (res.status === 401) { + // Token might be stale (file rewritten on agent restart). One + // retry with a freshly fetched token before bubbling the error. + invalidateToken(); + const retryHeaders = await authedHeaders(); + res = await fetch(`${BASE}${path}`, { method: 'GET', headers: retryHeaders }); + } + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`IPC ${path} ${res.status}: ${text || res.statusText}`); + } + return res.json(); +} + +async function post(path, body) { + const headers = await authedHeaders({ 'Content-Type': 'application/json' }); + let res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers, + body: body ? JSON.stringify(body) : null, + }); + if (res.status === 401) { + invalidateToken(); + const retryHeaders = await authedHeaders({ 'Content-Type': 'application/json' }); + res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: retryHeaders, + body: body ? JSON.stringify(body) : null, + }); + } + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`IPC ${path} ${res.status}: ${text || res.statusText}`); + } + return res.json(); +} + +export const ipc = { + health: () => get('/health'), + status: () => get('/status'), + metricsHistory: () => get('/metrics/history'), + events: (since = 0) => get(`/events${since ? `?since=${since}` : ''}`), + connection: () => get('/connection'), + logs: (lines = 200) => get(`/logs?lines=${lines}`), + clearLogs: () => post('/logs/clear'), + restart: () => post('/restart'), +}; + +// "local" calls hit the asset server in the *console process*, not the +// agent service. These are the operations that have to happen even when +// the agent service is down (Start the service, Re-pair) or that need an +// interactive Windows session (Open in Explorer / browser). +async function localCall(path, body) { + const res = await fetch(path, { + method: 'POST', + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : null, + }); + if (!res.ok) { + let msg = res.statusText; + try { + const j = await res.json(); + if (j && j.error) msg = j.error; + } catch { /* keep statusText */ } + const err = new Error(msg); + err.status = res.status; + throw err; + } + return res.json(); +} + +async function localGet(path) { + const res = await fetch(path); + if (!res.ok) { + const err = new Error(res.statusText); + err.status = res.status; + throw err; + } + return res.json(); +} + +export const local = { + serviceStart: () => localCall('/local/service/start'), + serviceStop: () => localCall('/local/service/stop'), + serviceRestart: () => localCall('/local/service/restart'), + open: (target) => localCall('/local/open', target), + repair: () => localCall('/local/wizard'), + diag: () => localCall('/local/diag'), + // status reads config.yaml in the console process so we can route the + // wizard correctly even when the agent service isn't running (which is + // the default state on a fresh install — no config, no service). + status: () => localGet('/local/status'), + pairStart: (panelUrl, serverName) => + localCall('/local/pair/start', { panel_url: panelUrl, server_name: serverName }), + pairState: () => localGet('/local/pair/state'), + pairCancel: () => localCall('/local/pair/cancel'), + // The single-string entry path. The agent decodes the string, calls + // /api/v1/servers/register on the panel, and lands on the same + // "claimed" state the pair-code flow ends in — so polling /state + // works unchanged. + pairConnectionString: (connectionString) => + localCall('/local/pair/connection-string', { connection_string: connectionString }), +}; diff --git a/agent/ui/src/ipc/hooks.js b/agent/ui/src/ipc/hooks.js new file mode 100644 index 00000000..8a357afb --- /dev/null +++ b/agent/ui/src/ipc/hooks.js @@ -0,0 +1,63 @@ +import { useEffect, useState, useRef } from 'react'; +import { ipc } from './client.js'; + +// Generic polling hook. Calls fetcher() on mount and then every intervalMs. +// Cancels on unmount and treats overlapping requests defensively (last write +// wins, but a stale response can't overwrite the current one). +function usePolling(fetcher, intervalMs) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const seq = useRef(0); + + useEffect(() => { + let cancelled = false; + async function tick() { + const mySeq = ++seq.current; + try { + const result = await fetcher(); + if (cancelled || mySeq !== seq.current) return; + setData(result); + setError(null); + } catch (err) { + if (cancelled || mySeq !== seq.current) return; + setError(err.message || String(err)); + } + } + tick(); + const id = setInterval(tick, intervalMs); + return () => { + cancelled = true; + clearInterval(id); + }; + // fetcher is captured by reference; in our use it's always a stable + // module-level function, so we deliberately exclude it from deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [intervalMs]); + + return { data, error }; +} + +export function useStatus(intervalMs = 2000) { + const { data, error } = usePolling(ipc.status, intervalMs); + return { status: data, error }; +} + +export function useMetricsHistory(intervalMs = 2000) { + const { data, error } = usePolling(ipc.metricsHistory, intervalMs); + return { samples: data?.samples || [], error }; +} + +export function useConnection(intervalMs = 5000) { + const { data, error } = usePolling(ipc.connection, intervalMs); + return { connection: data, error }; +} + +export function useEvents(intervalMs = 3000) { + const { data, error } = usePolling(() => ipc.events(0), intervalMs); + return { events: data?.events || [], error }; +} + +export function useLogs(lines = 500, intervalMs = 2000) { + const { data, error } = usePolling(() => ipc.logs(lines), intervalMs); + return { lines: data?.lines || [], count: data?.count || 0, error }; +} diff --git a/agent/ui/src/main.jsx b/agent/ui/src/main.jsx new file mode 100644 index 00000000..69bcf9d7 --- /dev/null +++ b/agent/ui/src/main.jsx @@ -0,0 +1,74 @@ +import { StrictMode, Component } from 'react'; +import { createRoot } from 'react-dom/client'; +import { HashRouter } from 'react-router-dom'; +import App from './App.jsx'; +import './styles/main.scss'; + +// 1.6.2 had a class of "blank #root" bug reports where a render exception +// in App or any provider unmounted the index.html splash and left no +// fallback. The user saw nothing and we got a navReady signal that lied — +// the bundle ran, the listener fired, and *then* React crashed on mount. +// This boundary keeps the window from going blank on render errors and +// echoes the message back to the WebView2 host log via window.agentLog. +class RootErrorBoundary extends Component { + state = { error: null }; + static getDerivedStateFromError(error) { + return { error }; + } + componentDidCatch(error, info) { + try { + if (window.agentLog) { + window.agentLog('error', 'react root: ' + (error && error.stack ? error.stack : String(error))); + } + } catch { /* ignore — host bridge may not be present */ } + } + render() { + if (this.state.error) { + const msg = this.state.error && this.state.error.message + ? this.state.error.message + : String(this.state.error); + return ( +
+
ServerKit Agent failed to start
+
+ Right-click anywhere and choose Inspect to see the full error in DevTools. + The message below has also been written to desktop.log. +
+
{msg}
+
+ ); + } + return this.props.children; + } +} + +createRoot(document.getElementById('root')).render( + + + + + + + +); diff --git a/agent/ui/src/pages/About.jsx b/agent/ui/src/pages/About.jsx new file mode 100644 index 00000000..a230423f --- /dev/null +++ b/agent/ui/src/pages/About.jsx @@ -0,0 +1,30 @@ +import { useStatus } from '../ipc/hooks.js'; + +export default function About() { + const { status } = useStatus(5000); + return ( +
+
+

About

+

ServerKit Agent Console

+
+
+
+ Version + {status?.version || '—'} +
+
+ Agent ID + {status?.agent_id || '—'} +
+
+ Hostname + {status?.agent_name || '—'} +
+
+
+ Unpair and Uninstall actions will land here in the next milestone. +
+
+ ); +} diff --git a/agent/ui/src/pages/Actions.jsx b/agent/ui/src/pages/Actions.jsx new file mode 100644 index 00000000..2eb2e5a3 --- /dev/null +++ b/agent/ui/src/pages/Actions.jsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Power, + Square, + RefreshCw, + FolderOpen, + Globe, + KeyRound, + Package, +} from 'lucide-react'; +import { useStatus } from '../ipc/hooks.js'; +import { local } from '../ipc/client.js'; + +// Actions are bucketed so the most-common operations sit on top and the +// destructive / re-pair ones live further down. A shared ActionRow renders +// them with icon + label + description + button. + +function ActionRow({ icon: Icon, title, desc, action, label = 'Run', danger }) { + const [busy, setBusy] = useState(false); + const [feedback, setFeedback] = useState(null); + + async function onClick() { + if (busy) return; + setBusy(true); + setFeedback(null); + try { + await action(); + setFeedback({ ok: true, msg: 'Done' }); + } catch (err) { + setFeedback({ ok: false, msg: err.message || 'Failed' }); + } finally { + setBusy(false); + setTimeout(() => setFeedback(null), 3000); + } + } + + return ( +
+
+ +
+
+
{title}
+
{desc}
+
+
+ {feedback && ( + + {feedback.msg} + + )} + +
+
+ ); +} + +export default function Actions() { + const { status } = useStatus(5000); + const navigate = useNavigate(); + const dashboardUrl = status?.server_url + ? status.server_url.replace(/^wss?:\/\//, 'https://').replace(/\/agent$/, '') + : ''; + + return ( +
+
+
+

Actions

+

Service controls, recovery, and quick-open shortcuts.

+
+
+ +
+
Service
+ + + +
+ +
+
Quick open
+ local.open({ path: 'C:\\ProgramData\\ServerKit\\Agent\\logs' })} + label="Open" + /> + local.open({ url: dashboardUrl })} + label="Open" + /> +
+ +
+
Pairing
+ { navigate('/pair'); }} + label="Open wizard" + /> +
+ +
+
Support
+ { + const res = await local.diag(); + if (res?.path) { + // Open the parent folder so the user sees the + // newly-created zip in Explorer rather than + // opening the zip itself. + const parent = res.path.replace(/[\\/][^\\/]*$/, ''); + await local.open({ path: parent }); + } + }} + label="Generate" + /> +
+
+ ); +} diff --git a/agent/ui/src/pages/Activity.jsx b/agent/ui/src/pages/Activity.jsx new file mode 100644 index 00000000..8f74fb93 --- /dev/null +++ b/agent/ui/src/pages/Activity.jsx @@ -0,0 +1,134 @@ +import { useMemo, useState } from 'react'; +import { + Power, + Square, + RefreshCw, + Plug, + PlugZap, + AlertTriangle, + XCircle, + Info, +} from 'lucide-react'; +import { useEvents } from '../ipc/hooks.js'; + +// Maps backend event Kind → React icon. Anything unrecognized falls through +// to the Info bubble so future kinds render gracefully without a UI patch. +const KIND_ICON = { + service_start: Power, + service_stop: Square, + restart_requested: RefreshCw, + ws_connected: PlugZap, + ws_disconnected: Plug, + ws_reconnecting: RefreshCw, + auth_failed: XCircle, + error: AlertTriangle, + info: Info, +}; + +const SEVERITIES = ['all', 'info', 'warn', 'error']; + +function formatRelative(ts) { + const diffMs = Date.now() - ts; + const sec = Math.floor(diffMs / 1000); + if (sec < 5) return 'just now'; + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + const day = Math.floor(hr / 24); + return `${day}d ago`; +} + +function formatAbsolute(ts) { + const d = new Date(ts); + return d.toLocaleString(); +} + +function EventRow({ event }) { + const Icon = KIND_ICON[event.kind] || Info; + return ( +
  • +
    + +
    +
    +
    {event.msg}
    + {event.meta && Object.keys(event.meta).length > 0 && ( +
    + {Object.entries(event.meta).map(([k, v]) => ( + + {k}: {String(v)} + + ))} +
    + )} +
    + +
  • + ); +} + +export default function Activity() { + const { events, error } = useEvents(3000); + const [filter, setFilter] = useState('all'); + + const visible = useMemo(() => { + const list = filter === 'all' + ? events + : events.filter((e) => (e.sev || 'info') === filter); + // Newest first reads better as a timeline. + return [...list].reverse(); + }, [events, filter]); + + return ( +
    +
    +
    +

    Activity

    +

    + Lifecycle events from this agent — service starts, connection changes, and operator actions. +

    +
    +
    + {SEVERITIES.map((sev) => ( + + ))} +
    +
    + + {error && ( +
    + Can't reach the agent IPC server. ({error}) +
    + )} + +
    + {visible.length === 0 ? ( +
    + {events.length === 0 + ? 'No events yet — they\'ll appear here as the agent connects, restarts, or hits errors.' + : `Nothing matches the “${filter}” filter.`} +
    + ) : ( +
      + {visible.map((ev) => ( + + ))} +
    + )} +
    +
    + ); +} diff --git a/agent/ui/src/pages/Logs.jsx b/agent/ui/src/pages/Logs.jsx new file mode 100644 index 00000000..9cd44833 --- /dev/null +++ b/agent/ui/src/pages/Logs.jsx @@ -0,0 +1,185 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Trash2, Search } from 'lucide-react'; +import { useLogs } from '../ipc/hooks.js'; +import { ipc } from '../ipc/client.js'; + +const LEVELS = ['all', 'info', 'warn', 'error']; + +// Each line is one JSON object emitted by slog. Tolerate plain text too — +// during early boot the lumberjack rotation can produce a mixed file. +function parseLine(raw) { + try { + const obj = JSON.parse(raw); + return { + time: obj.time || '', + level: (obj.level || 'INFO').toLowerCase(), + msg: obj.msg || '', + component: obj.component || '', + extras: extractExtras(obj), + raw, + }; + } catch { + return { time: '', level: 'info', msg: raw, component: '', extras: {}, raw }; + } +} + +function extractExtras(obj) { + const skip = new Set(['time', 'level', 'msg', 'component']); + const out = {}; + for (const [k, v] of Object.entries(obj)) { + if (!skip.has(k)) out[k] = v; + } + return out; +} + +function formatTime(iso) { + if (!iso) return ''; + // ISO strings are long; surface only HH:MM:SS plus milliseconds. + const m = /T(\d{2}:\d{2}:\d{2}\.\d{3})/.exec(iso); + return m ? m[1] : iso; +} + +function LogRow({ entry }) { + const lvl = entry.level || 'info'; + return ( +
    + {formatTime(entry.time)} + {lvl} + {entry.component && {entry.component}} + {entry.msg} + {Object.keys(entry.extras).length > 0 && ( + + {Object.entries(entry.extras).map(([k, v]) => ( + + {k}= + {typeof v === 'string' ? v : JSON.stringify(v)} + + ))} + + )} +
    + ); +} + +export default function Logs() { + const { lines, error } = useLogs(500, 2000); + const [filter, setFilter] = useState('all'); + const [query, setQuery] = useState(''); + const [autoScroll, setAutoScroll] = useState(true); + const [clearing, setClearing] = useState(false); + const scrollerRef = useRef(null); + + const parsed = useMemo(() => lines.map(parseLine), [lines]); + + const visible = useMemo(() => { + const q = query.trim().toLowerCase(); + return parsed.filter((e) => { + if (filter !== 'all' && e.level !== filter) return false; + if (q && !e.raw.toLowerCase().includes(q)) return false; + return true; + }); + }, [parsed, filter, query]); + + // Auto-scroll on new lines unless the user has scrolled up. Conservative + // heuristic: if the scroller is within 100 px of the bottom we treat the + // user as "following" the tail. + useEffect(() => { + if (!autoScroll || !scrollerRef.current) return; + const el = scrollerRef.current; + el.scrollTop = el.scrollHeight; + }, [visible.length, autoScroll]); + + function onScroll() { + if (!scrollerRef.current) return; + const el = scrollerRef.current; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100; + setAutoScroll(atBottom); + } + + async function handleClear() { + if (clearing) return; + setClearing(true); + try { + await ipc.clearLogs(); + } catch (err) { + // Surface the failure on the auto-scroll banner area; full error + // reporting comes when we add toast notifications later. + console.error('clearLogs failed', err); + } finally { + setClearing(false); + } + } + + return ( +
    +
    +
    +

    Logs

    +

    + Live tail of agent.log — last {lines.length} lines. +

    +
    +
    + {LEVELS.map((l) => ( + + ))} +
    +
    + +
    +
    + + setQuery(e.target.value)} + spellCheck={false} + /> +
    +
    + +
    + + {error && ( +
    + Can't reach the agent IPC server. ({error}) +
    + )} + +
    + {visible.length === 0 ? ( +
    + {lines.length === 0 + ? 'Waiting for log lines…' + : `No matches for "${query}" / ${filter}`} +
    + ) : ( + visible.map((entry, i) => ) + )} +
    + + {!autoScroll && ( +
    setAutoScroll(true)}> + Resume tail ↓ +
    + )} +
    + ); +} diff --git a/agent/ui/src/pages/Overview.jsx b/agent/ui/src/pages/Overview.jsx new file mode 100644 index 00000000..21a2379e --- /dev/null +++ b/agent/ui/src/pages/Overview.jsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from 'react'; +import { Copy, Check } from 'lucide-react'; +import { LineChart, Line, ResponsiveContainer, YAxis } from 'recharts'; +import { useStatus, useMetricsHistory } from '../ipc/hooks.js'; + +function StatusPill({ status }) { + if (!status) return Loading…; + if (!status.registered) return Not paired; + if (status.connected) return Connected; + if (status.running) return Reconnecting; + return Stopped; +} + +function CopyButton({ value }) { + const [copied, setCopied] = useState(false); + if (!value) return null; + return ( + + ); +} + +function formatUptime(seconds) { + if (!seconds || seconds < 0) return '—'; + const s = Math.floor(seconds); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const minutes = Math.floor((s % 3600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${s % 60}s`; + return `${s}s`; +} + +function Sparkline({ data, dataKey, accent }) { + if (!data || data.length === 0) { + return
    collecting samples…
    ; + } + return ( +
    + + + + + + +
    + ); +} + +export default function Overview() { + const { status, error: statusError } = useStatus(2000); + const { samples } = useMetricsHistory(2000); + + // Recharts wants plain objects; flatten the IPC payload. + const chartData = samples.map((s) => ({ + t: s.t, + cpu: s.cpu, + mem: s.mem, + })); + + const lastCpu = chartData.length ? chartData[chartData.length - 1].cpu : status?.cpu_percent; + const lastMem = chartData.length ? chartData[chartData.length - 1].mem : status?.mem_percent; + + return ( +
    +
    +
    +

    {status?.agent_name || 'Agent'}

    +
    + + {status?.version && v{status.version}} +
    +
    +
    + + {statusError && ( +
    + Can't reach the agent IPC server. Is the service running? ({statusError}) +
    + )} + +
    +

    Connection

    +
    + Server URL + + {status?.server_url || '—'} + + +
    +
    + Agent ID + + {status?.agent_id || '—'} + + +
    +
    + Transport + + {status?.transport === 'poll' ? 'Polling (REST fallback)' : 'WebSocket'} + +
    +
    + Agent version + {status?.version ? `v${status.version}` : '—'} +
    +
    + Uptime + {formatUptime(status?.uptime_seconds)} +
    +
    + +
    +
    +
    +

    CPU

    + + {lastCpu != null ? `${lastCpu.toFixed(1)}%` : '—'} + +
    + +
    +
    +
    +

    Memory

    + + {lastMem != null ? `${lastMem.toFixed(1)}%` : '—'} + +
    + +
    +
    +
    + ); +} diff --git a/agent/ui/src/pages/Pair.jsx b/agent/ui/src/pages/Pair.jsx new file mode 100644 index 00000000..34d49eec --- /dev/null +++ b/agent/ui/src/pages/Pair.jsx @@ -0,0 +1,356 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Copy, Check, Server, ArrowRight, Loader2, CircleCheck, AlertTriangle } from 'lucide-react'; +import { local } from '../ipc/client.js'; + +// Three-stage React port of the legacy walk wizard. The actual pairing +// protocol still runs in Go via internal/pairdriver — this component just +// drives the form, kicks off /local/pair/start, polls /local/pair/state, +// and renders whatever stage the backend reports. + +function CopyValue({ value, large }) { + const [copied, setCopied] = useState(false); + return ( +
    + {value} + +
    + ); +} + +function FormStage({ onConnectionString, onPairCode, busy, defaultName }) { + // Default to the connection-string tab — that's the modern entry path + // (one paste, one click). The legacy pair-code form stays available + // for users who already started from the panel's "Pair existing + // agent" tab, or who can't paste from clipboard for some reason. + const [mode, setMode] = useState('connection-string'); + const [connString, setConnString] = useState(''); + const [panelUrl, setPanelUrl] = useState(''); + const [name, setName] = useState(defaultName || ''); + const [error, setError] = useState(''); + + function submitConnString(e) { + e.preventDefault(); + const trimmed = connString.trim(); + if (!trimmed) { + setError('Paste the connection string from your panel.'); + return; + } + if (!trimmed.startsWith('sk1://')) { + setError('That doesn’t look like a connection string. Generate one from the panel’s Add Server screen.'); + return; + } + setError(''); + onConnectionString(trimmed); + } + + function submitPairCode(e) { + e.preventDefault(); + if (!panelUrl.trim()) { + setError('Panel URL is required'); + return; + } + setError(''); + onPairCode(panelUrl.trim(), name.trim()); + } + + return ( +
    +
    +
    + +
    +

    Pair this server

    +

    Connect this machine to your ServerKit panel.

    +
    + +
    + + +
    + + {mode === 'connection-string' ? ( +
    +