diff --git a/.gitattributes b/.gitattributes index ebbf0f13..dfe07704 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,2 @@ -* 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 +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 56460712..9855ea8f 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ # These are supported funding model platforms -github: [jhd3197] +github: [andreamada] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username @@ -10,6 +10,6 @@ liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username -buy_me_a_coffee: jhd3197 +buy_me_a_coffee: andreamada thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] \ No newline at end of file diff --git a/.github/workflows/agent-release.yml b/.github/workflows/agent-release.yml index aa397e03..1afb3ce6 100644 --- a/.github/workflows/agent-release.yml +++ b/.github/workflows/agent-release.yml @@ -12,7 +12,7 @@ on: type: string env: - GO_VERSION: '1.23' + GO_VERSION: '1.21' jobs: build: @@ -190,107 +190,10 @@ jobs: path: packages/*.rpm retention-days: 5 - # Build Windows MSI - build-msi: - name: Build MSI Installer - runs-on: windows-latest - needs: build - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Get version - id: version - shell: bash - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION="${{ github.event.inputs.version }}" - else - VERSION="${GITHUB_REF#refs/tags/agent-v}" - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Install WiX Toolset - run: | - dotnet tool install --global wix --version 5.0.2 - - - name: Download binary - uses: actions/download-artifact@v4 - with: - name: serverkit-agent-windows-amd64 - path: dist/ - - - name: Extract binary - shell: bash - run: | - cd dist - unzip serverkit-agent-${{ steps.version.outputs.version }}-windows-amd64.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" - - - name: Upload MSI - uses: actions/upload-artifact@v4 - with: - name: serverkit-agent-msi - path: packages/*.msi - retention-days: 5 - - # Build Docker image - build-docker: - name: Build Docker Image - runs-on: ubuntu-latest - needs: build - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Get version - id: version - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION="${{ github.event.inputs.version }}" - else - VERSION="${GITHUB_REF#refs/tags/agent-v}" - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - continue-on-error: true - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: ./agent - platforms: linux/amd64,linux/arm64 - push: ${{ secrets.DOCKERHUB_USERNAME != '' }} - tags: | - jhd3197/serverkit-agent:latest - jhd3197/serverkit-agent:${{ steps.version.outputs.version }} - build-args: | - VERSION=${{ steps.version.outputs.version }} - BUILD_TIME=${{ github.event.repository.updated_at }} - GIT_COMMIT=${{ github.sha }} - release: name: Create Release runs-on: ubuntu-latest - needs: [build, build-deb, build-rpm, build-msi, build-docker] + needs: [build, build-deb, build-rpm] permissions: contents: write @@ -322,8 +225,6 @@ jobs: find artifacts -type f -name "*.deb" -exec mv {} release/ \; # RPM packages find artifacts -type f -name "*.rpm" -exec mv {} release/ \; - # MSI installers - find artifacts -type f -name "*.msi" -exec mv {} release/ \; ls -la release/ - name: Generate checksums @@ -364,15 +265,9 @@ jobs: #### Windows Installer | Format | Architecture | Download | |--------|--------------|----------| - | MSI | x64 | `serverkit-agent-${{ steps.version.outputs.version }}-x64.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. + | ZIP | x64 | `serverkit-agent-${{ steps.version.outputs.version }}-windows-amd64.zip` | - #### Docker - ```bash - docker pull jhd3197/serverkit-agent:${{ steps.version.outputs.version }} - docker pull jhd3197/serverkit-agent:latest - ``` + > **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. ### Quick Install @@ -391,19 +286,11 @@ jobs: sudo systemctl start serverkit-agent ``` - **Windows (MSI):** - Download and run the MSI installer, then: + **Windows:** + Download and run the binary zip, then: ```powershell - serverkit-agent register --token "YOUR_TOKEN" --server "https://your-serverkit.com" - Start-Service ServerKitAgent - ``` - - **Docker:** - ```bash - docker run -d --name serverkit-agent \ - -v /var/run/docker.sock:/var/run/docker.sock:ro \ - -v serverkit-config:/etc/serverkit-agent \ - jhd3197/serverkit-agent:${{ steps.version.outputs.version }} + .\serverkit-agent.exe register --token "YOUR_TOKEN" --server "https://your-serverkit.com" + .\serverkit-agent.exe start ``` ### Verification @@ -422,7 +309,7 @@ jobs: summary: name: Build Summary runs-on: ubuntu-latest - needs: [build, build-deb, build-rpm, build-msi, build-docker, release] + needs: [build, build-deb, build-rpm, release] steps: - name: Get version @@ -457,8 +344,6 @@ jobs: echo "| DEB | arm64 | ✅ Built |" >> $GITHUB_STEP_SUMMARY echo "| RPM | x86_64 | ✅ Built |" >> $GITHUB_STEP_SUMMARY echo "| RPM | aarch64 | ✅ Built |" >> $GITHUB_STEP_SUMMARY - echo "| MSI | x64 | ✅ Built |" >> $GITHUB_STEP_SUMMARY - echo "| Docker | multi-arch | ✅ Built |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Release" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 29f74047..1df8cd72 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ nul SECURITY_AUDIT.md APP_IMPROVEMENTS.md *.png +frontend/.env.development diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 9e7d57a6..07dbb698 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -4,7 +4,7 @@ Thanks to everyone who has contributed to ServerKit! ## Core -- **Juan Denis** ([@jhd3197](https://github.com/jhd3197)) — Creator and maintainer +- **Juan Denis** ([@andreamada](https://github.com/andreamada)) — Creator and maintainer ## Contributors diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..9755ffb8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 andreamada + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b2c2ed4c..66bf3ebb 100644 --- a/README.md +++ b/README.md @@ -1,369 +1,2 @@ -
- -# ServerKit - -server-kit - -**Self-hosted infrastructure, made simple.** - -A lightweight, modern server control panel for managing web apps, databases, -Docker containers, and security — without the complexity of Kubernetes -or the cost of managed platforms. - -English | [Español](docs/README.es.md) | [中文版](docs/README.zh-CN.md) | [Português](docs/README.pt.md) - -
- -![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) -![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) -[![Discord](https://img.shields.io/discord/1470639209059455008?style=for-the-badge&logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/ZKk6tkCQfG) - -[![GitHub Stars](https://img.shields.io/github/stars/jhd3197/ServerKit?style=flat-square&color=f5c542)](https://github.com/jhd3197/ServerKit/stargazers) -[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) -[![Python](https://img.shields.io/badge/python-3.11+-3776AB.svg?style=flat-square&logo=python&logoColor=white)](https://python.org) -[![React](https://img.shields.io/badge/react-18-61DAFB.svg?style=flat-square&logo=react&logoColor=black)](https://reactjs.org) -[![Flask](https://img.shields.io/badge/flask-3.0-000000.svg?style=flat-square&logo=flask&logoColor=white)](https://flask.palletsprojects.com) -[![Nginx](https://img.shields.io/badge/nginx-reverse_proxy-009639.svg?style=flat-square&logo=nginx&logoColor=white)](https://nginx.org) -[![Let's Encrypt](https://img.shields.io/badge/SSL-Let's_Encrypt-003A70.svg?style=flat-square&logo=letsencrypt&logoColor=white)](https://letsencrypt.org) - -
- -[Features](#-features) · [Quick Start](#-quick-start) · [Screenshots](#-screenshots) · [Architecture](#-architecture) · [Roadmap](#-roadmap) · [Docs](#-documentation) · [Contributing](#-contributing) · [Discord](#-community) - -
- ---- - -

- Dashboard -

- ---- - -## 🎯 Features - -### 🚀 Apps & Deployment - -**PHP / WordPress** — PHP-FPM 8.x with one-click WordPress installation - -**Python Apps** — Deploy Flask and Django with Gunicorn - -**Node.js** — PM2-managed applications with log streaming - -**Workflow Builder** — Node-based visual automation for server tasks, deployments, and CI/CD - -**Environment Pipeline** — Multi-environment management for WordPress (Prod/Staging/Dev) with code/DB promotion - -**Docker** — Full container and Docker Compose management with real-time log streaming and terminal access - -**Marketplace** — Over 60+ one-click templates for popular apps (Immich, Ghost, Authelia, etc.) - -### 🏗️ Infrastructure - -**Domain Management** — Nginx virtual hosts with easy configuration - -**DNS Zone Management** — Full DNS record management with propagation checking (A, AAAA, CNAME, MX, TXT, etc.) - -**SSL Certificates** — Automatic Let's Encrypt with auto-renewal - -**Databases** — MySQL/MariaDB and PostgreSQL with user management and query interface - -**Cloud Provisioning** — Provision servers on DigitalOcean, Hetzner, Vultr, and Linode with cost tracking - -**Firewall** — UFW/firewalld with visual rule management and port presets - -**Cron Jobs** — Schedule tasks with a visual editor - -**File Manager** — Browse, edit, upload, and download files via web interface - -**FTP Server** — Manage vsftpd users and access - -**Backup & Restore** — Automated backups to S3, Backblaze B2, or local storage with scheduling, retention policies, and one-click restore - -**Email Server** — Postfix + Dovecot with DKIM/SPF/DMARC, SpamAssassin, Roundcube webmail, email forwarding rules - -### 🔒 Security - -**Two-Factor Auth** — TOTP-based with backup codes - -**Malware Scanning** — ClamAV integration with quarantine - -**File Integrity Monitoring** — Detect unauthorized file changes - -**Fail2ban & SSH** — Brute force protection, SSH key management, IP allowlist/blocklist - -**Vulnerability Scanning** — Lynis security audits with reports and recommendations - -**Automatic Updates** — unattended-upgrades / dnf-automatic for OS-level patching - -### 🖥️ Multi-Server Management - -**Agent-Based Architecture** — Go agent with HMAC-SHA256 authentication and real-time WebSocket gateway - -**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 - -**Agent Plugins** — Extensible plugin system with capabilities, permissions, and per-server installation - -**Server Templates** — Configuration templates with compliance tracking, drift detection, and auto-remediation - -**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 - -**Cross-Server Metrics** — Historical metrics with comparison charts and retention policies - -### 📊 Monitoring & Alerts - -**Real-time Metrics** — CPU, RAM, disk, network monitoring via WebSocket - -**Uptime Tracking** — Historical server uptime data and visualization - -**Status Pages** — Public status pages with HTTP/TCP/DNS/Ping health checks, component monitoring, and incident management - -**Notifications** — Discord, Slack, Telegram, email (HTML templates), and generic webhooks - -**Per-User Preferences** — Individual notification channels, severity filters, and quiet hours - -### 👥 Team & Access Control - -**Multi-User** — Admin, developer, and viewer roles with team invitations - -**Workspaces** — Multi-tenant workspace isolation with quotas and member management - -**RBAC** — Granular per-feature permissions (read/write per module) - -**SSO & OAuth** — Google, GitHub, OpenID Connect, and SAML 2.0 with account linking - -**Audit Logging** — Track all user actions with detailed activity dashboard - -**API Keys** — Tiered API keys (standard/elevated/unlimited) with rate limiting, usage analytics, and OpenAPI documentation - -**Webhook Subscriptions** — Event-driven webhooks with HMAC signatures, retry logic, and custom headers - -### 🎨 Customization - -**Sidebar Presets** — Switch between Full, Web Hosting, Email Admin, DevOps, and Minimal views with one click - -**Collapsible Navigation** — Sidebar groups auto-expand on navigation and collapse when switching sections - -**Accent Colors** — 8 preset accent colors plus custom hex picker - -**Custom Branding** — White-label the sidebar with your own logo, brand name, or full-width banner - -**Dashboard Widgets** — Toggle and reorder dashboard widgets to fit your workflow - ---- - -## 🚀 Quick Start - -> ⏱️ Up and running in under 2 minutes - -### Option 1: One-Line Install (Recommended) - -```bash -curl -fsSL https://serverkit.ai/install.sh | bash -``` - -> Works on Ubuntu 22.04+ and Debian 12+. Sets up everything automatically. - -### Option 2: Docker - -```bash -git clone https://github.com/jhd3197/ServerKit.git -cd ServerKit -cp .env.example .env # then edit .env with your secrets -docker compose up -d # access at http://localhost -``` - -### Option 3: Manual Installation - -See the [Installation Guide](docs/INSTALLATION.md) for step-by-step instructions. - -### Requirements - -| | Minimum | Recommended | -|---|---------|-------------| -| **OS** | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS | -| **CPU** | 1 vCPU | 2+ vCPU | -| **RAM** | 1 GB | 2+ GB | -| **Disk** | 10 GB | 20+ GB | -| **Docker** | 24.0+ | Latest | - ---- - -## 📸 Screenshots - -

- -![Workflow-Builder](https://github.com/user-attachments/assets/fc58beac-5e2c-487e-a37b-eaa6473eb325) - -

- -
-View More Screenshots - -
- -

- Docker -

- -

- Workflow Builder -

- -

- Templates -

- -

- Applications -

- -

- Applications Logs -

- -
- ---- - -## 🏗️ Architecture - -``` - ┌──────────────────┐ - │ INTERNET │ - └────────┬─────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────────────────┐ -│ YOUR SERVER │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ NGINX (Reverse Proxy) │ │ -│ │ :80 / :443 │ │ -│ │ │ │ -│ │ app1.com ──┐ app2.com ──┐ api.app3.com ──┐ │ │ -│ └───────────────┼─────────────────┼─────────────────────┼─────────────┘ │ -│ │ proxy_pass │ proxy_pass │ proxy_pass │ -│ ▼ ▼ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ DOCKER CONTAINERS │ │ -│ │ │ │ -│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │ -│ │ │ WordPress │ │ Flask │ │ Node.js │ ... │ │ -│ │ │ :8001 │ │ :8002 │ │ :8003 │ │ │ -│ │ └─────┬─────┘ └───────────┘ └───────────┘ │ │ -│ └──────────┼──────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ DATABASES │ │ -│ │ MySQL :3306 PostgreSQL :5432 Redis :6379 │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────────────────┘ -``` - -**[View Full Architecture Documentation →](docs/ARCHITECTURE.md)** — Request flow, template system, port allocation, database linking, and troubleshooting. - ---- - -## 🗺️ Roadmap - -- [x] Core infrastructure — Flask + React + JWT + WebSocket -- [x] Application management — PHP, Python, Node.js, Docker -- [x] Domain & SSL — Nginx virtual hosts, Let's Encrypt -- [x] Databases — MySQL, PostgreSQL -- [x] File & FTP management -- [x] Monitoring & alerts — Metrics, webhooks, uptime tracking -- [x] Security — 2FA, ClamAV, file integrity, Fail2ban, Lynis -- [x] Firewall — UFW/firewalld integration -- [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 -- [x] Team & permissions — RBAC, invitations, audit logging -- [x] API enhancements — API keys, rate limiting, OpenAPI docs, webhook subscriptions -- [x] SSO & OAuth — Google, GitHub, OIDC, SAML -- [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 -- [x] DNS zone management — Full record management with propagation checking -- [x] Status pages — Public status pages with health checks, incident management -- [x] Cloud provisioning — DigitalOcean, Hetzner, Vultr, Linode with cost tracking -- [x] Customizable sidebar — Collapsible groups, view presets, accent colors, white-label branding - -Full details: [ROADMAP.md](ROADMAP.md) - ---- - -## 📖 Documentation - -| Document | Description | -|----------|-------------| -| [Architecture](docs/ARCHITECTURE.md) | System design, request flow, diagrams | -| [Installation Guide](docs/INSTALLATION.md) | Complete setup instructions | -| [Deployment Guide](docs/DEPLOYMENT.md) | CLI commands and production deployment | -| [API Reference](docs/API.md) | REST API endpoints | -| [Roadmap](ROADMAP.md) | Development roadmap and planned features | -| [Contributing](CONTRIBUTING.md) | How to contribute | - ---- - -## 🧱 Tech Stack - -| Layer | Technology | -|-------|------------| -| Backend | Python 3.11, Flask, SQLAlchemy, Flask-SocketIO, Flask-Migrate | -| Frontend | React 18, Vite, SCSS, Recharts | -| Database | SQLite / PostgreSQL | -| Web Server | Nginx, Gunicorn (GeventWebSocket) | -| Containers | Docker, Docker Compose | -| Security | ClamAV, Lynis, Fail2ban, TOTP (pyotp), Fernet encryption | -| Auth | JWT, OAuth 2.0, OIDC, SAML 2.0 | -| Email | Postfix, Dovecot, SpamAssassin, Roundcube | -| Agent | Go (multi-server), HMAC-SHA256, WebSocket | - ---- - -## 🤝 Contributing - -Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) first. - -``` -fork → feature branch → commit → push → pull request -``` - -**Priority areas:** Cloud provider integrations, marketplace extensions, UI/UX improvements, documentation, test coverage. - ---- - -## 💬 Community - -[![Discord](https://img.shields.io/badge/Discord-Join_Us-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ZKk6tkCQfG) - -Join the Discord to ask questions, share feedback, or get help with your setup. - ---- - -## ⭐ Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=jhd3197/ServerKit&type=Date)](https://star-history.com/#jhd3197/ServerKit&Date) - ---- - -
- -**ServerKit** — Simple. Modern. Self-hosted. - -[Report Bug](https://github.com/jhd3197/ServerKit/issues) · [Request Feature](https://github.com/jhd3197/ServerKit/issues) - -Made with ❤️ by [Juan Denis](https://juandenis.com) - -
+# serverkit +serverkit diff --git a/VERSION b/VERSION index f689e8c1..acd81d7f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.18 +1.4.13 diff --git a/agent/Dockerfile b/agent/Dockerfile index 90418ae8..ecb6a4fb 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -4,7 +4,7 @@ # ============================================ # Stage 1: Build # ============================================ -FROM golang:1.23-alpine AS builder +FROM golang:1.21-alpine AS builder # Install build dependencies RUN apk add --no-cache git ca-certificates tzdata diff --git a/agent/cmd/agent/main.go b/agent/cmd/agent/main.go index 645b8f36..b931abfc 100644 --- a/agent/cmd/agent/main.go +++ b/agent/cmd/agent/main.go @@ -17,7 +17,7 @@ import ( ) var ( - Version = "dev" + Version = "1.0.4-dev" BuildTime = "unknown" GitCommit = "unknown" ) @@ -42,7 +42,6 @@ enabling remote Docker management, monitoring, and more.`, // Add commands rootCmd.AddCommand(startCmd()) rootCmd.AddCommand(registerCmd()) - rootCmd.AddCommand(pairCmd()) rootCmd.AddCommand(statusCmd()) rootCmd.AddCommand(versionCmd()) rootCmd.AddCommand(configCmd()) diff --git a/agent/docker-compose.yml b/agent/docker-compose.yml index 02422b2f..d3ed8940 100644 --- a/agent/docker-compose.yml +++ b/agent/docker-compose.yml @@ -15,7 +15,7 @@ services: agent: - image: jhd3197/serverkit-agent:${AGENT_VERSION:-1.0.0} + image: jhd3197/ServerKit-agent:${AGENT_VERSION:-1.0.0} container_name: serverkit-agent restart: unless-stopped diff --git a/agent/go.mod b/agent/go.mod index 4fae9b32..77af4156 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -1,6 +1,6 @@ module github.com/serverkit/agent -go 1.23.0 +go 1.21 require ( fyne.io/systray v1.11.0 @@ -9,7 +9,6 @@ require ( github.com/gorilla/websocket v1.5.1 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 ) @@ -38,7 +37,7 @@ 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.28.0 // indirect + golang.org/x/sys v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.16.1 // indirect gotest.tools/v3 v3.5.1 // indirect diff --git a/agent/go.sum b/agent/go.sum index 720000c4..beaeb7c6 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -109,11 +109,8 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w 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= diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go index 9cb2a903..3f3fabef 100644 --- a/agent/internal/agent/agent.go +++ b/agent/internal/agent/agent.go @@ -11,9 +11,6 @@ import ( "fmt" "net" "os" - "path/filepath" - "runtime" - "strings" "sync" "time" @@ -141,7 +138,6 @@ func (a *Agent) registerHandlers() { // Docker network commands a.handlers[protocol.ActionDockerNetworkList] = a.handleDockerNetworkList - a.handlers[protocol.ActionDockerNetworkRemove] = a.handleDockerNetworkRemove // Docker compose commands a.handlers[protocol.ActionDockerComposeList] = a.handleDockerComposeList @@ -779,16 +775,6 @@ 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) { @@ -815,9 +801,6 @@ func (a *Agent) handleFileRead(ctx context.Context, params json.RawMessage) (int 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 { @@ -833,10 +816,9 @@ func (a *Agent) handleFileRead(ctx context.Context, params json.RawMessage) (int 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"` + 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) @@ -844,9 +826,6 @@ func (a *Agent) handleFileWrite(ctx context.Context, params json.RawMessage) (in 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 { @@ -858,12 +837,6 @@ func (a *Agent) handleFileWrite(ctx context.Context, params json.RawMessage) (in mode = os.FileMode(p.Mode) } - if p.CreateDirs { - if err := os.MkdirAll(filepath.Dir(p.Path), 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) } @@ -885,9 +858,6 @@ func (a *Agent) handleFileList(ctx context.Context, params json.RawMessage) (int 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 { @@ -914,48 +884,6 @@ func (a *Agent) handleFileList(ctx context.Context, params json.RawMessage) (int }, 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 := filepath.Abs(filepath.Clean(path)) - if err != nil { - return fmt.Errorf("invalid path: %w", err) - } - - for _, allowedPath := range allowedPaths { - if strings.TrimSpace(allowedPath) == "" { - continue - } - allowed, err := filepath.Abs(filepath.Clean(allowedPath)) - if err != nil { - continue - } - - if pathWithinAllowedRoot(target, allowed) { - return nil - } - } - - return fmt.Errorf("file access denied for path: %s", path) -} - -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) -} - // Docker Compose command handlers func (a *Agent) handleDockerComposeList(ctx context.Context, params json.RawMessage) (interface{}, error) { @@ -969,9 +897,6 @@ 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) } @@ -984,9 +909,6 @@ 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 { @@ -1017,9 +939,6 @@ 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 { @@ -1045,9 +964,6 @@ 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 { @@ -1072,9 +988,6 @@ 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 { @@ -1099,9 +1012,6 @@ 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 { diff --git a/agent/internal/agent/registration.go b/agent/internal/agent/registration.go index e55513cc..cc5ea507 100644 --- a/agent/internal/agent/registration.go +++ b/agent/internal/agent/registration.go @@ -115,6 +115,7 @@ func (r *Registration) Register(serverURL, token, name string) (*RegistrationRes req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", fmt.Sprintf("ServerKit-Agent/%s", Version)) + req.Header.Set("ngrok-skip-browser-warning", "true") resp, err := client.Do(req) if err != nil { @@ -203,4 +204,4 @@ func (r *Registration) Unregister(serverURL, agentID, apiKey, apiSecret string) } // Version is set during build -var Version = "dev" +var Version = "1.0.4-dev" diff --git a/agent/internal/auth/auth.go b/agent/internal/auth/auth.go index c522f467..e81bfb20 100644 --- a/agent/internal/auth/auth.go +++ b/agent/internal/auth/auth.go @@ -79,13 +79,13 @@ func (a *Authenticator) VerifyTimestamp(timestamp int64, maxAgeSeconds int64) bo return diff <= maxAgeSeconds*1000 } -// GetAPIKeyPrefix returns the first 8 characters of the API key +// GetAPIKeyPrefix returns the first 12 characters of the API key // Used for identification without exposing full key 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/config/config.go b/agent/internal/config/config.go index 019b63c3..bb6ef6bf 100644 --- a/agent/internal/config/config.go +++ b/agent/internal/config/config.go @@ -122,7 +122,7 @@ func Default() *Config { Docker: true, Metrics: true, Logs: true, - FileAccess: true, + FileAccess: false, Exec: false, }, Metrics: MetricsConfig{ @@ -136,7 +136,7 @@ func Default() *Config { Timeout: 30 * time.Second, }, Security: SecurityConfig{ - AllowedPaths: defaultAllowedPaths(), + AllowedPaths: []string{}, BlockedCommands: []string{}, MaxExecTimeout: 5 * time.Minute, }, @@ -328,13 +328,6 @@ func defaultLogPath() string { return "/var/log/serverkit-agent/agent.log" } -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 func getMachineKey() []byte { // Use machine-specific data to derive key @@ -414,32 +407,3 @@ 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/docker/client.go b/agent/internal/docker/client.go index a415798b..beaf28bc 100644 --- a/agent/internal/docker/client.go +++ b/agent/internal/docker/client.go @@ -413,11 +413,6 @@ 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}) diff --git a/agent/internal/updater/updater.go b/agent/internal/updater/updater.go index ab7cacd3..8836b5cd 100644 --- a/agent/internal/updater/updater.go +++ b/agent/internal/updater/updater.go @@ -91,7 +91,7 @@ func (u *Updater) UpdateTo(ctx context.Context, version, downloadURL, checksumsU func (u *Updater) CheckForUpdate(ctx context.Context) (*VersionInfo, error) { u.log.Debug("Checking for updates", "current_version", u.currentVersion) - url := fmt.Sprintf("%s/api/servers/agent/version/check", u.serverURL) + url := fmt.Sprintf("%s/api/v1/servers/agent/version/check", u.serverURL) payload := map[string]string{ "current_version": u.currentVersion, diff --git a/agent/internal/ws/client.go b/agent/internal/ws/client.go index 47854491..265723f5 100644 --- a/agent/internal/ws/client.go +++ b/agent/internal/ws/client.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "encoding/json" "fmt" + "net" "net/http" "net/url" "os" @@ -24,19 +25,19 @@ type MessageHandler func(msgType protocol.MessageType, data []byte) // Client is a Socket.IO client with auto-reconnect type Client struct { - cfg config.ServerConfig - auth *auth.Authenticator - log *logger.Logger - conn *websocket.Conn - handler MessageHandler - session *auth.SessionToken + cfg config.ServerConfig + auth *auth.Authenticator + log *logger.Logger + conn *websocket.Conn + handler MessageHandler + session *auth.SessionToken - mu sync.RWMutex - connected bool - reconnecting bool + mu sync.RWMutex + connected bool + reconnecting bool - sendCh chan []byte - doneCh chan struct{} + sendCh chan []byte + doneCh chan struct{} reconnectCount int @@ -66,10 +67,10 @@ func (c *Client) SetHandler(handler MessageHandler) { // buildSocketIOURL converts the configured server URL to a Socket.IO WebSocket URL. // Input examples: -// - "wss://server.example.com/agent" +// - "https://epic-yeti-emerging.ngrok-free.app/agent" // - "ws://localhost:5000/agent" // -// Output: "wss://server.example.com/socket.io/?EIO=4&transport=websocket" +// Output: "wss://epic-yeti-emerging.ngrok-free.app/socket.io/?EIO=4&transport=websocket" func (c *Client) buildSocketIOURL() (string, error) { rawURL := c.cfg.URL if rawURL == "" { @@ -81,8 +82,23 @@ func (c *Client) buildSocketIOURL() (string, error) { return "", fmt.Errorf("invalid server URL: %w", err) } - // Keep the scheme (ws/wss), strip the path (e.g. /agent) - parsed.Path = "/socket.io/" + // If it's an ngrok URL, force wss scheme + if strings.Contains(parsed.Host, "ngrok") { + parsed.Scheme = "wss" + } else if parsed.Scheme == "http" { + parsed.Scheme = "ws" + } else if parsed.Scheme == "https" { + parsed.Scheme = "wss" + } + + // Socket.IO expects the base path, not the namespace path + // If the URL ends in /agent, we need to strip it + parsed.Path = strings.TrimSuffix(parsed.Path, "/agent") + if !strings.HasSuffix(parsed.Path, "/") { + parsed.Path += "/" + } + parsed.Path += "socket.io/" + q := url.Values{} q.Set("EIO", "4") q.Set("transport", "websocket") @@ -106,7 +122,14 @@ func (c *Client) Connect(ctx context.Context) error { } dialer := websocket.Dialer{ - HandshakeTimeout: 10 * time.Second, + HandshakeTimeout: 45 * time.Second, + NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // Force IPv4 (tcp4) to avoid issues with IPv6 routing through tunnels + return (&net.Dialer{ + Timeout: 45 * time.Second, + KeepAlive: 45 * time.Second, + }).DialContext(ctx, "tcp4", addr) + }, } // Only allow insecure TLS when explicitly set via environment variable @@ -118,7 +141,8 @@ 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", "ServerKit-Agent/1.0.6") + headers.Set("ngrok-skip-browser-warning", "true") c.log.Debug("Connecting to Socket.IO", "url", sioURL) @@ -168,7 +192,7 @@ func (c *Client) Connect(ctx context.Context) error { // handleEngineIOOpen reads and processes the Engine.IO OPEN packet (type 0) func (c *Client) handleEngineIOOpen() error { - c.conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + c.conn.SetReadDeadline(time.Now().Add(30 * time.Second)) _, msg, err := c.conn.ReadMessage() if err != nil { return fmt.Errorf("failed to read OPEN packet: %w", err) @@ -185,10 +209,10 @@ func (c *Client) handleEngineIOOpen() error { // Parse the JSON payload var openData struct { - SID string `json:"sid"` + SID string `json:"sid"` Upgrades []string `json:"upgrades"` - PingInterval int `json:"pingInterval"` - PingTimeout int `json:"pingTimeout"` + PingInterval int `json:"pingInterval"` + PingTimeout int `json:"pingTimeout"` } if err := json.Unmarshal([]byte(msgStr[1:]), &openData); err != nil { return fmt.Errorf("failed to parse OPEN data: %w", err) @@ -208,6 +232,9 @@ func (c *Client) handleEngineIOOpen() error { // connectNamespace sends a Socket.IO CONNECT packet to the /agent namespace func (c *Client) connectNamespace() error { + // Small delay to ensure server is ready + time.Sleep(200 * time.Millisecond) + // Socket.IO CONNECT: packet type 4 (MESSAGE) + message type 0 (CONNECT) + namespace // Wire format: "40/agent," connectMsg := fmt.Sprintf("40%s,", c.namespace) @@ -217,16 +244,32 @@ func (c *Client) connectNamespace() error { return fmt.Errorf("failed to send CONNECT: %w", err) } + c.log.Debug("Waiting for CONNECT ack from namespace", "namespace", c.namespace) + // Read namespace CONNECT ack - c.conn.SetReadDeadline(time.Now().Add(10 * time.Second)) - _, msg, err := c.conn.ReadMessage() - if err != nil { - return fmt.Errorf("failed to read CONNECT ack: %w", err) - } - c.conn.SetReadDeadline(time.Time{}) + c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + defer c.conn.SetReadDeadline(time.Time{}) - msgStr := string(msg) - c.log.Debug("Received namespace response", "raw", msgStr) + var msgStr string + for { + _, msg, err := c.conn.ReadMessage() + if err != nil { + return fmt.Errorf("failed to read CONNECT ack: %w", err) + } + + msgStr = string(msg) + c.log.Debug("Received namespace response", "raw", msgStr) + + // Handle Engine.IO ping (2) -> pong (3) + if msgStr == "2" { + c.log.Debug("Received ping during namespace connect, sending pong") + if err := c.conn.WriteMessage(websocket.TextMessage, []byte("3")); err != nil { + return fmt.Errorf("failed to send pong during namespace connect: %w", err) + } + continue + } + break + } // Expected: "40/agent,{\"sid\":\"...\"}" // Or error: "44/agent,{\"message\":\"...\"}" @@ -267,7 +310,7 @@ func (c *Client) authenticate() error { } // Wait for auth response event - c.conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + c.conn.SetReadDeadline(time.Now().Add(30 * time.Second)) defer c.conn.SetReadDeadline(time.Time{}) for { diff --git a/agent/scripts/install.sh b/agent/scripts/install.sh index 6dd4f97a..872b1b1e 100644 --- a/agent/scripts/install.sh +++ b/agent/scripts/install.sh @@ -191,11 +191,7 @@ 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 @@ -246,7 +242,7 @@ SyslogIdentifier=serverkit-agent NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes -ReadWritePaths=$LOG_DIR $CONFIG_DIR /var/lib/serverkit /var/serverkit +ReadWritePaths=$LOG_DIR $CONFIG_DIR PrivateTmp=yes [Install] diff --git a/backend/app/__init__.py b/backend/app/__init__.py index c845957d..342e2200 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,4 +1,5 @@ import os +import sqlite3 as _sqlite3 from flask import Flask, send_from_directory, request, jsonify from flask_sqlalchemy import SQLAlchemy from flask_jwt_extended import JWTManager @@ -6,6 +7,8 @@ from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_migrate import Migrate +from sqlalchemy import event +from sqlalchemy.engine import Engine from config import config @@ -13,6 +16,22 @@ jwt = JWTManager() migrate = Migrate() + +@event.listens_for(Engine, "connect") +def _sqlite_configure(dbapi_conn, _): + """Set SQLite pragmas on every new connection. + + DELETE journal mode avoids the /dev/shm shared-memory requirement of WAL mode + that causes 'attempt to write a readonly database' on some VPS/container providers. + """ + if not isinstance(dbapi_conn, _sqlite3.Connection): + return + cur = dbapi_conn.cursor() + cur.execute("PRAGMA journal_mode=DELETE") + cur.execute("PRAGMA synchronous=NORMAL") + cur.execute("PRAGMA foreign_keys=ON") + cur.close() + # PyJWT 2.10+ enforces that 'sub' must be a string. # Stringify the identity so integer user IDs work transparently. @jwt.user_identity_loader @@ -45,12 +64,64 @@ def create_app(config_name=None): migrate.init_app(app, db) jwt.init_app(app) limiter.init_app(app) + # CORS - Allow both dev server and Flask server + cors_origins = app.config.get('CORS_ORIGINS', []) + if isinstance(cors_origins, str): + cors_origins = [o.strip() for o in cors_origins.split(',') if o.strip()] + + # In development, ensure localhost origins are present + if app.debug: + dev_origins = [ + 'http://localhost:5173', 'http://localhost:5274', 'http://localhost:5000', 'http://localhost:5001', + 'http://127.0.0.1:5173', 'http://127.0.0.1:5274', 'http://127.0.0.1:5000', 'http://127.0.0.1:5001' + ] + for origin in dev_origins: + if origin not in cors_origins: + cors_origins.append(origin) + + # Also allow ngrok origins if they appear in requests, + # and ensure localhost origins are allowed even when proxying through ngrok + @app.after_request + def add_dev_cors(response): + origin = request.headers.get('Origin') + if origin: + if ('.ngrok-free.app' in origin or '.ngrok.io' in origin or + 'localhost:' in origin or '127.0.0.1:' in origin): + response.headers['Access-Control-Allow-Origin'] = origin + response.headers['Access-Control-Allow-Credentials'] = 'true' + response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Requested-With, X-API-Key, Accept, Origin, ngrok-skip-browser-warning' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS, PATCH' + return response + + # Log unexpected errors in development + @app.errorhandler(500) + def handle_500(e): + import traceback + app.logger.error(f"Internal Server Error: {str(e)}") + app.logger.error(traceback.format_exc()) + + # Create response + response = jsonify({ + 'error': 'Internal Server Error', + 'message': str(e), + 'traceback': traceback.format_exc() if app.debug else None + }) + + # Explicitly add CORS headers to error response in dev + origin = request.headers.get('Origin') + if origin: + response.headers['Access-Control-Allow-Origin'] = origin + response.headers['Access-Control-Allow-Credentials'] = 'true' + + return response, 500 + CORS( app, - origins=app.config['CORS_ORIGINS'], + origins=cors_origins, supports_credentials=True, - allow_headers=['Content-Type', 'Authorization', 'X-Requested-With', 'X-API-Key'], - methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'] + allow_headers=['Content-Type', 'Authorization', 'X-Requested-With', 'X-API-Key', 'Accept', 'Origin', 'ngrok-skip-browser-warning'], + methods=['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], + expose_headers=['Content-Type', 'Authorization'] ) # Register security headers middleware @@ -142,11 +213,9 @@ def create_app(config_name=None): from app.api.deploy import deploy_bp app.register_blueprint(deploy_bp, url_prefix='/api/v1/deploy') - # Register blueprints - Builds & Deployments - from app.api.builds import builds_bp - from app.api.deployment_jobs import deployment_jobs_bp - app.register_blueprint(builds_bp, url_prefix='/api/v1/builds') - app.register_blueprint(deployment_jobs_bp, url_prefix='/api/v1/deployment-jobs') + # Register blueprints - Builds & Deployments + from app.api.builds import builds_bp + app.register_blueprint(builds_bp, url_prefix='/api/v1/builds') # Register blueprints - Templates from app.api.templates import templates_bp @@ -278,22 +347,6 @@ def create_app(config_name=None): from app.api.marketplace import marketplace_bp app.register_blueprint(marketplace_bp, url_prefix='/api/v1/marketplace') - # Register blueprints - Plugins - from app.api.plugins import plugins_bp - app.register_blueprint(plugins_bp, url_prefix='/api/v1/plugins') - - # Register blueprints - Agent Pairing (RustDesk-style short-code flow) - from app.api.pairing import pairing_bp - app.register_blueprint(pairing_bp, url_prefix='/api/v1/pairing') - - # Load installed plugins (dynamic blueprints) - try: - from app.services.plugin_service import load_all_plugins - load_all_plugins(app) - except Exception as e: - import logging - logging.getLogger(__name__).warning(f'Plugin loader: {e}') - # Handle database migrations (Alembic) with app.app_context(): from app.services.migration_service import MigrationService @@ -322,9 +375,6 @@ def create_app(config_name=None): # Start hourly analytics aggregation and event retry threads _start_api_background_threads(app) - # Start hourly pruner for expired pending agent pairings - _start_pairing_pruner(app) - # Request body size limit app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB limit @@ -332,6 +382,9 @@ def create_app(config_name=None): @app.before_request def check_2fa_pending(): """Reject 2FA pending tokens on non-2FA endpoints.""" + if request.method == 'OPTIONS': + return + from flask_jwt_extended import verify_jwt_in_request, get_jwt if request.endpoint and request.path.startswith('/api/'): # Allow 2FA verification endpoints @@ -453,41 +506,6 @@ def _check_auto_sync_schedules(logger): _api_bg_thread = None -def _start_pairing_pruner(app): - """Start a background thread that prunes expired PendingAgent rows hourly.""" - global _pairing_prune_thread - if _pairing_prune_thread is not None: - return - - import threading - import time - import logging - - logger = logging.getLogger(__name__) - - def prune_loop(): - # Wait a bit before first run so app is fully initialized. - time.sleep(60) - while True: - try: - with app.app_context(): - from app.services import pairing_service - pairing_service.prune_expired() - except Exception as e: - logger.error(f'Pairing pruner error: {e}') - time.sleep(3600) - - _pairing_prune_thread = threading.Thread( - target=prune_loop, - daemon=True, - name='pairing-pruner' - ) - _pairing_prune_thread.start() - - -_pairing_prune_thread = None - - def _start_api_background_threads(app): """Start background threads for API analytics aggregation and event delivery retry.""" global _api_bg_thread diff --git a/backend/app/agent_gateway.py b/backend/app/agent_gateway.py index 6d513197..7e066192 100644 --- a/backend/app/agent_gateway.py +++ b/backend/app/agent_gateway.py @@ -44,12 +44,15 @@ def on_connect(self): """Handle agent connection attempt""" # Connection is not authenticated yet # Agent must send auth message first - print(f"[AgentGateway] New connection from {request.remote_addr}") + user_agent = request.headers.get('User-Agent', 'unknown') + print(f"[AgentGateway] New connection attempt from {request.remote_addr} (UA: {user_agent})") + print(f"[AgentGateway] Sid: {request.sid}") + # Note: Socket.IO implicitly sends the 'sid' back to the client after this function returns. - def on_disconnect(self): + def on_disconnect(self, *args): """Handle agent disconnection""" sid = request.sid - print(f"[AgentGateway] Disconnect: {sid}") + print(f"[AgentGateway] Disconnect (sid: {sid}, args: {args})") agent_registry.unregister_agent(sid, reason='disconnect') def on_auth(self, data): diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index 56377467..97cc52b4 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -5,31 +5,12 @@ from app import db from app.models import Application, User from app.services.docker_service import DockerService -from app.services.remote_docker_service import RemoteDockerService from app.services.log_service import LogService from app import paths apps_bp = Blueprint('apps', __name__) -def _compose_target(app): - if app.server_id and app.root_path: - return os.path.join(app.root_path, 'docker-compose.yml') - return app.root_path - - -def _agent_result_failed(result): - data = result.get('data') if isinstance(result, dict) else None - return isinstance(data, dict) and data.get('success') is False - - -def _agent_result_error(result, fallback): - data = result.get('data') if isinstance(result, dict) else None - if isinstance(data, dict): - return data.get('error') or result.get('error') or fallback - return result.get('error') or fallback - - # ==================== ENVIRONMENT LINKING ==================== @apps_bp.route('//link', methods=['POST']) @@ -444,17 +425,9 @@ def start_app(app_id): # Handle Docker apps if app.app_type == 'docker' and app.root_path: - if app.server_id: - result = RemoteDockerService.compose_up( - app.server_id, - _compose_target(app), - detach=True, - user_id=current_user_id - ) - else: - result = DockerService.compose_up(app.root_path, detach=True) - if not result.get('success') or _agent_result_failed(result): - return jsonify({'error': _agent_result_error(result, 'Failed to start containers')}), 400 + result = DockerService.compose_up(app.root_path, detach=True) + if not result.get('success'): + return jsonify({'error': result.get('error', 'Failed to start containers')}), 400 app.status = 'running' db.session.commit() @@ -480,16 +453,9 @@ def stop_app(app_id): # Handle Docker apps if app.app_type == 'docker' and app.root_path: - if app.server_id: - result = RemoteDockerService.compose_down( - app.server_id, - _compose_target(app), - user_id=current_user_id - ) - else: - result = DockerService.compose_down(app.root_path) - if not result.get('success') or _agent_result_failed(result): - return jsonify({'error': _agent_result_error(result, 'Failed to stop containers')}), 400 + result = DockerService.compose_down(app.root_path) + if not result.get('success'): + return jsonify({'error': result.get('error', 'Failed to stop containers')}), 400 app.status = 'stopped' db.session.commit() @@ -515,16 +481,9 @@ def restart_app(app_id): # Handle Docker apps if app.app_type == 'docker' and app.root_path: - if app.server_id: - result = RemoteDockerService.compose_restart( - app.server_id, - _compose_target(app), - user_id=current_user_id - ) - else: - result = DockerService.compose_restart(app.root_path) - if not result.get('success') or _agent_result_failed(result): - return jsonify({'error': _agent_result_error(result, 'Failed to restart containers')}), 400 + result = DockerService.compose_restart(app.root_path) + if not result.get('success'): + return jsonify({'error': result.get('error', 'Failed to restart containers')}), 400 app.status = 'running' db.session.commit() @@ -554,15 +513,6 @@ def get_app_logs(app_id): # For Docker apps, get docker compose logs if app.app_type == 'docker' and app.root_path: - if app.server_id: - result = RemoteDockerService.compose_logs( - app.server_id, - _compose_target(app), - tail=lines, - user_id=current_user_id - ) - return jsonify(result.get('data') if result.get('success') else result), 200 if result.get('success') else 400 - result = LogService.get_docker_app_logs(app.name, app.root_path, lines) return jsonify(result), 200 if result.get('success') else 400 @@ -734,17 +684,7 @@ def get_app_status(app_id): if app.app_type == 'docker' and app.root_path: # Get container status from Docker - if app.server_id: - result = RemoteDockerService.compose_ps( - app.server_id, - _compose_target(app), - user_id=current_user_id - ) - if not result.get('success'): - return jsonify(result), 503 if result.get('code') == 'AGENT_OFFLINE' else 400 - containers = result.get('data', []) - else: - containers = DockerService.compose_ps(app.root_path) + containers = DockerService.compose_ps(app.root_path) # Determine overall status running_count = sum(1 for c in containers if c.get('Status', c.get('status', '')).startswith('Up')) @@ -766,7 +706,7 @@ def get_app_status(app_id): # Check port accessibility port_status = None - if app.port and not app.server_id: + if app.port: port_status = DockerService.check_port_accessible(app.port) return jsonify({ diff --git a/backend/app/api/servers.py b/backend/app/api/servers.py index 1f5702ae..38c372eb 100644 --- a/backend/app/api/servers.py +++ b/backend/app/api/servers.py @@ -22,32 +22,6 @@ servers_bp = Blueprint('servers', __name__) -def _get_external_base_url(): - """Return the public origin agents should use when this app sits behind a proxy.""" - public_url = current_app.config.get('PUBLIC_URL') or os.environ.get('SERVERKIT_PUBLIC_URL') - if public_url: - return public_url.rstrip('/') - - forwarded_proto = request.headers.get('X-Forwarded-Proto', request.scheme) - forwarded_host = request.headers.get('X-Forwarded-Host', request.host) - - scheme = forwarded_proto.split(',')[0].strip() or request.scheme - host = forwarded_host.split(',')[0].strip() or request.host - - return f"{scheme}://{host}".rstrip('/') - - -def _get_external_websocket_url(): - base_url = _get_external_base_url() - - if base_url.startswith('https://'): - return f"wss://{base_url[len('https://'):]}/agent" - if base_url.startswith('http://'): - return f"ws://{base_url[len('http://'):]}/agent" - - return f"{base_url}/agent" - - # ==================== Permission Profiles ==================== PERMISSION_PROFILES = { @@ -55,12 +29,18 @@ def _get_external_websocket_url(): 'name': 'Docker Read-Only', 'description': 'View containers, images, and metrics', 'permissions': [ - 'docker:container:read', - 'docker:image:read', - 'docker:compose:read', - 'docker:volume:read', - 'docker:network:read', - 'system:metrics:read', + 'docker:container:list', + 'docker:container:inspect', + 'docker:container:stats', + 'docker:container:logs', + 'docker:image:list', + 'docker:compose:list', + 'docker:compose:ps', + 'docker:compose:logs', + 'docker:volume:list', + 'docker:network:list', + 'system:metrics', + 'system:info', ] }, 'docker_manager': { @@ -72,24 +52,9 @@ def _get_external_websocket_url(): 'docker:compose:*', 'docker:volume:*', 'docker:network:*', - 'system:metrics:read', - 'system:logs:read', - ] - }, - 'deployment_runner': { - 'name': 'Deployment Runner', - 'description': 'Deploy and operate ServerKit-managed Docker Compose apps', - 'permissions': [ - 'docker:container:*', - 'docker:image:*', - 'docker:compose:*', - 'docker:volume:*', - 'docker:network:*', - 'file:read', - 'file:write', - 'file:list', - 'system:metrics:read', - 'system:logs:read', + 'system:metrics', + 'system:info', + 'system:processes', ] }, 'full_access': { @@ -231,41 +196,59 @@ def create_server(): Returns server info with registration token for agent installation. """ - data = request.get_json() - user_id = get_jwt_identity() - - if not data.get('name'): - return jsonify({'error': 'Name is required'}), 400 - - # Generate registration token - registration_token = Server.generate_registration_token() + try: + data = request.get_json() + user_id = get_jwt_identity() + + if not data: + return jsonify({'error': 'No data provided'}), 400 + + if not data.get('name'): + return jsonify({'error': 'Name is required'}), 400 + + # Generate registration token + registration_token = Server.generate_registration_token() + + # Get permissions from profile or custom list + permissions = data.get('permissions', []) + profile = data.get('permission_profile') + if profile and profile in PERMISSION_PROFILES: + permissions = PERMISSION_PROFILES[profile]['permissions'] + + # Ensure we have valid integers/strings for foreign keys + group_id = data.get('group_id') + if group_id == "": + group_id = None + + server = Server( + name=data['name'], + description=data.get('description'), + group_id=group_id, + tags=data.get('tags', []), + permissions=permissions, + allowed_ips=data.get('allowed_ips', []), + registered_by=int(user_id) if user_id else None, + registration_token_expires=datetime.utcnow() + timedelta(hours=24) + ) + server.set_registration_token(registration_token) - # Get permissions from profile or custom list - permissions = data.get('permissions', []) - profile = data.get('permission_profile') - if profile and profile in PERMISSION_PROFILES: - permissions = PERMISSION_PROFILES[profile]['permissions'] + db.session.add(server) + db.session.commit() - server = Server( - name=data['name'], - description=data.get('description'), - group_id=data.get('group_id'), - tags=data.get('tags', []), - permissions=permissions, - allowed_ips=data.get('allowed_ips', []), - registered_by=user_id, - registration_token_expires=datetime.utcnow() + timedelta(hours=24) - ) - server.set_registration_token(registration_token) - - db.session.add(server) - db.session.commit() + result = server.to_dict() + result['registration_token'] = registration_token + result['registration_expires'] = server.registration_token_expires.isoformat() - result = server.to_dict() - result['registration_token'] = registration_token - result['registration_expires'] = server.registration_token_expires.isoformat() - - return jsonify(result), 201 + return jsonify(result), 201 + except Exception as e: + import traceback + current_app.logger.error(f"Error creating server: {str(e)}") + current_app.logger.error(traceback.format_exc()) + return jsonify({ + 'error': 'Internal Server Error', + 'message': str(e), + 'traceback': traceback.format_exc() if current_app.debug else None + }), 500 @servers_bp.route('/', methods=['GET']) @@ -279,6 +262,18 @@ def get_server(server_id): result = server.to_dict(include_metrics=True) result['is_connected'] = agent_registry.is_agent_connected(server.id) + # Augment with live connection IP (agent registry → last active session → server record) + if not result.get('ip_address'): + agent = agent_registry.get_agent(server.id) + if agent and agent.ip_address: + result['ip_address'] = agent.ip_address + else: + session = AgentSession.query.filter_by( + server_id=server.id, is_active=True + ).order_by(AgentSession.connected_at.desc()).first() + if session and session.ip_address: + result['ip_address'] = session.ip_address + return jsonify(result) @@ -427,6 +422,12 @@ def register_agent(): db.session.commit() + # Construct WebSocket URL + # Force wss if we're behind ngrok or if the request is secure + is_ngrok = 'ngrok' in request.host + ws_scheme = 'wss' if (request.is_secure or is_ngrok) else 'ws' + ws_url = f"{ws_scheme}://{request.host}/agent" + # Security note: api_secret is returned once during registration so the agent # can store it. The server-side copy is stored encrypted. The registration token # is already cleared above (single-use), preventing re-registration. @@ -435,7 +436,7 @@ def register_agent(): 'name': server.name, 'api_key': api_key, 'api_secret': api_secret, - 'websocket_url': _get_external_websocket_url(), + 'websocket_url': ws_url, 'server_id': server.id }) @@ -1094,22 +1095,6 @@ def list_remote_volumes(server_id): return jsonify(result.get('data', [])) -@servers_bp.route('//docker/volumes/', methods=['DELETE']) -@jwt_required() -@developer_required -def remove_remote_volume(server_id, volume_name): - """Remove a volume on a remote server""" - user_id = get_jwt_identity() - force = request.args.get('force', 'false').lower() == 'true' - - result = RemoteDockerService.remove_volume(server_id, volume_name, force=force, user_id=user_id) - - if not result.get('success'): - return jsonify(result), 500 - - return jsonify({'message': 'Volume removed'}) - - @servers_bp.route('//docker/networks', methods=['GET']) @jwt_required() def list_remote_networks(server_id): @@ -1124,19 +1109,21 @@ def list_remote_networks(server_id): return jsonify(result.get('data', [])) -@servers_bp.route('//docker/networks/', methods=['DELETE']) -@jwt_required() -@developer_required -def remove_remote_network(server_id, network_id): - """Remove a network on a remote server""" - user_id = get_jwt_identity() - - result = RemoteDockerService.remove_network(server_id, network_id, user_id=user_id) - - if not result.get('success'): - return jsonify(result), 500 - - return jsonify({'message': 'Network removed'}) +def _handle_agent_response(result): + """Common handler for agent service responses to return appropriate HTTP status codes.""" + if result.get('success'): + return jsonify(result.get('data')) + + code = result.get('code') + status = 500 + if code == 'PERMISSION_DENIED': + status = 403 + elif code == 'AGENT_OFFLINE': + status = 503 + elif code == 'TIMEOUT': + status = 504 + + return jsonify(result), status @servers_bp.route('//system/metrics', methods=['GET']) @@ -1144,13 +1131,8 @@ def remove_remote_network(server_id, network_id): def get_remote_system_metrics(server_id): """Get system metrics from a remote server""" user_id = get_jwt_identity() - result = RemoteDockerService.get_system_metrics(server_id, user_id=user_id) - - if not result.get('success'): - return jsonify(result), 500 - - return jsonify(result.get('data')) + return _handle_agent_response(result) @servers_bp.route('//system/info', methods=['GET']) @@ -1158,13 +1140,8 @@ def get_remote_system_metrics(server_id): def get_remote_system_info(server_id): """Get system info from a remote server""" user_id = get_jwt_identity() - result = RemoteDockerService.get_system_info(server_id, user_id=user_id) - - if not result.get('success'): - return jsonify(result), 500 - - return jsonify(result.get('data')) + return _handle_agent_response(result) # ==================== Remote Docker Compose Operations ==================== @@ -1516,7 +1493,7 @@ def get_install_script_linux(): the ServerKit agent on Linux systems. Usage: - curl -fsSL https://your-server/api/v1/servers/install.sh | sudo bash -s -- \\ + curl -fsSL https://your-server/api/servers/install.sh | sudo bash -s -- \\ --token "YOUR_TOKEN" --server "https://your-server" """ script_path = os.path.join(_get_scripts_dir(), 'install.sh') @@ -1528,7 +1505,7 @@ def get_install_script_linux(): content = f.read() # Replace placeholders with actual values - server_url = _get_external_base_url() + server_url = request.url_root.rstrip('/') content = content.replace('https://your-serverkit.com', server_url) content = content.replace('jhd3197/ServerKit', GITHUB_REPO) @@ -1551,7 +1528,7 @@ def get_install_script_windows(): the ServerKit agent on Windows systems. Usage: - irm https://your-server/api/v1/servers/install.ps1 | iex; \\ + irm https://your-server/api/servers/install.ps1 | iex; \\ Install-ServerKitAgent -Token "YOUR_TOKEN" -Server "https://your-server" """ script_path = os.path.join(_get_scripts_dir(), 'install.ps1') @@ -1563,7 +1540,7 @@ def get_install_script_windows(): content = f.read() # Replace placeholders with actual values - server_url = _get_external_base_url() + server_url = request.url_root.rstrip('/') content = content.replace('https://your-serverkit.com', server_url) content = content.replace('jhd3197/ServerKit', GITHUB_REPO) @@ -1583,8 +1560,8 @@ def get_install_instructions(server_id): """ Get installation instructions for a specific server. - Returns installation commands with the correct API endpoint. Registration - tokens are only shown when they are generated, so the UI supplies the token. + Returns the installation commands with the server's registration token + already embedded. """ server = Server.query.get(server_id) if not server: @@ -1604,8 +1581,8 @@ def get_install_instructions(server_id): }), 400 # Get base URL - base_url = _get_external_base_url() - api_url = f"{base_url}/api/v1/servers" + base_url = request.url_root.rstrip('/') + api_url = f"{base_url}/api/servers" return jsonify({ 'linux': { @@ -1652,57 +1629,64 @@ def _get_latest_agent_release(): if _releases_cache['data'] and _releases_cache['expires'] and _releases_cache['expires'] > now: return _releases_cache['data'] - try: - # Fetch releases from GitHub - response = requests.get( - f'https://api.github.com/repos/{GITHUB_REPO}/releases', - headers={'Accept': 'application/vnd.github.v3+json'}, - timeout=10 - ) - response.raise_for_status() - releases = response.json() - - # Find latest agent release - for release in releases: - if release.get('tag_name', '').startswith('agent-v'): - version = release['tag_name'].replace('agent-v', '') - - # Build assets map - assets = {} - for asset in release.get('assets', []): - name = asset['name'] - if 'linux-amd64' in name: - assets['linux-amd64'] = asset['browser_download_url'] - elif 'linux-arm64' in name: - assets['linux-arm64'] = asset['browser_download_url'] - elif 'windows-amd64' in name: - assets['windows-amd64'] = asset['browser_download_url'] - elif name == 'checksums.txt': - assets['checksums'] = asset['browser_download_url'] - - result = { - 'version': version, - 'tag': release['tag_name'], - 'published_at': release['published_at'], - 'release_url': release['html_url'], - 'assets': assets, - 'body': release.get('body', '') - } - - # Cache for 5 minutes - _releases_cache['data'] = result - _releases_cache['expires'] = now + timedelta(minutes=5) - - return result - - return None - - except Exception as e: - current_app.logger.error(f"Failed to fetch GitHub releases: {e}") - # Return cached data if available, even if expired - if _releases_cache['data']: - return _releases_cache['data'] - return None + def fetch_from_repo(repo): + try: + response = requests.get( + f'https://api.github.com/repos/{repo}/releases', + headers={'Accept': 'application/vnd.github.v3+json'}, + timeout=10 + ) + if response.status_code != 200: + return None + + releases = response.json() + for release in releases: + if release.get('tag_name', '').startswith('agent-v'): + version = release['tag_name'].replace('agent-v', '') + assets = {} + for asset in release.get('assets', []): + name = asset['name'] + if 'linux-amd64' in name: + assets['linux-amd64'] = asset['browser_download_url'] + elif 'linux-arm64' in name: + assets['linux-arm64'] = asset['browser_download_url'] + elif 'windows-amd64' in name: + assets['windows-amd64'] = asset['browser_download_url'] + elif name == 'checksums.txt': + assets['checksums'] = asset['browser_download_url'] + + return { + 'version': version, + 'tag': release['tag_name'], + 'published_at': release['published_at'], + 'release_url': release['html_url'], + 'assets': assets, + 'body': release.get('body', '') + } + return None + except Exception as e: + current_app.logger.error(f"Failed to fetch releases from {repo}: {e}") + return None + + # Try configured repo + result = fetch_from_repo(GITHUB_REPO) + + # Fallback to official repo if no releases found and we aren't already using it + if not result and GITHUB_REPO != 'jhd3197/ServerKit': + current_app.logger.info(f"No releases found in {GITHUB_REPO}, falling back to official repo.") + result = fetch_from_repo('jhd3197/ServerKit') + + if result: + # Cache for 5 minutes + _releases_cache['data'] = result + _releases_cache['expires'] = now + timedelta(minutes=5) + return result + + # Return cached data if available, even if expired, as a last resort + if _releases_cache['data']: + return _releases_cache['data'] + + return None @servers_bp.route('/agent/version', methods=['GET']) diff --git a/backend/app/api/system.py b/backend/app/api/system.py index 668949b8..7eff9a19 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -126,6 +126,9 @@ def get_metrics(): current_user_id = get_jwt_identity() user = User.query.get(current_user_id) + if not user: + return jsonify({'error': 'User not found'}), 401 + if user.role != 'admin': return jsonify({'error': 'Admin access required'}), 403 @@ -140,6 +143,9 @@ def get_system_info(): current_user_id = get_jwt_identity() user = User.query.get(current_user_id) + if not user: + return jsonify({'error': 'User not found'}), 401 + if user.role != 'admin': return jsonify({'error': 'Admin access required'}), 403 diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py index dc73328e..2bb8cc20 100644 --- a/backend/app/api/templates.py +++ b/backend/app/api/templates.py @@ -11,7 +11,6 @@ from flask import Blueprint, request, jsonify from flask_jwt_extended import jwt_required, get_jwt_identity from app.models import User, Application -from app.services.deployment_job_service import DeploymentJobService from app.services.template_service import TemplateService templates_bp = Blueprint('templates', __name__) @@ -152,23 +151,15 @@ def install_template(template_id): }), 400 user_variables = data.get('variables', {}) - server_id = data.get('server_id') or data.get('target_server_id') - wait = bool(data.get('wait', False)) - result = DeploymentJobService.install_template( + result = TemplateService.install_template( template_id=template_id, app_name=app_name, user_variables=user_variables, - user_id=current_user_id, - server_id=server_id, - wait=wait, + user_id=current_user_id ) - if not result.get('success'): - return jsonify(result), 400 - - status = result.get('job', {}).get('status') - return jsonify(result), 201 if status == 'succeeded' else 202 + return jsonify(result), 201 if result.get('success') else 400 except Exception as e: import traceback diff --git a/backend/app/api/workspaces.py b/backend/app/api/workspaces.py index fcc55a56..b45bb88a 100644 --- a/backend/app/api/workspaces.py +++ b/backend/app/api/workspaces.py @@ -66,6 +66,16 @@ def create_workspace(): return jsonify(ws.to_dict()), 201 except ValueError as e: return jsonify({'error': str(e)}), 400 + except Exception as e: + import traceback + from flask import current_app + current_app.logger.error(f"Error creating workspace: {str(e)}") + current_app.logger.error(traceback.format_exc()) + return jsonify({ + 'error': 'Internal Server Error', + 'message': str(e), + 'traceback': traceback.format_exc() + }), 500 @workspaces_bp.route('/', methods=['PUT']) diff --git a/backend/app/middleware/api_analytics.py b/backend/app/middleware/api_analytics.py index aee428d9..ac2c41dd 100644 --- a/backend/app/middleware/api_analytics.py +++ b/backend/app/middleware/api_analytics.py @@ -17,13 +17,16 @@ def register_api_analytics(app): @app.before_request def record_request_start(): """Record request start time.""" + if request.method == 'OPTIONS': + return + if request.path.startswith('/api/'): g.request_start_time = time.time() @app.after_request def record_request_metrics(response): """Log API request metrics.""" - if not request.path.startswith('/api/'): + if request.method == 'OPTIONS' or not request.path.startswith('/api/'): return response start_time = getattr(g, 'request_start_time', None) diff --git a/backend/app/middleware/api_key_auth.py b/backend/app/middleware/api_key_auth.py index 64601885..c36831cf 100644 --- a/backend/app/middleware/api_key_auth.py +++ b/backend/app/middleware/api_key_auth.py @@ -8,6 +8,9 @@ def register_api_key_auth(app): @app.before_request def authenticate_api_key(): """Check for X-API-Key header and validate.""" + if request.method == 'OPTIONS': + return + api_key_header = request.headers.get('X-API-Key') if not api_key_header: diff --git a/backend/app/middleware/security.py b/backend/app/middleware/security.py index d592153b..03222afd 100644 --- a/backend/app/middleware/security.py +++ b/backend/app/middleware/security.py @@ -8,6 +8,11 @@ def register_security_headers(app: Flask): @app.after_request def add_security_headers(response): + # Skip for OPTIONS requests (CORS preflight) + from flask import request + if request.method == 'OPTIONS': + return response + # Prevent clickjacking response.headers['X-Frame-Options'] = 'DENY' @@ -29,7 +34,7 @@ def add_security_headers(response): "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self'", - "connect-src 'self' ws: wss: http://localhost:* http://127.0.0.1:*", + "connect-src 'self' ws: wss: http://localhost:* http://127.0.0.1:* https://*.ngrok-free.app https://*.ngrok.io", "frame-ancestors 'none'", ] else: diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b39a4275..a6fea628 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,8 +3,7 @@ from app.models.domain import Domain from app.models.env_variable import EnvironmentVariable, EnvironmentVariableHistory from app.models.notification_preferences import NotificationPreferences -from app.models.deployment import Deployment, DeploymentDiff -from app.models.deployment_job import DeploymentJob, DeploymentJobLog +from app.models.deployment import Deployment, DeploymentDiff from app.models.system_settings import SystemSettings from app.models.audit_log import AuditLog from app.models.metrics_history import MetricsHistory @@ -30,11 +29,10 @@ from app.models.status_page import StatusPage, StatusComponent, HealthCheck, StatusIncident, StatusIncidentUpdate from app.models.cloud_server import CloudProvider, CloudServer, CloudSnapshot from app.models.marketplace import Extension, ExtensionInstall -from app.models.pending_agent import PendingAgent __all__ = [ 'User', 'Application', 'Domain', 'EnvironmentVariable', 'EnvironmentVariableHistory', - 'NotificationPreferences', 'Deployment', 'DeploymentDiff', 'DeploymentJob', 'DeploymentJobLog', 'SystemSettings', 'AuditLog', + 'NotificationPreferences', 'Deployment', 'DeploymentDiff', 'SystemSettings', 'AuditLog', 'MetricsHistory', 'Workflow', 'WorkflowExecution', 'WorkflowLog', 'GitWebhook', 'WebhookLog', 'GitDeployment', 'Server', 'ServerGroup', 'ServerMetrics', 'ServerCommand', 'AgentSession', 'AgentVersion', 'AgentRollout', 'SecurityAlert', 'WordPressSite', 'DatabaseSnapshot', 'SyncJob', @@ -49,6 +47,5 @@ 'DNSZone', 'DNSRecord', 'StatusPage', 'StatusComponent', 'HealthCheck', 'StatusIncident', 'StatusIncidentUpdate', 'CloudProvider', 'CloudServer', 'CloudSnapshot', - 'Extension', 'ExtensionInstall', - 'PendingAgent', + 'Extension', 'ExtensionInstall' ] diff --git a/backend/app/models/application.py b/backend/app/models/application.py index 8b35630c..51c9f122 100644 --- a/backend/app/models/application.py +++ b/backend/app/models/application.py @@ -36,13 +36,11 @@ class Application(db.Model): # Foreign keys user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - server_id = db.Column(db.String(36), db.ForeignKey('servers.id'), nullable=True, index=True) # Relationships # Use 'subquery' to eagerly load domains in a single query, avoiding N+1 domains = db.relationship('Domain', backref='application', lazy='subquery', cascade='all, delete-orphan') linked_app = db.relationship('Application', remote_side=[id], backref='linked_from', foreign_keys=[linked_app_id]) - server = db.relationship('Server', backref=db.backref('applications', lazy='dynamic')) def to_dict(self, include_linked=False): import json @@ -67,8 +65,6 @@ def to_dict(self, include_linked=False): 'updated_at': self.updated_at.isoformat(), 'last_deployed_at': self.last_deployed_at.isoformat() if self.last_deployed_at else None, 'user_id': self.user_id, - 'server_id': self.server_id, - 'server_name': self.server.name if self.server else 'Local server', 'domains': [d.to_dict() for d in self.domains] } if include_linked and self.linked_app: diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py index 9f58ef30..a6e77ac1 100644 --- a/backend/app/models/audit_log.py +++ b/backend/app/models/audit_log.py @@ -11,7 +11,7 @@ class AuditLog(db.Model): action = db.Column(db.String(100), nullable=False, index=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) target_type = db.Column(db.String(50), nullable=True) # user, app, setting, etc. - target_id = db.Column(db.Integer, nullable=True) + target_id = db.Column(db.String(64), nullable=True) details = db.Column(db.Text, nullable=True) # JSON string ip_address = db.Column(db.String(45), nullable=True) # IPv6 compatible user_agent = db.Column(db.String(500), nullable=True) @@ -52,6 +52,14 @@ class AuditLog(db.Model): ACTION_INVITATION_ACCEPT = 'invitation.accept' ACTION_USER_PERMISSIONS_UPDATE = 'user.permissions_update' ACTION_USER_PERMISSIONS_RESET = 'user.permissions_reset' + + # Workspace actions + ACTION_WORKSPACE_CREATE = 'workspace.create' + ACTION_WORKSPACE_UPDATE = 'workspace.update' + ACTION_WORKSPACE_DELETE = 'workspace.delete' + ACTION_WORKSPACE_ARCHIVE = 'workspace.archive' + ACTION_WORKSPACE_RESTORE = 'workspace.restore' + ACTION_RESOURCE_CREATE = 'resource.create' # Legacy/Common def get_details(self): """Return parsed details JSON.""" diff --git a/backend/app/models/server.py b/backend/app/models/server.py index 81c3830d..442ce7d9 100644 --- a/backend/app/models/server.py +++ b/backend/app/models/server.py @@ -4,6 +4,31 @@ from werkzeug.security import generate_password_hash, check_password_hash from app import db +# Maps generic convenience permissions to the specific action names they cover. +# Used so servers registered with broad roles (e.g. system:read) can still +# pass the per-action permission check in has_permission(). +PERMISSION_ALIASES = { + 'system:read': ['system:info', 'system:metrics', 'system:processes'], + 'docker:read': [ + 'docker:container:list', 'docker:container:inspect', + 'docker:container:stats', 'docker:container:logs', + 'docker:image:list', + 'docker:compose:list', 'docker:compose:ps', 'docker:compose:logs', + 'docker:volume:list', + 'docker:network:list', + ], + 'docker:write': [ + 'docker:container:start', 'docker:container:stop', + 'docker:container:restart', 'docker:container:remove', + 'docker:container:create', 'docker:container:exec', + 'docker:image:pull', 'docker:image:remove', 'docker:image:build', + 'docker:volume:create', 'docker:volume:remove', + 'docker:network:create', 'docker:network:remove', + 'docker:compose:up', 'docker:compose:down', 'docker:compose:restart', + 'docker:compose:pull', + ], +} + class ServerGroup(db.Model): """Group servers for organization""" @@ -54,15 +79,6 @@ class Server(db.Model): """Represents a remote server managed by ServerKit""" __tablename__ = 'servers' - DOCKER_READ_ACTIONS = { - 'container': {'list', 'inspect', 'logs', 'stats'}, - 'image': {'list'}, - 'volume': {'list'}, - 'network': {'list'}, - 'compose': {'list', 'ps', 'logs'}, - } - SYSTEM_READ_ACTIONS = {'metrics', 'info', 'processes'} - id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) # Basic Info @@ -256,64 +272,71 @@ def verify_pending_api_key(self, api_key): def has_permission(self, scope): """Check if server/agent has a specific permission scope""" - if not self.permissions: + import logging + logger = logging.getLogger(__name__) + + # Ensure scope is a string + scope = str(scope) + + # Ensure permissions is a list + perms = self.permissions + if perms is None: + perms = [] + if isinstance(perms, str): + import json + try: + perms = json.loads(perms) + except: + perms = [perms] + if not isinstance(perms, list): + perms = [] + + if not perms: + logger.debug(f"Permission check for {scope}: False (no permissions)") return False - if '*' in self.permissions: + + if '*' in perms: + logger.debug(f"Permission check for {scope}: True (wildcard *)") return True - candidate_scopes = self._expand_permission_scope(scope) - for perm in self.permissions: - for candidate in candidate_scopes: - if self._permission_matches(perm, candidate): - return True - return False - - @classmethod - def _expand_permission_scope(cls, scope): - """Add profile and legacy aliases for an agent command scope.""" - candidate_scopes = {scope} - scope_parts = scope.split(':') - - if len(scope_parts) >= 3 and scope_parts[0] == 'docker': - resource = scope_parts[1] - action = scope_parts[2] - read_actions = cls.DOCKER_READ_ACTIONS.get(resource, set()) - - if action in read_actions: - candidate_scopes.add(f'docker:{resource}:read') - candidate_scopes.add('docker:read') - else: - candidate_scopes.add(f'docker:{resource}:write') - candidate_scopes.add('docker:write') - - if len(scope_parts) >= 2 and scope_parts[0] == 'system': - action = scope_parts[1] - if action in cls.SYSTEM_READ_ACTIONS: - candidate_scopes.add(f'system:{action}:read') - candidate_scopes.add('system:metrics:read') - candidate_scopes.add('system:read') - - return candidate_scopes - - @staticmethod - def _permission_matches(permission, scope): - """Check exact and wildcard patterns like docker:* or docker:container:*.""" - if permission == scope: + # Check exact match + if scope in perms: + logger.debug(f"Permission check for {scope}: True (exact match)") return True + # Check alias expansion (e.g. 'system:read' grants 'system:info', 'system:metrics') + for perm in perms: + granted = PERMISSION_ALIASES.get(str(perm), []) + if scope in granted: + logger.debug(f"Permission check for {scope}: True (alias match via {perm})") + return True + scope_parts = scope.split(':') - perm_parts = permission.split(':') + for perm in perms: + perm = str(perm) + perm_parts = perm.split(':') + + # Handle wildcard match (e.g., 'docker:*' matches 'docker:container:list') + if '*' in perm_parts: + star_idx = perm_parts.index('*') + if scope_parts[:star_idx] == perm_parts[:star_idx]: + logger.debug(f"Permission check for {scope}: True (wildcard match with {perm})") + return True - if len(perm_parts) > len(scope_parts): - return False + # Handle sub-scope match (e.g., 'system:metrics:read' matches 'system:metrics') + if len(perm_parts) > len(scope_parts): + if perm_parts[:len(scope_parts)] == scope_parts: + logger.debug(f"Permission check for {scope}: True (sub-scope match with {perm})") + return True - for i, part in enumerate(perm_parts): - if part == '*': - return True - if i >= len(scope_parts) or part != scope_parts[i]: - return False + # Handle parent-scope match (e.g., 'system:metrics' matches 'system:metrics:read') + if len(scope_parts) > len(perm_parts): + if scope_parts[:len(perm_parts)] == perm_parts: + logger.debug(f"Permission check for {scope}: True (parent-scope match with {perm})") + return True - return True + logger.debug(f"Permission check for {scope}: False (no match found in {perms})") + return False def to_dict(self, include_metrics=False): result = { @@ -361,7 +384,7 @@ class ServerMetrics(db.Model): """Historical metrics from servers""" __tablename__ = 'server_metrics' - id = db.Column(db.BigInteger, primary_key=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) server_id = db.Column(db.String(36), db.ForeignKey('servers.id'), nullable=False, index=True) timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True) diff --git a/backend/app/paths.py b/backend/app/paths.py index fd2f1635..05d7f862 100644 --- a/backend/app/paths.py +++ b/backend/app/paths.py @@ -23,3 +23,31 @@ VMAIL_UID = 5000 VMAIL_GID = 5000 EMAIL_CONFIG_DIR = os.path.join(SERVERKIT_CONFIG_DIR, 'email') + + +def ensure_paths(): + """Ensure all required directories exist.""" + paths = [ + SERVERKIT_DIR, + SERVERKIT_CONFIG_DIR, + SERVERKIT_LOG_DIR, + SERVERKIT_BACKUP_DIR, + SERVERKIT_CACHE_DIR, + SERVERKIT_QUARANTINE_DIR, + APPS_DIR, + DEPLOYMENTS_DIR, + TEMPLATES_DIR, + BUILD_LOG_DIR, + BUILD_CACHE_DIR, + DB_BACKUP_DIR, + WP_BACKUP_DIR, + SNAPSHOT_DIR, + EMAIL_CONFIG_DIR + ] + for path in paths: + try: + os.makedirs(path, exist_ok=True) + except Exception as e: + # If it's a permission error on an absolute path like /var/serverkit, + # we just log it. In production, these should be created by setup scripts. + print(f"Warning: Could not create directory {path}: {e}") diff --git a/backend/app/services/agent_registry.py b/backend/app/services/agent_registry.py index 0118aa56..10ec6f9e 100644 --- a/backend/app/services/agent_registry.py +++ b/backend/app/services/agent_registry.py @@ -326,6 +326,11 @@ def update_heartbeat(self, server_id: str, metrics: dict = None, client_timestam def _store_metrics(self, server_id: str, metrics: dict): """Store metrics from heartbeat""" try: + # Verify server exists to avoid FK constraint failure + server = Server.query.get(server_id) + if not server: + return + metric = ServerMetrics( server_id=server_id, cpu_percent=metrics.get('cpu_percent'), @@ -374,6 +379,7 @@ def send_command( # Check permissions server = Server.query.get(server_id) if server and not server.has_permission(action): + logger.warning(f"Permission denied: server={server_id}, action={action}, server_perms={server.permissions}") return { 'success': False, 'error': f'Permission denied for action: {action}', @@ -529,7 +535,9 @@ def verify_agent_auth( # Check timestamp (allow 5 minute window) now = int(time.time() * 1000) - if abs(now - timestamp) > 60000: # 60 seconds + drift = abs(now - timestamp) + if drift > 60000: # 60 seconds + print(f"[AgentRegistry] Auth failed: Timestamp drift too high ({drift}ms). Now: {now}, Sent: {timestamp}") if ip_address: anomaly_detection_service.track_auth_attempt(None, False, ip_address) return None @@ -537,6 +545,7 @@ def verify_agent_auth( # Find server by agent_id server = Server.query.filter_by(agent_id=agent_id).first() if not server: + print(f"[AgentRegistry] Auth failed: Agent ID {agent_id} not found in database") if ip_address: anomaly_detection_service.track_auth_attempt(None, False, ip_address) return None @@ -550,6 +559,7 @@ def verify_agent_auth( ) if not prefix_matches and not pending_prefix_matches: + print(f"[AgentRegistry] Auth failed: API key prefix mismatch. Expected {server.api_key_prefix}, got {api_key_prefix}") anomaly_detection_service.track_auth_attempt(server.id, False, ip_address) return None @@ -578,6 +588,7 @@ def verify_agent_auth( ).hexdigest() if not hmac.compare_digest(signature, expected_signature): + print(f"[AgentRegistry] Auth failed: Signature mismatch for server {server.id}. Message: {message}") anomaly_detection_service.track_auth_attempt(server.id, False, ip_address) return None diff --git a/backend/app/services/remote_docker_service.py b/backend/app/services/remote_docker_service.py index 93c24974..89941914 100644 --- a/backend/app/services/remote_docker_service.py +++ b/backend/app/services/remote_docker_service.py @@ -13,6 +13,140 @@ from app.models.server import Server +def _fmt_bytes(n: int) -> str: + """Convert byte count to human-readable string (e.g. 4.2 GB).""" + if not n: + return '0 B' + for unit in ('B', 'KB', 'MB', 'GB', 'TB'): + if n < 1024: + return f'{n:.1f} {unit}' + n /= 1024 + return f'{n:.1f} PB' + + +def _time_from_agent_timestamp(ts_ms) -> dict: + """Build a time dict from the agent's Unix-millisecond timestamp (UTC).""" + from datetime import datetime as _dt + try: + dt = _dt.utcfromtimestamp(int(ts_ms) / 1000) if ts_ms else _dt.utcnow() + except (TypeError, ValueError, OSError): + dt = _dt.utcnow() + return { + 'current_time': dt.isoformat(), + 'current_time_formatted': dt.strftime('%Y-%m-%d %H:%M:%S'), + 'timezone_id': 'UTC', + 'timezone_name': 'UTC', + 'utc_offset': 'UTC+0:00', + 'utc_offset_seconds': 0, + } + + +def _normalize_agent_metrics(data: dict) -> dict: + """Convert the agent's flat SystemMetrics struct to the nested shape the dashboard expects.""" + mem_total = data.get('memory_total', 0) + mem_used = data.get('memory_used', 0) + mem_free = max(mem_total - mem_used, 0) + + swap_total = data.get('swap_total', 0) + swap_used = data.get('swap_used', 0) + + disk_total = data.get('disk_total', 0) + disk_used = data.get('disk_used', 0) + disk_free = max(disk_total - disk_used, 0) + + net_rx = data.get('network_rx', 0) + net_tx = data.get('network_tx', 0) + + return { + 'cpu': { + 'percent': data.get('cpu_percent', 0), + 'count_logical': data.get('cpu_threads', data.get('cpu_cores', 0)), + 'count_physical': data.get('cpu_cores', 0), + 'per_core': data.get('cpu_per_core', []), + }, + 'memory': { + 'ram': { + 'total': mem_total, + 'used': mem_used, + 'free': mem_free, + 'percent': data.get('memory_percent', 0), + 'total_human': _fmt_bytes(mem_total), + 'used_human': _fmt_bytes(mem_used), + 'free_human': _fmt_bytes(mem_free), + 'cached_human': '0 B', + }, + 'swap': { + 'total': swap_total, + 'used': swap_used, + 'percent': data.get('swap_percent', 0), + 'total_human': _fmt_bytes(swap_total), + 'used_human': _fmt_bytes(swap_used), + }, + }, + 'disk': { + 'partitions': [{ + 'device': '/', + 'mountpoint': '/', + 'total': disk_total, + 'used': disk_used, + 'free': disk_free, + 'percent': data.get('disk_percent', 0), + 'total_human': _fmt_bytes(disk_total), + 'used_human': _fmt_bytes(disk_used), + 'free_human': _fmt_bytes(disk_free), + }], + }, + 'network': { + 'io': { + 'bytes_recv': net_rx, + 'bytes_sent': net_tx, + 'bytes_recv_human': _fmt_bytes(net_rx), + 'bytes_sent_human': _fmt_bytes(net_tx), + 'bytes_recv_rate': data.get('network_rx_rate', 0), + 'bytes_sent_rate': data.get('network_tx_rate', 0), + }, + }, + 'system': { + 'uptime_seconds': data.get('uptime', 0), + 'hostname': data.get('hostname', ''), + 'kernel': data.get('kernel_version', ''), + 'ip_address': '', + }, + 'load_average': { + '1min': data.get('load_avg_1', 0), + '5min': data.get('load_avg_5', 0), + '15min': data.get('load_avg_15', 0), + }, + 'time': _time_from_agent_timestamp(data.get('timestamp')), + 'timestamp': data.get('timestamp', ''), + } + + +def _normalize_agent_system_info(data: dict) -> dict: + """Convert the agent's flat SystemInfo struct to the shape the dashboard expects.""" + return { + 'hostname': data.get('hostname', ''), + 'os': data.get('os', ''), + 'platform': data.get('platform', data.get('os', '')), + 'kernel': data.get('kernel_version', ''), + 'architecture': data.get('architecture', ''), + 'ip_address': '', + # Flat keys kept for OverviewTab compatibility + 'cpu_model': data.get('cpu_model', ''), + 'cpu_cores': data.get('cpu_cores', 0), + 'cpu_threads': data.get('cpu_threads', 0), + 'total_memory': data.get('total_memory', 0), + 'total_disk': data.get('total_disk', 0), + # Nested keys for Dashboard compatibility + 'cpu': { + 'model': data.get('cpu_model', ''), + 'architecture': data.get('architecture', ''), + 'cores': data.get('cpu_cores', 0), + 'threads': data.get('cpu_threads', 0), + }, + } + + class RemoteDockerService: """ Service for executing Docker commands on remote servers. @@ -338,24 +472,6 @@ def list_networks(server_id: str, user_id: int = None) -> Dict[str, Any]: user_id=user_id ) - @staticmethod - def remove_network(server_id: str, network_id: str, user_id: int = None) -> Dict[str, Any]: - """Remove a network on a remote server""" - if not server_id or server_id == 'local': - from app.services.docker_service import DockerService - try: - DockerService.remove_network(network_id) - return {'success': True} - except Exception as e: - return {'success': False, 'error': str(e)} - - return agent_registry.send_command( - server_id=server_id, - action='docker:network:remove', - params={'id': network_id}, - user_id=user_id - ) - # ==================== System ==================== @staticmethod @@ -369,12 +485,19 @@ def get_system_metrics(server_id: str, user_id: int = None) -> Dict[str, Any]: except Exception as e: return {'success': False, 'error': str(e)} - return agent_registry.send_command( + result = agent_registry.send_command( server_id=server_id, action='system:metrics', params={}, user_id=user_id ) + if result.get('success') and isinstance(result.get('data'), dict): + normalized = _normalize_agent_metrics(result['data']) + server = Server.query.get(server_id) + if server: + normalized['system']['ip_address'] = server.ip_address or '' + result['data'] = normalized + return result @staticmethod def get_system_info(server_id: str, user_id: int = None) -> Dict[str, Any]: @@ -387,12 +510,19 @@ def get_system_info(server_id: str, user_id: int = None) -> Dict[str, Any]: except Exception as e: return {'success': False, 'error': str(e)} - return agent_registry.send_command( + result = agent_registry.send_command( server_id=server_id, action='system:info', params={}, user_id=user_id ) + if result.get('success') and isinstance(result.get('data'), dict): + normalized = _normalize_agent_system_info(result['data']) + server = Server.query.get(server_id) + if server: + normalized['ip_address'] = server.ip_address or '' + result['data'] = normalized + return result # ==================== Utility ==================== diff --git a/backend/app/services/system_service.py b/backend/app/services/system_service.py index 194ba083..01b4ec28 100644 --- a/backend/app/services/system_service.py +++ b/backend/app/services/system_service.py @@ -405,14 +405,23 @@ def set_timezone(cls, timezone_id): @classmethod def get_all_metrics(cls): - """Get all system metrics at once.""" - return { - 'cpu': cls.get_cpu_metrics(), - 'memory': cls.get_memory_metrics(), - 'disk': cls.get_disk_metrics(), - 'network': cls.get_network_metrics(), - 'load_average': cls.get_load_average(), - 'system': cls.get_system_info(), - 'time': cls.get_server_time(), - 'timestamp': datetime.utcnow().isoformat() - } + """Get all system metrics at once with error handling.""" + try: + return { + 'cpu': cls.get_cpu_metrics(), + 'memory': cls.get_memory_metrics(), + 'disk': cls.get_disk_metrics(), + 'network': cls.get_network_metrics(), + 'load_average': cls.get_load_average(), + 'system': cls.get_system_info(), + 'time': cls.get_server_time(), + 'timestamp': datetime.utcnow().isoformat() + } + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Error collecting metrics: {e}", exc_info=True) + # Return partial data if possible or at least a valid structure + return { + 'error': str(e), + 'timestamp': datetime.utcnow().isoformat() + } diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py index 34787e2f..9a360633 100644 --- a/backend/app/services/template_service.py +++ b/backend/app/services/template_service.py @@ -470,247 +470,6 @@ def get_template(cls, template_id: str) -> Dict: return {'success': False, 'error': 'Template not found'} - @classmethod - def build_install_plan(cls, template_id: str, app_name: str, - user_variables: Dict = None, user_id: int = None, - server_id: str = None) -> Dict: - """Build a reusable deployment plan for installing a template. - - The returned plan can be executed locally or by a connected agent. - """ - result = cls.get_template(template_id) - if not result.get('success'): - return result - - template = result['template'] - variables_result = cls._prepare_install_variables( - template_id, - template, - app_name, - user_variables or {}, - ) - if not variables_result.get('success'): - return variables_result - - variables = variables_result['variables'] - app_path = os.path.join(cls.INSTALLED_DIR, app_name) - compose_file = os.path.join(app_path, 'docker-compose.yml') - - compose_result = cls._render_compose_and_files(template, variables, app_path) - if not compose_result.get('success'): - return compose_result - - install_info = { - 'template_id': template_id, - 'template_version': template.get('version'), - 'template_name': template.get('name'), - 'installed_at': datetime.now().isoformat(), - 'variables': variables, - 'user_id': user_id, - 'server_id': server_id, - } - - env_content = ''.join(f"{key}={value}\n" for key, value in variables.items()) - - app_port = None - for port_var in ['PORT', 'HTTP_PORT', 'WEB_PORT']: - if port_var in variables: - try: - app_port = int(variables[port_var]) - break - except (ValueError, TypeError): - pass - - files = [ - { - 'path': compose_file, - 'content': compose_result['compose_content'], - 'mode': 0o644, - }, - { - 'path': os.path.join(app_path, '.serverkit-template.json'), - 'content': json.dumps(install_info, indent=2), - 'mode': 0o600, - }, - { - 'path': os.path.join(app_path, '.env'), - 'content': env_content, - 'mode': 0o600, - }, - ] - files.extend(compose_result.get('files', [])) - - steps = [] - for file_def in files: - steps.append({ - 'type': 'file.write', - 'name': f"Write {os.path.basename(file_def['path'])}", - 'path': file_def['path'], - 'content': file_def['content'], - 'mode': file_def.get('mode', 0o644), - 'create_dirs': True, - }) - - steps.append({ - 'type': 'docker.compose.up', - 'name': 'Start Docker Compose stack', - 'project_dir': app_path, - 'compose_file': compose_file, - 'detach': True, - 'build': True, - 'timeout': 300, - }) - steps.append({ - 'type': 'sleep', - 'name': 'Wait for containers to initialize', - 'seconds': 3, - }) - steps.append({ - 'type': 'docker.compose.ps', - 'name': 'Capture container status', - 'project_dir': app_path, - 'compose_file': compose_file, - 'timeout': 30, - }) - - return { - 'success': True, - 'plan': { - 'kind': 'template_install', - 'template_id': template_id, - 'template_name': template.get('name'), - 'template_version': template.get('version'), - 'app_name': app_name, - 'app_path': app_path, - 'compose_file': compose_file, - 'variables': variables, - 'port': app_port, - 'server_id': server_id, - 'steps': steps, - }, - 'template': template, - 'variables': variables, - 'app_path': app_path, - 'port': app_port, - } - - @classmethod - def _prepare_install_variables(cls, template_id: str, template: Dict, - app_name: str, user_variables: Dict) -> Dict: - """Prepare template variables for installation.""" - variables = { - 'APP_NAME': app_name, - } - template_vars = template.get('variables', {}) - - if isinstance(template_vars, list): - template_vars = {v['name']: v for v in template_vars if isinstance(v, dict) and 'name' in v} - - for var_name, var_config in template_vars.items(): - var_type = var_config.get('type', 'string') - - if var_type == 'port': - variables[var_name] = cls.generate_value(var_config) - elif user_variables and var_name in user_variables and user_variables[var_name]: - variables[var_name] = user_variables[var_name] - elif var_config.get('required', False) and var_name not in user_variables: - return {'success': False, 'error': f"Required variable not provided: {var_name}"} - else: - variables[var_name] = cls.generate_value(var_config) - - if template_id == 'wordpress-external-db': - db_check = cls.validate_mysql_connection( - host=variables.get('DB_HOST'), - port=variables.get('DB_PORT', '3306'), - user=variables.get('DB_USER'), - password=variables.get('DB_PASSWORD'), - database=variables.get('DB_NAME') - ) - if not db_check.get('success'): - return { - 'success': False, - 'error': f"Database connection failed: {db_check.get('error')}" - } - - return {'success': True, 'variables': variables} - - @classmethod - def _render_compose_and_files(cls, template: Dict, variables: Dict, app_path: str) -> Dict: - """Render compose YAML and any template-defined files in memory.""" - try: - compose = cls.substitute_in_dict(template.get('compose', {}), variables) - if 'version' in compose: - del compose['version'] - - rendered_files = [] - bind_mounts = [] - - for file_def in template.get('files', []) or []: - container_path = file_def.get('path') - content = file_def.get('content', '') - if not container_path: - continue - - content = cls.substitute_variables(content, variables) - filename = os.path.basename(container_path) - rendered_files.append({ - 'path': os.path.join(app_path, filename), - 'content': content, - 'mode': int(file_def.get('mode', 0o644)), - }) - bind_mounts.append({ - 'local': f'./{filename}', - 'container': container_path, - 'container_dir': os.path.dirname(container_path), - }) - - if bind_mounts: - cls._apply_bind_mounts_to_compose(compose, bind_mounts) - - return { - 'success': True, - 'compose_content': yaml.dump(compose, default_flow_style=False, sort_keys=False), - 'files': rendered_files, - } - except Exception as e: - return {'success': False, 'error': f'Failed to render template: {str(e)}'} - - @classmethod - def _apply_bind_mounts_to_compose(cls, compose: Dict, bind_mounts: List[Dict]) -> None: - """Apply file bind mounts to a compose dictionary.""" - volumes_to_remove = set() - - for service in compose.get('services', {}).values(): - volumes = service.get('volumes', []) - new_volumes = [] - - for vol in volumes: - if isinstance(vol, str): - parts = vol.split(':') - if len(parts) >= 2: - mount_target = parts[1].rstrip('/') - should_replace = any( - mount_target == mount['container_dir'].rstrip('/') - for mount in bind_mounts - ) - if should_replace: - volumes_to_remove.add(parts[0]) - continue - new_volumes.append(vol) - - for mount in bind_mounts: - bind_mount = f"{mount['local']}:{mount['container']}" - if bind_mount not in new_volumes: - new_volumes.append(bind_mount) - - service['volumes'] = new_volumes - - if 'volumes' in compose: - for volume_name in volumes_to_remove: - compose['volumes'].pop(volume_name, None) - if not compose['volumes']: - del compose['volumes'] - @classmethod def install_template(cls, template_id: str, app_name: str, user_variables: Dict = None, user_id: int = None) -> Dict: diff --git a/backend/app/services/workspace_service.py b/backend/app/services/workspace_service.py index 3be852c1..140ae74d 100644 --- a/backend/app/services/workspace_service.py +++ b/backend/app/services/workspace_service.py @@ -42,6 +42,13 @@ def create_workspace(data, user_id): if not name: raise ValueError('Workspace name required') + # Ensure unique name (SQLite doesn't handle this as gracefully as slug) + base_name = name + counter = 1 + while Workspace.query.filter_by(name=name).first(): + name = f'{base_name} ({counter})' + counter += 1 + slug = WorkspaceService._slugify(name) # Ensure unique slug base_slug = slug diff --git a/backend/app/sockets.py b/backend/app/sockets.py index ae703ca2..545c4849 100644 --- a/backend/app/sockets.py +++ b/backend/app/sockets.py @@ -30,10 +30,21 @@ def init_socketio(app): """Initialize SocketIO with the Flask app.""" + # In development, allow all origins to support ngrok tunnels + cors_allowed_origins = "*" if app.debug else app.config.get('CORS_ORIGINS', []) + + # async_mode must match the gunicorn worker: + # - production uses GeventWebSocketWorker → 'gevent' + # - development uses werkzeug dev server via socketio.run() → 'threading' + async_mode = 'threading' if app.debug else 'gevent' + socketio.init_app( app, - cors_allowed_origins=app.config.get('CORS_ORIGINS', '*'), - async_mode='threading' + cors_allowed_origins=cors_allowed_origins, + async_mode=async_mode, + ping_timeout=60, + ping_interval=25, + max_http_buffer_size=1e7 ) return socketio diff --git a/backend/cli.py b/backend/cli.py index 6c667541..b045047c 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -564,21 +564,32 @@ def factory_reset(): config['installed'] = {} with open(template_config, 'w') as f: json.dump(config, f, indent=2) - click.echo(click.style('✓ Cleared template cache', fg='green')) + click.echo(click.style(' Cleared template cache', fg='green')) except Exception as e: - click.echo(click.style(f'✗ Failed to clear template cache: {e}', fg='red')) + click.echo(click.style(f' Failed to clear template cache: {e}', fg='red')) - # 7. Drop and recreate database via Alembic + # 7. Drop and recreate database try: db.drop_all() + # Clear alembic version table manually to ensure fresh migration + try: + db.session.execute(text('DROP TABLE IF EXISTS alembic_version')) + db.session.commit() + except: + db.session.rollback() + from app.services.migration_service import MigrationService + # Fresh initialization instead of just applying migrations + db.create_all() + + # Now apply migrations to ensure alembic_version is set result = MigrationService.apply_migrations(app) if result['success']: - click.echo(click.style('✓ Reset database', fg='green')) + click.echo(click.style(' Reset database', fg='green')) else: - click.echo(click.style(f'✗ Migration after reset failed: {result["error"]}', fg='red')) + click.echo(click.style(' Reset database (base schema)', fg='green')) except Exception as e: - click.echo(click.style(f'✗ Failed to reset database: {e}', fg='red')) + click.echo(click.style(f' Failed to reset database: {e}', fg='red')) click.echo(click.style('\nFactory reset completed!', fg='green')) click.echo('Run "serverkit create-admin" to create a new admin user.') @@ -647,105 +658,5 @@ def list_apps(show_all): click.echo('') -@cli.command('list-servers') -def list_servers(): - """List deployment targets.""" - app = create_app() - with app.app_context(): - from app.services.remote_docker_service import RemoteDockerService - - servers = RemoteDockerService.get_available_servers() - click.echo(f"\n{'ID':<38} {'Name':<28} {'Status':<12} {'Target'}") - click.echo('-' * 90) - for server in servers: - target = 'local' if server.get('is_local') else server.get('group_name') or 'remote' - click.echo( - f"{server.get('id'):<38} " - f"{server.get('name'):<28} " - f"{server.get('status', '-'):<12} " - f"{target}" - ) - click.echo('') - - -@cli.command('deploy-template') -@click.argument('template_id') -@click.option('--name', 'app_name', required=True, help='Application name') -@click.option('--target', 'server_id', default='local', help='Target server ID or "local"') -@click.option('--var', 'variables', multiple=True, help='Template variable in KEY=VALUE form') -@click.option('--wait', is_flag=True, help='Wait for deployment to finish') -def deploy_template(template_id, app_name, server_id, variables, wait): - """Deploy a template to local or remote ServerKit target.""" - app = create_app() - with app.app_context(): - from app.services.deployment_job_service import DeploymentJobService - - parsed_vars = {} - for item in variables: - if '=' not in item: - click.echo(click.style(f'Invalid --var value: {item}. Use KEY=VALUE.', fg='red')) - sys.exit(1) - key, value = item.split('=', 1) - parsed_vars[key] = value - - result = DeploymentJobService.install_template( - template_id=template_id, - app_name=app_name, - user_variables=parsed_vars, - server_id=server_id, - wait=wait, - ) - - if not result.get('success'): - click.echo(click.style(result.get('error', 'Deployment failed'), fg='red')) - sys.exit(1) - - job = result.get('job', {}) - click.echo(click.style(f'Deployment job created: {job.get("id")}', fg='green')) - click.echo(f'Status: {job.get("status")}') - click.echo(f'Target: {job.get("target_server_name")}') - - if wait: - if job.get('status') == 'succeeded': - click.echo(click.style(f'App created: {job.get("result", {}).get("app_name")}', fg='green')) - else: - click.echo(click.style(job.get('error_message') or 'Deployment did not complete successfully', fg='red')) - sys.exit(1) - else: - click.echo(f'Check status: serverkit deployment-status {job.get("id")}') - - -@cli.command('deployment-status') -@click.argument('job_id') -@click.option('--logs', is_flag=True, help='Show job logs') -def deployment_status(job_id, logs): - """Show deployment job status.""" - app = create_app() - with app.app_context(): - from app.services.deployment_job_service import DeploymentJobService - - job = DeploymentJobService.get_job(job_id, include_logs=logs) - if not job: - click.echo(click.style('Deployment job not found', fg='red')) - sys.exit(1) - - click.echo(f"\nJob: {job['id']}") - click.echo(f"Kind: {job['kind']}") - click.echo(f"Status: {job['status']} ({job['progress_percent']}%)") - click.echo(f"Target: {job['target_server_name']}") - if job.get('app_name'): - click.echo(f"App: {job['app_name']}") - if job.get('error_message'): - click.echo(click.style(f"Error: {job['error_message']}", fg='red')) - - if logs: - click.echo('\nLogs') - click.echo('-' * 80) - for entry in job.get('logs', []): - prefix = f"[{entry['step_index']}] " if entry.get('step_index') else '' - click.echo(f"{entry['created_at']} {entry['level'].upper():<5} {prefix}{entry['message']}") - click.echo('') - - if __name__ == '__main__': cli() diff --git a/backend/config.py b/backend/config.py index cadd43da..ed96da61 100644 --- a/backend/config.py +++ b/backend/config.py @@ -3,6 +3,9 @@ import warnings from datetime import timedelta +# Get base directory of the backend +basedir = os.path.abspath(os.path.dirname(__file__)) + # Default insecure keys that must be changed in production INSECURE_SECRET_KEYS = [ 'dev-secret-key-change-in-production', @@ -16,8 +19,32 @@ class Config: SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') # Database - use instance folder for Flask convention - SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:////app/instance/serverkit.db') + # If DATABASE_URL is not set, use a local sqlite database in the instance folder + default_db_path = os.path.join(basedir, 'instance', 'serverkit.db') + + # Ensure instance directory exists + os.makedirs(os.path.dirname(default_db_path), exist_ok=True) + + db_url = os.environ.get('DATABASE_URL') + if db_url and db_url.startswith('sqlite:///'): + # Convert relative SQLite paths to absolute to avoid CWD issues in different environments + sqlite_path = db_url.replace('sqlite:///', '') + if not os.path.isabs(sqlite_path): + db_url = f'sqlite:///{os.path.abspath(os.path.join(basedir, sqlite_path))}' + + # Ensure the directory for the provided SQLite path exists + actual_db_path = db_url.replace('sqlite:///', '') + os.makedirs(os.path.dirname(actual_db_path), exist_ok=True) + + SQLALCHEMY_DATABASE_URI = db_url or f'sqlite:///{default_db_path}' SQLALCHEMY_TRACK_MODIFICATIONS = False + # Use DELETE journal mode: no /dev/shm dependency, works in all container environments. + # WAL mode can fail with "attempt to write a readonly database" when /dev/shm is + # restricted (common on some VPS/container providers). + SQLALCHEMY_ENGINE_OPTIONS = { + 'connect_args': {'check_same_thread': False}, + 'pool_pre_ping': True, + } # JWT JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production') @@ -25,7 +52,7 @@ class Config: JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=30) # CORS - Allow both dev server and Flask server - CORS_ORIGINS = os.environ.get('CORS_ORIGINS', 'http://localhost:5173,http://localhost:5274,http://localhost:5000,http://127.0.0.1:5173,http://127.0.0.1:5274,http://127.0.0.1:5000').split(',') + CORS_ORIGINS = os.environ.get('CORS_ORIGINS', 'http://localhost:5173,http://localhost:5274,http://localhost:5000,http://localhost:5001,http://127.0.0.1:5173,http://127.0.0.1:5274,http://127.0.0.1:5000,http://127.0.0.1:5001').split(',') class DevelopmentConfig(Config): diff --git a/backend/migrations/versions/001_baseline.py b/backend/migrations/versions/001_baseline.py index b7516333..4f57d23d 100644 --- a/backend/migrations/versions/001_baseline.py +++ b/backend/migrations/versions/001_baseline.py @@ -355,7 +355,7 @@ def upgrade(): if 'server_metrics' not in existing_tables: op.create_table('server_metrics', - sa.Column('id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), sa.Column('server_id', sa.String(36), sa.ForeignKey('servers.id'), nullable=False, index=True), sa.Column('timestamp', sa.DateTime(), server_default=sa.func.now(), index=True), sa.Column('cpu_percent', sa.Float()), diff --git a/backend/requirements.txt b/backend/requirements.txt index 149ae82e..0e1df0e5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,6 @@ # Flask core -Flask==3.1.3 -Werkzeug==3.1.6 +Flask==3.0.3 +Werkzeug==3.0.6 # Database Flask-SQLAlchemy==3.1.1 @@ -28,7 +28,7 @@ gevent==25.4.2 gevent-websocket==0.10.1 # Environment variables -python-dotenv==1.2.2 +python-dotenv==1.0.0 # YAML parsing (for Docker Compose) PyYAML==6.0.1 @@ -45,20 +45,20 @@ passlib==1.7.4 bcrypt==4.2.1 # Encryption for env vars -cryptography==46.0.7 +cryptography==46.0.5 # Two-Factor Authentication pyotp==2.9.0 qrcode[pil]==7.4.2 # HTTP Requests (for webhooks & notifications) -requests==2.33.1 +requests==2.32.5 # S3-compatible storage (AWS S3, Backblaze B2, MinIO, Wasabi) boto3==1.35.0 # SSO / OAuth -Authlib==1.6.11 +Authlib==1.6.9 python3-saml==1.16.0 # OpenAPI documentation diff --git a/backend/run.py b/backend/run.py index 351655da..b5f04647 100644 --- a/backend/run.py +++ b/backend/run.py @@ -6,6 +6,10 @@ load_dotenv() from app import create_app, get_socketio +from app.paths import ensure_paths + +# Ensure data directories exist +ensure_paths() app = create_app() socketio = get_socketio() @@ -31,5 +35,5 @@ debug=debug, allow_unsafe_werkzeug=True, use_reloader=True, - reloader_type='stat' # Use polling instead of inotify (for WSL) + reloader_type='stat' ) diff --git a/check_users.py b/check_users.py new file mode 100644 index 00000000..d2202dad --- /dev/null +++ b/check_users.py @@ -0,0 +1,24 @@ +import sqlite3 +import os + +db_path = os.path.join(os.path.dirname(__file__), 'backend', 'instance', 'serverkit.db') +if not os.path.exists(db_path): + print(f"Database not found at {db_path}") + exit(1) + +conn = sqlite3.connect(db_path) +cursor = conn.cursor() +try: + cursor.execute('SELECT username, email FROM users') + users = cursor.fetchall() + print(f"Found {len(users)} users:") + for user in users: + print(f" - {user[0]} ({user[1]})") + + cursor.execute("SELECT key, value FROM system_settings WHERE key='setup_completed'") + setup = cursor.fetchone() + print(f"Setup completed: {setup[1] if setup else 'Not found'}") +except sqlite3.OperationalError as e: + print(f"Error reading users: {e}") +finally: + conn.close() diff --git a/clear_db.py b/clear_db.py new file mode 100644 index 00000000..4ab92d4c --- /dev/null +++ b/clear_db.py @@ -0,0 +1,19 @@ +import sqlite3 +import os + +db_path = os.path.join(os.path.dirname(__file__), 'backend', 'instance', 'serverkit.db') +if not os.path.exists(db_path): + print(f"Database not found at {db_path}") + exit(1) + +conn = sqlite3.connect(db_path) +cursor = conn.cursor() +try: + cursor.execute('DELETE FROM users') + cursor.execute("DELETE FROM system_settings WHERE key='setup_completed'") + conn.commit() + print("Database cleared. You can now register as the first user.") +except sqlite3.OperationalError as e: + print(f"Error clearing database: {e}") +finally: + conn.close() diff --git a/deploy/serverkit.service b/deploy/serverkit.service index f233027b..f66869f1 100644 --- a/deploy/serverkit.service +++ b/deploy/serverkit.service @@ -1,6 +1,6 @@ [Unit] Description=ServerKit Web Server Management Dashboard -Documentation=https://github.com/jhd3197/serverkit +Documentation=https://github.com/jhd3197/ServerKit After=network.target docker.service Requires=docker.service diff --git a/dev.ps1 b/dev.ps1 index c9ff27d5..62b8a9dd 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -3,25 +3,19 @@ .SYNOPSIS ServerKit development launcher and validation tool. .DESCRIPTION - Start backend, frontend, both, or expose backend via an ngrok tunnel. - Run validation checks. + Start backend, frontend, or both. Run validation checks. .PARAMETER Mode - Operation mode: start (default), backend, frontend, tunnel, validate + Operation mode: start (default), backend, frontend, validate .EXAMPLE .\dev.ps1 # Start backend + frontend .\dev.ps1 backend # Backend only .\dev.ps1 frontend # Frontend only - .\dev.ps1 tunnel # Backend + frontend + ngrok tunnel for the backend .\dev.ps1 validate # Run all linters/checks -.NOTES - Optional environment variables for tunnel mode: - NGROK_DOMAIN - reserved ngrok domain (e.g. my-app.ngrok-free.app) - NGROK_AUTHTOKEN - ngrok authtoken (alternatively run `ngrok config add-authtoken`) #> param( [Parameter(Position = 0)] - [ValidateSet('start', 'backend', 'frontend', 'tunnel', 'validate')] + [ValidateSet('start', 'backend', 'frontend', 'validate')] [string]$Mode = 'start' ) @@ -126,120 +120,6 @@ function Start-Both { } } -function Start-Tunnel { - if (-not (Get-Command ngrok -ErrorAction SilentlyContinue)) { - Write-Host "Error: ngrok is not installed or not in PATH." -ForegroundColor Red - Write-Host "" - Write-Host "Install ngrok:" - Write-Host " - https://ngrok.com/download" - Write-Host " - Windows: choco install ngrok (or scoop install ngrok)" - Write-Host " - WSL: snap install ngrok (or download the .tgz)" - Write-Host "" - Write-Host "Then authenticate once: ngrok config add-authtoken " - exit 1 - } - - Write-Host "" - Write-Host "ServerKit Dev Server (with ngrok tunnel)" -ForegroundColor Cyan - Write-Host " Backend: http://localhost:5000" - Write-Host " Frontend: http://localhost:5173" - Write-Host " Tunnel: exposing backend (port 5000) via ngrok" - Write-Host "" - Write-Host "NOTE: " -ForegroundColor Yellow -NoNewline - Write-Host "Agents and remote callers should use the public ngrok URL" - Write-Host " printed below as their --server / control plane URL." - Write-Host "" - - if (-not $env:CORS_ORIGINS) { - $env:CORS_ORIGINS = 'http://localhost:5173,http://localhost:5000,https://*.ngrok-free.app,https://*.ngrok.app,https://*.ngrok.io' - } - - $envSnapshot = @{ - CORS_ORIGINS = $env:CORS_ORIGINS - } - - $backendJob = Start-Job -ScriptBlock { - param($dir, $envVars) - foreach ($k in $envVars.Keys) { Set-Item -Path "Env:$k" -Value $envVars[$k] } - Set-Location $dir - if (Test-Path 'venv\Scripts\Activate.ps1') { - & 'venv\Scripts\Activate.ps1' - } - python run.py - } -ArgumentList $BackendDir, $envSnapshot - - Start-Sleep -Seconds 2 - - $frontendJob = Start-Job -ScriptBlock { - param($dir) - Set-Location $dir - npm run dev - } -ArgumentList $FrontendDir - - Start-Sleep -Seconds 1 - - $ngrokArgs = @('http', '5000', '--log=stdout') - if ($env:NGROK_DOMAIN) { $ngrokArgs += "--domain=$($env:NGROK_DOMAIN)" } - if ($env:NGROK_AUTHTOKEN) { $ngrokArgs += "--authtoken=$($env:NGROK_AUTHTOKEN)" } - - $ngrokProc = Start-Process -FilePath 'ngrok' -ArgumentList $ngrokArgs -PassThru -WindowStyle Hidden - - $urlPrinted = $false - try { - Write-Host "Press Ctrl+C to stop..." -ForegroundColor DarkGray - $polls = 0 - while ($true) { - Receive-Job $backendJob -ErrorAction SilentlyContinue - Receive-Job $frontendJob -ErrorAction SilentlyContinue - - if (-not $urlPrinted -and $polls -lt 30) { - try { - $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:4040/api/tunnels' -TimeoutSec 1 -ErrorAction Stop - $publicUrl = ($resp.tunnels | Where-Object { $_.public_url -like 'https://*' } | Select-Object -First 1).public_url - if ($publicUrl) { - Write-Host "" - Write-Host "=========================================================" -ForegroundColor Green - Write-Host " Public tunnel URL: " -ForegroundColor Green -NoNewline - Write-Host $publicUrl -ForegroundColor Cyan - Write-Host " Use this as your agent --server / control plane URL." -ForegroundColor Green - Write-Host "=========================================================" -ForegroundColor Green - Write-Host "" - $urlPrinted = $true - } - } - catch { } - $polls++ - } - - if ($backendJob.State -eq 'Failed') { - Write-Host "Backend crashed!" -ForegroundColor Red - Receive-Job $backendJob - break - } - if ($frontendJob.State -eq 'Failed') { - Write-Host "Frontend crashed!" -ForegroundColor Red - Receive-Job $frontendJob - break - } - if ($ngrokProc.HasExited) { - Write-Host "ngrok exited." -ForegroundColor Red - break - } - Start-Sleep -Seconds 1 - } - } - finally { - Stop-Job $backendJob -ErrorAction SilentlyContinue - Stop-Job $frontendJob -ErrorAction SilentlyContinue - Remove-Job $backendJob -Force -ErrorAction SilentlyContinue - Remove-Job $frontendJob -Force -ErrorAction SilentlyContinue - if ($ngrokProc -and -not $ngrokProc.HasExited) { - Stop-Process -Id $ngrokProc.Id -ErrorAction SilentlyContinue - } - Write-Host "`nStopped." -ForegroundColor Yellow - } -} - function Invoke-Check { param( [string]$Name, @@ -354,7 +234,6 @@ function Run-ValidateWatch { switch ($Mode) { 'backend' { Start-Backend } 'frontend' { Start-Frontend } - 'tunnel' { Start-Tunnel } 'validate' { Run-ValidateWatch } default { Start-Both } } diff --git a/dev.sh b/dev.sh index cdee06d2..8028c0cf 100644 --- a/dev.sh +++ b/dev.sh @@ -5,12 +5,7 @@ # ./dev.sh Start backend + frontend (default) # ./dev.sh backend Backend only # ./dev.sh frontend Frontend only -# ./dev.sh tunnel Start backend + frontend + expose backend via ngrok # ./dev.sh validate Run all linters/checks -# -# Environment variables: -# NGROK_DOMAIN Optional reserved ngrok domain (e.g. my-app.ngrok-free.app) -# NGROK_AUTHTOKEN Optional ngrok authtoken (alternatively run `ngrok config add-authtoken`) set -euo pipefail @@ -88,94 +83,6 @@ start_both() { wait } -start_tunnel() { - if ! command -v ngrok &>/dev/null; then - echo -e "${RED}Error:${NC} ngrok is not installed or not in PATH." - echo "" - echo "Install ngrok:" - echo " - https://ngrok.com/download" - echo " - macOS: brew install ngrok/ngrok/ngrok" - echo " - Linux: snap install ngrok (or download the .tgz)" - echo " - Windows: choco install ngrok (or scoop install ngrok)" - echo "" - echo "Then authenticate once: ngrok config add-authtoken " - exit 1 - fi - - echo "" - echo -e "${CYAN}ServerKit Dev Server (with ngrok tunnel)${NC}" - echo " Backend: http://localhost:5000" - echo " Frontend: http://localhost:5173" - echo " Tunnel: exposing backend (port 5000) via ngrok" - echo "" - echo -e "${YELLOW}NOTE:${NC} Agents and remote callers should use the public ngrok URL" - echo " printed below as their --server / control plane URL." - echo "" - - # Allow CORS from any ngrok domain by default unless caller already set it. - export CORS_ORIGINS="${CORS_ORIGINS:-http://localhost:5173,http://localhost:5000,https://*.ngrok-free.app,https://*.ngrok.app,https://*.ngrok.io}" - - cd "$BACKEND_DIR" - if [ -f venv/bin/activate ]; then - source venv/bin/activate - fi - python run.py & - BACKEND_PID=$! - - sleep 2 - - cd "$FRONTEND_DIR" - npm run dev & - FRONTEND_PID=$! - - sleep 1 - - NGROK_ARGS=(http 5000 --log=stdout) - if [ -n "${NGROK_DOMAIN:-}" ]; then - NGROK_ARGS+=("--domain=$NGROK_DOMAIN") - fi - if [ -n "${NGROK_AUTHTOKEN:-}" ]; then - NGROK_ARGS+=("--authtoken=$NGROK_AUTHTOKEN") - fi - - ngrok "${NGROK_ARGS[@]}" & - NGROK_PID=$! - - # Poll the local ngrok API for the public URL and print it prominently. - ( - for _ in $(seq 1 20); do - sleep 1 - if command -v curl &>/dev/null; then - URL=$(curl -fsS http://127.0.0.1:4040/api/tunnels 2>/dev/null \ - | grep -oE '"public_url":"https://[^"]+' \ - | head -n1 \ - | sed 's/"public_url":"//') - if [ -n "$URL" ]; then - echo "" - echo -e "${GREEN}=========================================================${NC}" - echo -e "${GREEN} Public tunnel URL: ${NC}${CYAN}$URL${NC}" - echo -e "${GREEN} Use this as your agent --server / control plane URL.${NC}" - echo -e "${GREEN}=========================================================${NC}" - echo "" - break - fi - fi - done - ) & - - cleanup_tunnel() { - echo "" - echo -e "${YELLOW}Stopping...${NC}" - kill "$BACKEND_PID" "$FRONTEND_PID" "$NGROK_PID" 2>/dev/null || true - wait "$BACKEND_PID" "$FRONTEND_PID" "$NGROK_PID" 2>/dev/null || true - echo "Stopped." - } - trap cleanup_tunnel INT TERM - - echo -e "${DIM}Press Ctrl+C to stop...${NC}" - wait -} - run_validate() { header "ServerKit Validation Suite" local failed=0 @@ -266,7 +173,6 @@ MODE="${1:-start}" case "$MODE" in backend) start_backend ;; frontend) start_frontend ;; - tunnel) start_tunnel ;; validate) run_validate_watch ;; start|*) start_both ;; esac diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..d3692321 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,81 @@ +# ============================================ +# ServerKit Docker Compose — Development +# ============================================ +# Runs both backend and frontend in Docker. +# Note: System management features (PHP install, +# firewall, etc.) won't work in Docker. +# +# Usage: +# docker compose -f docker-compose.dev.yml up --build -d +# +# Access: +# Frontend: http://localhost:3847 +# Backend: http://localhost:5849 +# ============================================ + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: serverkit-backend + environment: + - FLASK_ENV=development + - SECRET_KEY=dev-secret-key-not-for-production + - JWT_SECRET_KEY=dev-jwt-key-not-for-production + - DATABASE_URL=sqlite:////app/instance/serverkit.db + - CORS_ORIGINS=http://localhost,http://localhost:3847,http://localhost:5173 + ports: + - "5849:5000" + volumes: + - serverkit-data:/app/instance + - serverkit-config:/etc/serverkit + networks: + - serverkit-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/api/v1/system/health"] + interval: 10s + timeout: 5s + retries: 3 + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + target: serve + container_name: serverkit-frontend + ports: + - "3847:80" + depends_on: + backend: + condition: service_healthy + networks: + - serverkit-network + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 3 + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +networks: + serverkit-network: + driver: bridge + name: serverkit-network + +volumes: + serverkit-data: + name: serverkit-data + serverkit-config: + name: serverkit-config diff --git a/docker-compose.yml b/docker-compose.yml index d8e6ab6a..0a6400f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,59 +1,34 @@ # ============================================ -# ServerKit Docker Compose +# ServerKit Docker Compose — Production # ============================================ -# Runs both backend and frontend in Docker. -# Note: System management features (PHP install, -# firewall, etc.) won't work in Docker. +# Used by install.sh and the `serverkit` CLI. # -# Usage: -# docker compose up --build -d +# Architecture: +# - Backend runs directly on the host via systemd (full system access) +# - Frontend runs in Docker (nginx serving pre-built static files) # -# Access: -# Frontend: http://localhost:3847 -# Backend: http://localhost:5849 +# The frontend image is built from a pre-built dist/ folder. +# Run `npm run build` inside frontend/ on the host before `docker compose build`. +# +# Usage (via serverkit CLI): +# serverkit start / stop / restart / logs +# +# Direct usage: +# docker compose build +# docker compose up -d # ============================================ services: - backend: - build: - context: ./backend - dockerfile: Dockerfile - container_name: serverkit-backend - environment: - - FLASK_ENV=development - - SECRET_KEY=dev-secret-key-not-for-production - - JWT_SECRET_KEY=dev-jwt-key-not-for-production - - DATABASE_URL=sqlite:////app/instance/serverkit.db - - CORS_ORIGINS=http://localhost,http://localhost:3847,http://localhost:5173 - ports: - - "5849:5000" - volumes: - - serverkit-data:/app/instance - - serverkit-config:/etc/serverkit - networks: - - serverkit-network - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5000/api/v1/system/health"] - interval: 10s - timeout: 5s - retries: 3 - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - frontend: build: context: ./frontend dockerfile: Dockerfile + target: serve-prebuilt container_name: serverkit-frontend ports: - "3847:80" - depends_on: - backend: - condition: service_healthy + extra_hosts: + - "backend:host-gateway" networks: - serverkit-network restart: unless-stopped diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7548d916..7d1bba6d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -9,7 +9,7 @@ Complete guide for deploying ServerKit on Ubuntu servers. curl -fsSL https://serverkit.ai/install.sh | bash # Or clone and install manually -git clone https://github.com/jhd3197/serverkit.git +git clone https://github.com/jhd3197/ServerKit.git cd serverkit chmod +x serverkit ./serverkit install @@ -157,7 +157,7 @@ docker --version ### 2. Clone Repository ```bash -git clone https://github.com/jhd3197/serverkit.git /opt/serverkit +git clone https://github.com/jhd3197/ServerKit.git /opt/serverkit cd /opt/serverkit ``` @@ -362,5 +362,5 @@ serverkit unlock-user ## Support -- GitHub Issues: https://github.com/jhd3197/serverkit/issues -- Documentation: https://github.com/jhd3197/serverkit/wiki +- GitHub Issues: https://github.com/jhd3197/ServerKit/issues +- Documentation: https://github.com/jhd3197/ServerKit/wiki diff --git a/docs/LOCAL_DEVELOPMENT.md b/docs/LOCAL_DEVELOPMENT.md index 76056b6d..a0175936 100644 --- a/docs/LOCAL_DEVELOPMENT.md +++ b/docs/LOCAL_DEVELOPMENT.md @@ -252,90 +252,3 @@ sudo usermod -aG docker $USER # Remove the lock file rm backend/instance/serverkit.db-journal ``` - ---- - -## Exposing Your Local Control Plane (ngrok) - -When developing locally on Windows/WSL you may want a remote ServerKit agent (or -a Docker host on another machine) to connect back to your machine. The `dev.sh` -and `dev.ps1` scripts include a `tunnel` mode that starts the backend, frontend, -and an ngrok tunnel pointed at the backend (port 5000). - -### Prerequisites - -1. Install ngrok and authenticate once: - - ```bash - # macOS / Linux / WSL - brew install ngrok/ngrok/ngrok # or: snap install ngrok - ngrok config add-authtoken - ``` - - ```powershell - # Windows - choco install ngrok # or: scoop install ngrok - ngrok config add-authtoken - ``` - -2. (Optional) Reserve a domain in the ngrok dashboard so the URL stays stable - across restarts. - -### Start with a tunnel - -```bash -# Linux / macOS / WSL -./dev.sh tunnel -``` - -```powershell -# Windows -.\dev.ps1 tunnel -``` - -The public ngrok URL is printed in green once the tunnel is up. Use it as the -`--server` argument when registering an agent: - -```bash -serverkit-agent register \ - --token "sk_reg_xxx" \ - --server "https://.ngrok-free.app" -``` - -### Optional environment variables - -| Variable | Purpose | -|----------|---------| -| `NGROK_DOMAIN` | Use a reserved ngrok domain (e.g. `my-app.ngrok-free.app`). | -| `NGROK_AUTHTOKEN` | Override the authtoken (otherwise the one stored by `ngrok config` is used). | -| `CORS_ORIGINS` | Override CORS allow-list. The script defaults include `https://*.ngrok-free.app`, `https://*.ngrok.app`, `https://*.ngrok.io`. | - ---- - -## Quick Start with WSL2 (Helper Script) - -If you're on Windows and using WSL2 Ubuntu/Debian, you can skip the manual -steps above and use the bundled setup helper: - -```bash -# from inside WSL, in the cloned repo -bash scripts/setup-wsl.sh # full setup -bash scripts/setup-wsl.sh --check # dry-run / diagnostics only -``` - -The script will: - -- Detect WSL and warn if the repo lives under `/mnt/c` (slow file I/O for hot-reload). -- Verify Ubuntu/Debian and install missing apt packages - (`python3`, `python3-venv`, `python3-pip`, `nodejs`, `npm`, `git`, `curl`). -- Create `backend/venv` and install Python requirements. -- Run `npm install` in `frontend/`. -- Copy `backend/.env.example` to `backend/.env` if missing. -- Print WSL-specific tips (localhost forwarding, polling reloader, ngrok). - -Once it finishes: - -```bash -./dev.sh # start backend + frontend -./dev.sh tunnel # also expose backend via ngrok (see section above) -``` diff --git a/ensure_dev_state.py b/ensure_dev_state.py new file mode 100644 index 00000000..85dffd09 --- /dev/null +++ b/ensure_dev_state.py @@ -0,0 +1,32 @@ +import sqlite3 +import os +import werkzeug.security + +db_path = os.path.join(os.path.dirname(__file__), 'backend', 'instance', 'serverkit.db') +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +password_hash = werkzeug.security.generate_password_hash('admin1234') + +try: + # Ensure admin user exists and has a known password + cursor.execute("SELECT id FROM users WHERE username='admin'") + user = cursor.fetchone() + if user: + cursor.execute("UPDATE users SET password_hash=?, role='admin' WHERE username='admin'", (password_hash,)) + print("Updated existing admin user password to: admin1234") + else: + cursor.execute("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)", + ('admin', 'admin@admin.com', password_hash, 'admin')) + print("Created new admin user with password: admin1234") + + # Force setup as completed and enable registration for dev + cursor.execute("INSERT OR REPLACE INTO system_settings (key, value, value_type) VALUES ('setup_completed', 'true', 'boolean')") + cursor.execute("INSERT OR REPLACE INTO system_settings (key, value, value_type) VALUES ('registration_enabled', 'true', 'boolean')") + + conn.commit() + print("Persistence guaranteed: Admin user ready and setup marked as complete.") +except Exception as e: + print(f"Error: {e}") +finally: + conn.close() diff --git a/frontend/Dockerfile b/frontend/Dockerfile index bb06fe2f..c2261f55 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,7 +1,7 @@ # Frontend Dockerfile for ServerKit # Multi-stage build: Node builds the assets, nginx serves them. -# Stage 1: Build +# Stage 1: Build from source (used by docker-compose.dev.yml) FROM node:20-alpine AS builder WORKDIR /app @@ -12,27 +12,39 @@ RUN npm ci COPY . . RUN npm run build -# Stage 2: Serve -FROM nginx:alpine +# Stage 2: Serve from source build (target: serve — for docker-compose.dev.yml) +FROM nginx:alpine AS serve -# Copy custom nginx config COPY nginx.conf /etc/nginx/conf.d/default.conf - -# Copy built frontend assets from builder COPY --from=builder /app/dist /usr/share/nginx/html -# Create non-root user for nginx RUN chown -R nginx:nginx /usr/share/nginx/html && \ chown -R nginx:nginx /var/cache/nginx && \ chown -R nginx:nginx /var/log/nginx && \ chown -R nginx:nginx /etc/nginx/conf.d -# Expose port EXPOSE 80 -# Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 -# Start nginx +CMD ["nginx", "-g", "daemon off;"] + +# Stage 3: Serve from pre-built dist (target: serve-prebuilt — used by install.sh) +# Requires `npm run build` to have been run on the host first (dist/ must exist). +FROM nginx:alpine AS serve-prebuilt + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY dist/ /usr/share/nginx/html + +RUN chown -R nginx:nginx /usr/share/nginx/html && \ + chown -R nginx:nginx /var/cache/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + chown -R nginx:nginx /etc/nginx/conf.d + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 + CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 51157ce2..fafc1c9b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,6 @@ "name": "serverkit-frontend", "version": "0.1.0", "dependencies": { - "@rollup/rollup-win32-x64-msvc": "^4.60.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", @@ -31,6 +30,9 @@ "globals": "^15.9.0", "sass": "^1.86.0", "vite": "^5.4.1" + }, + "optionalDependencies": { + "@rollup/rollup-win32-x64-msvc": "^4.60.0" } }, "node_modules/@babel/code-frame": { @@ -1644,6 +1646,7 @@ "x64" ], "license": "MIT", + "optional": true, "os": [ "win32" ] diff --git a/frontend/package.json b/frontend/package.json index 31f83ab4..df4b7ad4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,9 +9,6 @@ "lint": "eslint .", "preview": "vite preview" }, - "optionalDependencies": { - "@rollup/rollup-win32-x64-msvc": "^4.60.0" - }, "dependencies": { "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 08b61c95..45fba18e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -49,8 +49,6 @@ import DNSZones from './pages/DNSZones'; import StatusPages from './pages/StatusPages'; import CloudProvision from './pages/CloudProvision'; import Marketplace from './pages/Marketplace'; -import StyleGuide from './pages/StyleGuide'; -import Deployments from './pages/Deployments'; // Page title mapping const PAGE_TITLES = { @@ -63,7 +61,6 @@ const PAGE_TITLES = { '/wordpress': 'WordPress Sites', '/wordpress/projects': 'WordPress Projects', '/templates': 'Templates', - '/deployments': 'Deployments', '/workflow': 'Workflow Builder', '/domains': 'Domains', '/databases': 'Databases', @@ -91,7 +88,6 @@ const PAGE_TITLES = { '/status-pages': 'Status Pages', '/cloud': 'Cloud Provisioning', '/marketplace': 'Marketplace', - '/style-guide': 'Style Guide', }; function PageTitleUpdater() { @@ -216,8 +212,6 @@ function AppRoutes() { } /> } /> } /> - } /> - } /> } /> } /> } /> @@ -237,7 +231,6 @@ function AppRoutes() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/frontend/src/components/CommandPalette.jsx b/frontend/src/components/CommandPalette.jsx index 8e8d04dc..2d1f3916 100644 --- a/frontend/src/components/CommandPalette.jsx +++ b/frontend/src/components/CommandPalette.jsx @@ -9,7 +9,6 @@ const STATIC_PAGES = [ { label: 'Domains', path: '/domains', category: 'Pages', keywords: 'dns nginx' }, { label: 'SSL Certificates', path: '/ssl', category: 'Pages', keywords: 'https tls' }, { label: 'Templates', path: '/templates', category: 'Pages', keywords: 'deploy one-click' }, - { label: 'Deployments', path: '/deployments', category: 'Pages', keywords: 'deploy jobs status logs' }, { label: 'Workflow Builder', path: '/workflow', category: 'Pages', keywords: 'automation pipeline' }, { label: 'WordPress', path: '/wordpress', category: 'Pages', keywords: 'wp sites' }, { label: 'WordPress Projects', path: '/wordpress/projects', category: 'Pages', keywords: 'wp environments' }, diff --git a/frontend/src/components/EmptyState.jsx b/frontend/src/components/EmptyState.jsx index d255e92b..8edeadf8 100644 --- a/frontend/src/components/EmptyState.jsx +++ b/frontend/src/components/EmptyState.jsx @@ -1,28 +1,16 @@ import React from 'react'; import { Inbox } from 'lucide-react'; -import { Spinner } from './Spinner'; export default function EmptyState({ icon: Icon = Inbox, title = 'No items found', description = '', - action = null, - size = 'default', - loading = false + action = null }) { - if (loading) { - return ( -
- - {title &&

{title}

} -
- ); - } - return ( -
+
- +

{title}

{description && ( diff --git a/frontend/src/components/EnvironmentVariables.jsx b/frontend/src/components/EnvironmentVariables.jsx index 86a2c4d9..edf689b1 100644 --- a/frontend/src/components/EnvironmentVariables.jsx +++ b/frontend/src/components/EnvironmentVariables.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'; import api from '../services/api'; import { useToast } from '../contexts/ToastContext'; import Modal from './Modal'; +import { copyToClipboard as clipboardWrite } from '../utils/clipboard'; const EnvironmentVariables = ({ appId }) => { const toast = useToast(); @@ -212,7 +213,7 @@ const EnvironmentVariables = ({ appId }) => { } function copyToClipboard(value) { - navigator.clipboard.writeText(value); + clipboardWrite(value); toast.success('Copied to clipboard'); } diff --git a/frontend/src/components/PrivateURLSection.jsx b/frontend/src/components/PrivateURLSection.jsx index ea232f68..b99a9fb4 100644 --- a/frontend/src/components/PrivateURLSection.jsx +++ b/frontend/src/components/PrivateURLSection.jsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import api from '../services/api'; import { useToast } from '../contexts/ToastContext'; +import { copyToClipboard as clipboardWrite } from '../utils/clipboard'; const PrivateURLSection = ({ app, onUpdate }) => { const toast = useToast(); @@ -75,7 +76,7 @@ const PrivateURLSection = ({ app, onUpdate }) => { } function copyToClipboard() { - navigator.clipboard.writeText(privateUrl); + clipboardWrite(privateUrl); toast.success('URL copied to clipboard'); } diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 04713744..ab6825ad 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -5,7 +5,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { Star, Settings, LogOut, Sun, Moon, Monitor, ChevronRight, ChevronDown, ChevronUp, Layers, Palette, PanelLeft, Check } from 'lucide-react'; import { api } from '../services/api'; import ServerKitLogo from './ServerKitLogo'; -import { SIDEBAR_CATEGORIES, CATEGORY_LABELS, SIDEBAR_PRESETS, getVisibleItems, SIDEBAR_ITEMS } from './sidebarItems'; +import { SIDEBAR_CATEGORIES, CATEGORY_LABELS, SIDEBAR_PRESETS, getVisibleItems } from './sidebarItems'; const Sidebar = () => { const { user, logout, updateUser } = useAuth(); @@ -240,23 +240,6 @@ const Sidebar = () => { })}
- {import.meta.env.DEV && ( - <> -
Dev Tools
- - - )} -
{menuOpen && (
diff --git a/frontend/src/components/service-detail/LogsTab.jsx b/frontend/src/components/service-detail/LogsTab.jsx index cc16b007..4b9fbf06 100644 --- a/frontend/src/components/service-detail/LogsTab.jsx +++ b/frontend/src/components/service-detail/LogsTab.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import api from '../../services/api'; import { useLogsDrawer } from '../../contexts/LogsDrawerContext'; +import { copyToClipboard as clipboardWrite } from '../../utils/clipboard'; const LOG_LEVELS = ['all', 'error', 'warn', 'info', 'debug']; @@ -97,7 +98,7 @@ const LogsTab = ({ app }) => { } function handleCopy() { - navigator.clipboard.writeText(rawLogs); + clipboardWrite(rawLogs); } const matchCount = searchTerm ? filteredLines.length : null; diff --git a/frontend/src/components/settings/ApiKeyModal.jsx b/frontend/src/components/settings/ApiKeyModal.jsx index ba77b203..08541a1a 100644 --- a/frontend/src/components/settings/ApiKeyModal.jsx +++ b/frontend/src/components/settings/ApiKeyModal.jsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { X, Copy, Check, AlertTriangle } from 'lucide-react'; import Modal from '../Modal'; +import { copyToClipboard as clipboardWrite } from '../../utils/clipboard'; const SCOPE_OPTIONS = [ { value: '*', label: 'Full Access' }, @@ -63,7 +64,7 @@ const ApiKeyModal = ({ onClose, onSubmit, createdKey }) => { const copyKey = () => { if (createdKey) { - navigator.clipboard.writeText(createdKey); + clipboardWrite(createdKey); setCopied(true); setTimeout(() => setCopied(false), 2000); } diff --git a/frontend/src/components/settings/IconReferenceTab.jsx b/frontend/src/components/settings/IconReferenceTab.jsx index 8dfcc068..be318e7b 100644 --- a/frontend/src/components/settings/IconReferenceTab.jsx +++ b/frontend/src/components/settings/IconReferenceTab.jsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { copyToClipboard as clipboardWrite } from '../../utils/clipboard'; import { Github, FileText, HelpCircle, MessageSquare, Bug, Check, Download, CheckCircle, RefreshCw, ExternalLink, Star, X, Code, Search, Container, Globe, BarChart3, @@ -55,7 +56,7 @@ const IconReferenceTab = () => { const [copiedIcon, setCopiedIcon] = useState(null); function handleCopyImport(name) { - navigator.clipboard.writeText(name); + clipboardWrite(name); setCopiedIcon(name); setTimeout(() => setCopiedIcon(null), 1500); } diff --git a/frontend/src/components/settings/InvitationsTab.jsx b/frontend/src/components/settings/InvitationsTab.jsx index becfd108..4c976d39 100644 --- a/frontend/src/components/settings/InvitationsTab.jsx +++ b/frontend/src/components/settings/InvitationsTab.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import api from '../../services/api'; import InviteModal from './InviteModal'; +import { copyToClipboard as clipboardWrite } from '../../utils/clipboard'; const InvitationsTab = () => { const [invitations, setInvitations] = useState([]); @@ -43,7 +44,7 @@ const InvitationsTab = () => { function copyLink(token) { const url = `${window.location.origin}/register?invite=${token}`; - navigator.clipboard.writeText(url); + clipboardWrite(url); setCopied(token); setTimeout(() => setCopied(null), 2000); } diff --git a/frontend/src/components/settings/InviteModal.jsx b/frontend/src/components/settings/InviteModal.jsx index 108a7050..49dd3af0 100644 --- a/frontend/src/components/settings/InviteModal.jsx +++ b/frontend/src/components/settings/InviteModal.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import api from '../../services/api'; import PermissionEditor from './PermissionEditor'; import Modal from '../Modal'; +import { copyToClipboard as clipboardWrite } from '../../utils/clipboard'; const InviteModal = ({ onClose, onCreated }) => { const [email, setEmail] = useState(''); @@ -59,7 +60,7 @@ const InviteModal = ({ onClose, onCreated }) => { function copyLink() { if (result?.invite_url) { - navigator.clipboard.writeText(result.invite_url); + clipboardWrite(result.invite_url); setCopied(true); setTimeout(() => setCopied(false), 2000); } diff --git a/frontend/src/components/settings/SecuritySettingsTab.jsx b/frontend/src/components/settings/SecuritySettingsTab.jsx index 9bb3fc57..cb11f9c7 100644 --- a/frontend/src/components/settings/SecuritySettingsTab.jsx +++ b/frontend/src/components/settings/SecuritySettingsTab.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useAuth } from '../../contexts/AuthContext'; import api from '../../services/api'; +import { copyToClipboard as clipboardWrite } from '../../utils/clipboard'; import SSOProviderIcon from '../SSOProviderIcon'; import Modal from '../Modal'; @@ -272,7 +273,7 @@ Keep these codes in a safe place.`; } function copyBackupCodes() { - navigator.clipboard.writeText(backupCodes.join('\n')); + clipboardWrite(backupCodes.join('\n')); } return ( diff --git a/frontend/src/components/sidebarItems.js b/frontend/src/components/sidebarItems.js index 6d5135fa..19b48485 100644 --- a/frontend/src/components/sidebarItems.js +++ b/frontend/src/components/sidebarItems.js @@ -53,8 +53,7 @@ export const SIDEBAR_ITEMS = [ category: 'infrastructure', icon: '', subItems: [ - { id: 'templates', label: 'Templates', route: '/templates', icon: '' }, - { id: 'deployments', label: 'Deployments', route: '/deployments', icon: '' } + { id: 'templates', label: 'Templates', route: '/templates', icon: '' } ] }, { diff --git a/frontend/src/components/wordpress/BasicAuthModal.jsx b/frontend/src/components/wordpress/BasicAuthModal.jsx index 4fa47c98..fa471887 100644 --- a/frontend/src/components/wordpress/BasicAuthModal.jsx +++ b/frontend/src/components/wordpress/BasicAuthModal.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Shield, Copy, Check, AlertTriangle } from 'lucide-react'; import Spinner from '../Spinner'; import Modal from '../Modal'; +import { copyToClipboard as clipboardWrite } from '../../utils/clipboard'; const BasicAuthModal = ({ environment, prodId, onClose, api }) => { const [loading, setLoading] = useState(true); @@ -57,7 +58,7 @@ const BasicAuthModal = ({ environment, prodId, onClose, api }) => { } function copyToClipboard(text, field) { - navigator.clipboard.writeText(text); + clipboardWrite(text); setCopied(field); setTimeout(() => setCopied(null), 2000); } diff --git a/frontend/src/components/workflow/panels/TriggerConfigPanel.jsx b/frontend/src/components/workflow/panels/TriggerConfigPanel.jsx index d1b2b54e..cc7067a7 100644 --- a/frontend/src/components/workflow/panels/TriggerConfigPanel.jsx +++ b/frontend/src/components/workflow/panels/TriggerConfigPanel.jsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import ConfigPanel from '../ConfigPanel'; import { Play, Clock, Webhook, Zap, Copy, Check } from 'lucide-react'; +import { copyToClipboard } from '../../../utils/clipboard'; const TriggerConfigPanel = ({ node, onChange, onClose, onDelete }) => { const { data } = node; @@ -38,7 +39,7 @@ const TriggerConfigPanel = ({ node, onChange, onClose, onDelete }) => { const copyWebhookUrl = () => { if (webhookUrl) { - navigator.clipboard.writeText(webhookUrl); + copyToClipboard(webhookUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); } diff --git a/frontend/src/layouts/DashboardLayout.jsx b/frontend/src/layouts/DashboardLayout.jsx index 04d5342e..ab00f66a 100644 --- a/frontend/src/layouts/DashboardLayout.jsx +++ b/frontend/src/layouts/DashboardLayout.jsx @@ -4,8 +4,6 @@ import Sidebar from '../components/Sidebar'; import CommandPalette from '../components/CommandPalette'; import LogsDrawer from '../components/LogsDrawer'; import { LogsDrawerProvider } from '../contexts/LogsDrawerContext'; -import PluginLoader from '../plugins/PluginLoader'; -import api from '../services/api'; const DashboardLayout = () => { const [paletteOpen, setPaletteOpen] = useState(false); @@ -31,7 +29,6 @@ const DashboardLayout = () => { setPaletteOpen(false)} /> -
); diff --git a/frontend/src/pages/Applications.jsx b/frontend/src/pages/Applications.jsx index 155562b5..7a3f3a8d 100644 --- a/frontend/src/pages/Applications.jsx +++ b/frontend/src/pages/Applications.jsx @@ -431,9 +431,15 @@ const AppLogsModal = ({ app, onClose }) => { try { // For Docker apps, try to get container logs if (app.app_type === 'docker') { - const data = await api.getDockerAppLogs(app.id, 200); - setLogs(data.logs || data.content || 'No logs available'); - return; + const containersData = await api.getContainers(true).catch(() => ({ containers: [] })); + const appContainer = containersData.containers?.find(c => + c.name?.includes(app.name) || c.name?.includes(app.id) + ); + if (appContainer) { + const data = await api.getContainerLogs(appContainer.id, 200); + setLogs(data.logs || 'No logs available'); + return; + } } // For other apps, use app logs endpoint const data = await api.getAppLogs(app.name, logType, 200); diff --git a/frontend/src/pages/Docker.jsx b/frontend/src/pages/Docker.jsx index 7c49217b..cd7c601a 100644 --- a/frontend/src/pages/Docker.jsx +++ b/frontend/src/pages/Docker.jsx @@ -11,20 +11,6 @@ const useServer = () => useContext(ServerContext); const VALID_TABS = ['containers', 'compose', 'images', 'volumes', 'networks']; -const unwrapRemoteData = (response) => { - if (response?.success && response.data !== undefined) { - return response.data; - } - return response; -}; - -const normalizeListResponse = (response, key) => { - const data = unwrapRemoteData(response); - if (Array.isArray(data)) return data; - if (Array.isArray(data?.[key])) return data[key]; - return []; -}; - const Docker = () => { const [activeTab, setActiveTab] = useTabParam('/docker', VALID_TABS); const [dockerStatus, setDockerStatus] = useState(null); @@ -71,7 +57,7 @@ const Docker = () => { } else { // For remote servers, check if the agent is online const serverData = await api.getServer(selectedServer.id); - if (serverData.status === 'online' || serverData.server?.status === 'online') { + if (serverData.server?.status === 'online') { setDockerStatus({ installed: true, running: true }); loadStats(); } else { @@ -104,20 +90,17 @@ const Docker = () => { api.getRemoteNetworks(selectedServer.id) ]); + // Transform remote response format + if (containersData.success) containersData = { containers: containersData.data }; + if (imagesData.success) imagesData = { images: imagesData.data }; + if (volumesData.success) volumesData = { volumes: volumesData.data }; + if (networksData.success) networksData = { networks: networksData.data }; } - const containers = selectedServer.id === 'local' - ? containersData.containers || [] - : normalizeListResponse(containersData, 'containers'); - const images = selectedServer.id === 'local' - ? imagesData.images || [] - : normalizeListResponse(imagesData, 'images'); - const volumes = selectedServer.id === 'local' - ? volumesData.volumes || [] - : normalizeListResponse(volumesData, 'volumes'); - const networks = selectedServer.id === 'local' - ? networksData.networks || [] - : normalizeListResponse(networksData, 'networks'); + const containers = containersData.containers || []; + const images = imagesData.images || []; + const volumes = volumesData.volumes || []; + const networks = networksData.networks || []; const running = containers.filter(c => c.state === 'running').length; @@ -507,7 +490,7 @@ const ContainersTab = ({ onStatsChange }) => { let data; if (isRemote) { const result = await api.getRemoteContainers(serverId, showAll); - data = { containers: normalizeListResponse(result, 'containers') }; + data = result.success ? { containers: result.data || [] } : { containers: [] }; } else { data = await api.getContainers(showAll); } @@ -521,7 +504,7 @@ const ContainersTab = ({ onStatsChange }) => { let statsData; if (isRemote) { const result = await api.getRemoteContainerStats(serverId, c.id); - statsData = { stats: unwrapRemoteData(result) }; + statsData = result.success ? { stats: result.data } : { stats: null }; } else { statsData = await api.getContainerStats(c.id); } @@ -779,7 +762,7 @@ const ImagesTab = ({ onStatsChange }) => { let data; if (isRemote) { const result = await api.getRemoteImages(serverId); - data = { images: normalizeListResponse(result, 'images') }; + data = result.success ? { images: result.data || [] } : { images: [] }; } else { data = await api.getImages(); } @@ -908,7 +891,7 @@ const NetworksTab = ({ onStatsChange }) => { let data; if (isRemote) { const result = await api.getRemoteNetworks(serverId); - data = { networks: normalizeListResponse(result, 'networks') }; + data = result.success ? { networks: result.data || [] } : { networks: [] }; } else { data = await api.getNetworks(); } @@ -925,11 +908,7 @@ const NetworksTab = ({ onStatsChange }) => { if (!confirmed) return; try { - if (isRemote) { - await api.removeRemoteNetwork(serverId, networkId); - } else { - await api.removeNetwork(networkId); - } + await api.removeNetwork(networkId); toast.success('Network removed successfully'); loadNetworks(); onStatsChange?.(); @@ -1018,7 +997,7 @@ const VolumesTab = ({ onStatsChange }) => { let data; if (isRemote) { const result = await api.getRemoteVolumes(serverId); - data = { volumes: normalizeListResponse(result, 'volumes') }; + data = result.success ? { volumes: result.data || [] } : { volumes: [] }; } else { data = await api.getVolumes(); } @@ -1035,11 +1014,7 @@ const VolumesTab = ({ onStatsChange }) => { if (!confirmed) return; try { - if (isRemote) { - await api.removeRemoteVolume(serverId, volumeName, true); - } else { - await api.removeVolume(volumeName, true); - } + await api.removeVolume(volumeName, true); toast.success('Volume removed successfully'); loadVolumes(); onStatsChange?.(); @@ -1130,7 +1105,7 @@ const ComposeTab = ({ onStatsChange }) => { } else { data = await api.request('/docker/compose/list'); } - setProjects(normalizeListResponse(data, 'projects')); + setProjects(Array.isArray(data) ? data : data.projects || []); } catch (err) { console.error('Failed to load compose projects:', err); setProjects([]); @@ -1367,10 +1342,7 @@ const ComposeLogsModal = ({ project, onClose }) => { try { let containers; if (isRemote) { - containers = normalizeListResponse( - await api.getRemoteComposePs(serverId, projectPath), - 'containers' - ); + containers = await api.getRemoteComposePs(serverId, projectPath); } else { const result = await api.composePs(projectPath); containers = result.containers || result || []; @@ -1393,7 +1365,7 @@ const ComposeLogsModal = ({ project, onClose }) => { try { let data; if (isRemote) { - data = unwrapRemoteData(await api.remoteComposeLogs(serverId, projectPath, selectedService || null, tail)); + data = await api.remoteComposeLogs(serverId, projectPath, selectedService || null, tail); } else { data = await api.composeLogs(projectPath, selectedService || null, tail); } @@ -1589,7 +1561,7 @@ const ContainerLogsModal = ({ container, onClose }) => { let data; if (isRemote) { const result = await api.getRemoteContainerLogs(serverId, container.id, tail); - data = unwrapRemoteData(result); + data = result.success ? { logs: result.data?.logs || '' } : { logs: '' }; } else { data = await api.getContainerLogs(container.id, tail); } diff --git a/frontend/src/pages/Downloads.jsx b/frontend/src/pages/Downloads.jsx index a056fe28..2da4d18c 100644 --- a/frontend/src/pages/Downloads.jsx +++ b/frontend/src/pages/Downloads.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import api from '../services/api'; +import { copyToClipboard as clipboardWrite } from '../utils/clipboard'; // Platform icons as SVG components const LinuxIcon = () => ( @@ -83,7 +84,7 @@ function Downloads() { const copyToClipboard = async (text, commandId) => { try { - await navigator.clipboard.writeText(text); + await clipboardWrite(text); setCopiedCommand(commandId); setTimeout(() => setCopiedCommand(null), 2000); } catch (err) { @@ -107,7 +108,7 @@ function Downloads() { icon: LinuxIcon, os: 'linux', archKey: 'amd64', - command: `curl -fsSL ${getBaseUrl()}/api/v1/servers/install.sh | sudo bash -s -- --token "YOUR_TOKEN" --server "${getBaseUrl()}"`, + command: `curl -fsSL ${getBaseUrl()}/api/servers/install.sh | sudo bash -s -- --token "YOUR_TOKEN" --server "${getBaseUrl()}"`, }, { id: 'linux-arm64', @@ -116,7 +117,7 @@ function Downloads() { icon: LinuxIcon, os: 'linux', archKey: 'arm64', - command: `curl -fsSL ${getBaseUrl()}/api/v1/servers/install.sh | sudo bash -s -- --token "YOUR_TOKEN" --server "${getBaseUrl()}"`, + command: `curl -fsSL ${getBaseUrl()}/api/servers/install.sh | sudo bash -s -- --token "YOUR_TOKEN" --server "${getBaseUrl()}"`, }, { id: 'windows-amd64', @@ -125,7 +126,7 @@ function Downloads() { icon: WindowsIcon, os: 'windows', archKey: 'amd64', - command: `irm ${getBaseUrl()}/api/v1/servers/install.ps1 | iex; Install-ServerKitAgent -Token "YOUR_TOKEN" -Server "${getBaseUrl()}"`, + command: `irm ${getBaseUrl()}/api/servers/install.ps1 | iex; Install-ServerKitAgent -Token "YOUR_TOKEN" -Server "${getBaseUrl()}"`, }, ]; @@ -242,7 +243,7 @@ function Downloads() {

Quick Install Commands

- Use these one-liner commands to download and install the agent. Replace YOUR_TOKEN with the server registration token. + Use these one-liner commands to download and install the agent. Replace YOUR_TOKEN with your server's registration token.

@@ -314,7 +315,7 @@ function Downloads() {

Register the Agent

Run the registration command with your token:

-
{`serverkit-agent register --token "YOUR_TOKEN" --server "${getBaseUrl()}"`}
+
serverkit-agent register --token "YOUR_TOKEN" --server "{getBaseUrl()}"
diff --git a/frontend/src/pages/Git.jsx b/frontend/src/pages/Git.jsx index cd9b3784..445c57d0 100644 --- a/frontend/src/pages/Git.jsx +++ b/frontend/src/pages/Git.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import useTabParam from '../hooks/useTabParam'; import { api } from '../services/api'; import { useToast } from '../contexts/ToastContext'; +import { copyToClipboard } from '../utils/clipboard'; import Spinner from '../components/Spinner'; import ConfirmDialog from '../components/ConfirmDialog'; @@ -201,7 +202,7 @@ function Git() { const copyWebhookUrl = (webhook) => { const baseUrl = window.location.origin; const url = `${baseUrl}/api${webhook.webhook_url}`; - navigator.clipboard.writeText(url); + copyToClipboard(url); toast.success('Webhook URL copied'); }; @@ -772,7 +773,7 @@ function Git() { -
{tab === 'browse' && ( @@ -184,64 +142,6 @@ const Marketplace = () => {
)} - {tab === 'plugins' && ( -
-
-

Install Plugin from URL

-

Paste a GitHub repo URL, release URL, or direct zip link.

-
- setPluginUrl(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handlePluginInstall()} - disabled={installing} - /> - -
-
- -
- {plugins.map(plugin => ( -
-
- {plugin.display_name} - v{plugin.version} - - {plugin.status} - - {plugin.has_backend && Backend} - {plugin.has_frontend && Frontend} -
- {plugin.description &&

{plugin.description}

} - {plugin.error_message &&

{plugin.error_message}

} -
- - -
-
- ))} - {plugins.length === 0 && ( -
-

No plugins installed. Use the form above to install one from a URL.

-
- )} -
-
- )} - {showSubmit && (
setShowSubmit(false)}>
e.stopPropagation()}> diff --git a/frontend/src/pages/ServerDetail.jsx b/frontend/src/pages/ServerDetail.jsx index bbb875e0..f4884835 100644 --- a/frontend/src/pages/ServerDetail.jsx +++ b/frontend/src/pages/ServerDetail.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import api from '../services/api'; +import { copyToClipboard as clipboardWrite } from '../utils/clipboard'; import { useToast } from '../contexts/ToastContext'; import MetricsGraph from '../components/MetricsGraph'; import { useConfirm } from '../hooks/useConfirm'; @@ -37,9 +38,7 @@ const ServerDetail = () => { if (!server || server.status !== 'online') return; try { const data = await api.getRemoteSystemMetrics(id); - if (data.success) { - setMetrics(data.data); - } + setMetrics(data); } catch (err) { console.error('Failed to load metrics:', err); } @@ -49,9 +48,7 @@ const ServerDetail = () => { if (!server || server.status !== 'online') return; try { const data = await api.getRemoteSystemInfo(id); - if (data.success) { - setSystemInfo(data.data); - } + setSystemInfo(data); } catch (err) { console.error('Failed to load system info:', err); } @@ -152,6 +149,13 @@ const ServerDetail = () => { ); } + const statusColors = { + online: '#10B981', + offline: '#EF4444', + connecting: '#F59E0B', + pending: '#6B7280' + }; + const tabs = [ { id: 'overview', label: 'Overview' }, { id: 'docker', label: 'Docker' }, @@ -161,55 +165,35 @@ const ServerDetail = () => { return (
-
- Servers - / - {server.name} -
- -
-
-
- {(server.name || '?').charAt(0).toUpperCase()} -
-
-
-

{server.name}

- - - {server.status || 'pending'} - -
-
- {server.hostname || server.ip_address || 'No endpoint configured'} - {server.group_name && ( - {server.group_name} - )} - {server.os_type && {server.os_type}} - {server.agent_version && agent {server.agent_version}} - {server.last_seen && ( - - last seen {new Date(server.last_seen).toLocaleString()} - - )} -
- {server.description && ( -

{server.description}

- )} +
+
+ Servers + / + {server.name} +
+
+
+
+

{server.name}

+

+ {server.hostname || server.ip_address} + {server.description && ` - ${server.description}`} +

-
+
- - Docker -
-
+
{tabs.map(t => ( @@ -322,7 +306,7 @@ const OverviewTab = ({ server, metrics, systemInfo }) => { Operating System {systemInfo?.os || server.os_type || 'Unknown'} - {systemInfo?.os_version && ` ${systemInfo.os_version}`} + {(systemInfo?.os_version || server.os_version) && ` ${systemInfo?.os_version || server.os_version}`}
@@ -332,17 +316,17 @@ const OverviewTab = ({ server, metrics, systemInfo }) => {
CPU - {systemInfo?.cpu_model || 'N/A'} - {systemInfo?.cpu_cores && ` (${systemInfo.cpu_cores} cores)`} + {systemInfo?.cpu?.model || systemInfo?.cpu_model || server.cpu_model || 'N/A'} + {(systemInfo?.cpu?.cores || systemInfo?.cpu_cores || server.cpu_cores) && ` (${systemInfo?.cpu?.cores || systemInfo?.cpu_cores || server.cpu_cores} cores)`}
Total Memory - {formatBytes(systemInfo?.total_memory)} + {formatBytes(systemInfo?.total_memory || server.total_memory)}
Total Disk - {formatBytes(systemInfo?.total_disk)} + {formatBytes(systemInfo?.total_disk || server.total_disk)}
@@ -364,7 +348,7 @@ const OverviewTab = ({ server, metrics, systemInfo }) => {
Uptime - {formatUptime(metrics?.uptime)} + {formatUptime(metrics?.system?.uptime_seconds)}
@@ -373,9 +357,9 @@ const OverviewTab = ({ server, metrics, systemInfo }) => {

Current Resources

- - - + + +
)} @@ -647,27 +631,27 @@ const MetricsTab = ({ serverId, metrics }) => {
CPU - {(metrics.cpu_percent || 0).toFixed(1)}% + {(metrics?.cpu?.percent || 0).toFixed(1)}%
Memory - {(metrics.memory_percent || 0).toFixed(1)}% + {(metrics?.memory?.ram?.percent || 0).toFixed(1)}%
Disk - {(metrics.disk_percent || 0).toFixed(1)}% + {(metrics?.disk?.partitions?.[0]?.percent || 0).toFixed(1)}%
Net TX - {formatBytes(metrics.network_sent)}/s + {formatBytes(metrics?.network?.io?.bytes_sent_rate)}/s
Net RX - {formatBytes(metrics.network_recv)}/s + {formatBytes(metrics?.network?.io?.bytes_recv_rate)}/s
- Containers - {metrics.container_running || 0} / {metrics.container_count || 0} + Uptime + {(() => { const s = metrics?.system?.uptime_seconds; if (!s) return 'N/A'; const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : `${h}h ${m}m`; })()}
@@ -694,7 +678,7 @@ const AgentRegistrationSection = ({ server, onRegenerateToken }) => { Install-ServerKitAgent -Server "${window.location.origin}" -Token "${token}"` : ''; function copyToClipboard(text) { - navigator.clipboard.writeText(text); + clipboardWrite(text); setCopied(true); toast.success('Copied to clipboard'); setTimeout(() => setCopied(false), 2000); @@ -714,7 +698,7 @@ Install-ServerKitAgent -Server "${window.location.origin}" -Token "${token}"` :
- Linux + Linux / macOS @@ -1125,7 +1109,7 @@ const TokenModal = ({ server, onClose }) => { Install-ServerKitAgent -Server "${window.location.origin}" -Token "${token}"` : ''; function copyToClipboard(text) { - navigator.clipboard.writeText(text); + clipboardWrite(text); toast.success('Copied to clipboard'); } @@ -1147,8 +1131,8 @@ Install-ServerKitAgent -Server "${window.location.origin}" -Token "${token}"` :
- Linux - Linux server with curl, tar, sudo, and systemd + Linux / macOS + Ubuntu, Debian, CentOS, Fedora, Arch, macOS — requires curl and sudo
- - -
- {statCards.map(stat => ( - - ))}
-
-
- -
- Group - +
+
+
+ +
+
+
{stats.total}
+
Total Servers
- -
- {filteredServers.length} - {filteredServers.length === 1 ? 'server' : 'servers'} - {hasActiveFilters && ( - - )} +
+
+ +
+
+
{stats.online}
+
Online
+
-
- - {selectedIds.size > 0 && ( -
-
- {selectedIds.size} - selected +
+
+
-
- - +
+
{stats.offline}
+
Offline
- )} +
+
+ +
+
+
{stats.connecting}
+
Connecting
+
+
+
+ +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ +
+
{filteredServers.length === 0 ? ( -
-
- -
-

{servers.length === 0 ? 'No servers yet' : 'No servers match these filters'}

+
+ +

No servers found

{servers.length === 0 - ? 'Install a ServerKit agent on a machine to start monitoring health and managing Docker remotely.' - : 'Try a different status, group, or search term to bring machines back into view.'} + ? 'Add your first server to start managing remote infrastructure.' + : 'No servers match your current filters.'}

- {servers.length === 0 ? ( + {servers.length === 0 && ( - ) : ( - )}
) : ( -
- - - - - - - - - - - - - - {filteredServers.map(server => ( - toggleSelect(server.id)} - onPing={() => handlePingServer(server.id)} - onDelete={() => setDeleteTarget(server)} - onCopyInstall={() => handleCopyInstall(server)} - /> - ))} - -
- { if (el) el.indeterminate = someVisibleSelected && !allVisibleSelected; }} - onChange={() => toggleSelectAll(visibleIds)} - /> - ServerStatusGroupOS · AgentTelemetryLast seen -
+
+ {filteredServers.map(server => ( + handlePingServer(server.id)} + /> + ))}
)} @@ -372,370 +193,119 @@ const Servers = () => { onUpdated={loadData} /> )} - - setDeleteTarget(null)} - /> - - setBulkDeleteOpen(false)} - />
); }; -const formatLastSeen = (timestamp) => { - if (!timestamp) return 'Never'; - const date = new Date(timestamp); - const now = new Date(); - const diff = (now - date) / 1000; - if (diff < 60) return 'Just now'; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - return date.toLocaleDateString(); -}; - -const clamp = (v) => Math.min(100, Math.max(0, Number(v) || 0)); - -const ServerRow = ({ server, selected, onToggle, onPing, onDelete, onCopyInstall }) => { - const [menuPos, setMenuPos] = useState(null); - const triggerRef = useRef(null); - const menuRef = useRef(null); - - const closeMenu = useCallback(() => setMenuPos(null), []); - - const openMenu = () => { - const r = triggerRef.current?.getBoundingClientRect(); - if (!r) return; - const menuHeight = 240; - const menuWidth = 220; - const flipUp = r.bottom + menuHeight + 8 > window.innerHeight; - setMenuPos({ - top: flipUp ? r.top - menuHeight - 4 : r.bottom + 4, - left: Math.max(8, r.right - menuWidth), - }); +const ServerCard = ({ server, onPing }) => { + const statusColors = { + online: '#10B981', + offline: '#EF4444', + connecting: '#F59E0B', + pending: '#6B7280' }; - useEffect(() => { - if (!menuPos) return undefined; - const onDocDown = (e) => { - if ( - menuRef.current && !menuRef.current.contains(e.target) && - triggerRef.current && !triggerRef.current.contains(e.target) - ) { - closeMenu(); - } - }; - const onScroll = () => closeMenu(); - document.addEventListener('mousedown', onDocDown); - window.addEventListener('scroll', onScroll, true); - window.addEventListener('resize', onScroll); - return () => { - document.removeEventListener('mousedown', onDocDown); - window.removeEventListener('scroll', onScroll, true); - window.removeEventListener('resize', onScroll); - }; - }, [menuPos, closeMenu]); - - const status = server.status || 'pending'; - const displayHost = server.hostname || server.ip_address || 'Unassigned endpoint'; - const initial = (server.name || displayHost || '?').charAt(0).toUpperCase(); - const metrics = { - cpu: clamp(server.metrics?.cpu_percent), - memory: clamp(server.metrics?.memory_percent), - disk: clamp(server.metrics?.disk_percent), + const formatLastSeen = (timestamp) => { + if (!timestamp) return 'Never'; + const date = new Date(timestamp); + const now = new Date(); + const diff = (now - date) / 1000; + + if (diff < 60) return 'Just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return date.toLocaleDateString(); }; - const hasMetrics = server.metrics && status === 'online'; return ( - - - - - - - - - {server.name} - {displayHost} - - - - - - - {status} - - - - {server.group_name ? ( - - - {server.group_name} - - ) : ( - - )} - - -
- {server.os_type || 'Unknown'} - {server.agent_version ? `agent ${server.agent_version}` : 'agent not installed'} +
+
+
+
+

{server.name}

+ {server.hostname || server.ip_address}
- - - {hasMetrics ? ( -
- - CPU - - {metrics.cpu.toFixed(0)}% - - - RAM - - {metrics.memory.toFixed(0)}% - - - DSK - - {metrics.disk.toFixed(0)}% - -
- ) : ( - {status === 'pending' ? 'Awaiting agent' : status === 'offline' ? 'Offline' : 'No data'} - )} - - - {formatLastSeen(server.last_seen)} - - -
- - - - - - {menuPos && ( -
- - View details - - - {status === 'pending' && ( - - )} - - Manage Docker - -
- -
- )} -
- - - ); -}; - -const PairAgentForm = ({ groups, onClose, onClaimed }) => { - const [pairCode, setPairCode] = useState(''); - const [passphrase, setPassphrase] = useState(''); - const [name, setName] = useState(''); - const [groupId, setGroupId] = useState(''); - const [lookupResult, setLookupResult] = useState(null); - const [lookupError, setLookupError] = useState(''); - const [claimError, setClaimError] = useState(''); - const [loading, setLoading] = useState(false); - const toast = useToast(); - - const formattedCode = pairCode - .toUpperCase() - .replace(/[^0-9A-Z]/g, '') - .replace(/[01OIL]/g, '') - .slice(0, 6); - - async function handleLookup() { - setLookupError(''); - setLookupResult(null); - if (formattedCode.length !== 6) { - setLookupError('Pair code must be 6 characters'); - return; - } - setLoading(true); - try { - const res = await api.lookupPairCode(formattedCode); - setLookupResult(res); - if (!name && res.suggested_name) setName(res.suggested_name); - } catch (err) { - setLookupError(err.message || 'Pair code not found'); - } finally { - setLoading(false); - } - } - - async function handleClaim(e) { - e.preventDefault(); - setClaimError(''); - if (!passphrase) { - setClaimError('Passphrase is required'); - return; - } - setLoading(true); - try { - await api.claimPairedAgent({ - pair_code: formattedCode, - passphrase, - name: name || undefined, - group_id: groupId || undefined, - trust_fingerprint: true - }); - toast.success('Agent paired successfully'); - onClaimed(); - } catch (err) { - setClaimError(err.message || 'Failed to claim agent'); - } finally { - setLoading(false); - } - } - - function formatDisplay(code) { - if (!code) return '------'; - return code.length > 3 ? `${code.slice(0, 3)}-${code.slice(3)}` : code; - } +
- return ( -
-
-
- - { - setPairCode(e.target.value); - setLookupResult(null); - setLookupError(''); - }} - onBlur={handleLookup} - placeholder="ABC-123" - autoFocus - autoComplete="off" - spellCheck={false} - style={{ fontFamily: 'monospace', fontSize: '1.25rem', letterSpacing: '0.15em', textAlign: 'center' }} - required - /> - Run serverkit-agent pair on the target machine and read the code from its output (or system tray). - {lookupError &&
{lookupError}
} +
+
+
+ OS + {server.os_type || 'Unknown'} +
+
+ Agent + {server.agent_version || 'Not installed'} +
+
+ Docker + {server.docker_version || 'N/A'} +
+
+ Last Seen + {formatLastSeen(server.last_seen)} +
- {lookupResult && ( -
-
- Agent found -

- Hostname: {lookupResult.hostname || 'unknown'}
- Fingerprint: {lookupResult.pubkey_fpr} -

-

- Confirm this fingerprint matches the one shown by the agent before continuing. -

+ {server.metrics && server.status === 'online' && ( +
+
+ CPU +
+
+
+ {(server.metrics.cpu_percent || 0).toFixed(0)}% +
+
+ RAM +
+
+
+ {(server.metrics.memory_percent || 0).toFixed(0)}% +
+
+ Disk +
+
+
+ {(server.metrics.disk_percent || 0).toFixed(0)}%
)} -
- - setPassphrase(e.target.value)} - placeholder="The passphrase set when pairing started" - autoComplete="new-password" - required - /> -
- -
-
- - setName(e.target.value)} - placeholder="prod-web-01 (optional)" - /> -
-
- - + {server.group_name && ( +
+ + {server.group_name}
-
- - {claimError &&
{claimError}
} + )}
-
- - + + Docker +
- +
); }; const AddServerModal = ({ groups, onClose, onCreated }) => { - const [mode, setMode] = useState('pair'); const [step, setStep] = useState(1); const [formData, setFormData] = useState({ name: '', @@ -743,10 +313,8 @@ const AddServerModal = ({ groups, onClose, onCreated }) => { group_id: '', hostname: '', ip_address: '', - permission_profile: 'deployment_runner', - permissions: [] + permissions: ['docker:read', 'docker:write', 'system:read'] }); - const [showOptional, setShowOptional] = useState(false); const [registrationData, setRegistrationData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -774,142 +342,120 @@ const AddServerModal = ({ groups, onClose, onCreated }) => { } function copyToClipboard(text) { - navigator.clipboard.writeText(text); + clipboardWrite(text); toast.success('Copied to clipboard'); } - const linuxInstallScript = registrationData ? `curl -fsSL ${window.location.origin}/api/v1/servers/install.sh | sudo bash -s -- \\ - --server "${window.location.origin}" \\ + const getBaseUrl = () => { + // Prefer the API URL from environment, stripped of /api/v1 + const apiUrl = import.meta.env.VITE_API_URL; + if (apiUrl) { + return apiUrl.replace('/api/v1', ''); + } + return window.location.origin; + }; + + const baseUrl = getBaseUrl(); + + const linuxInstallScript = registrationData ? `curl -fsSL ${baseUrl}/api/v1/servers/install.sh | sudo bash -s -- \\ + --server "${baseUrl}" \\ --token "${registrationData.registration_token}"` : ''; - const windowsInstallScript = registrationData ? `irm ${window.location.origin}/api/v1/servers/install.ps1 | iex -Install-ServerKitAgent -Server "${window.location.origin}" -Token "${registrationData.registration_token}"` : ''; + const windowsInstallScript = registrationData ? `irm ${baseUrl}/api/v1/servers/install.ps1 | iex +Install-ServerKitAgent -Server "${baseUrl}" -Token "${registrationData.registration_token}"` : ''; return (
-
e.stopPropagation()}> +
e.stopPropagation()}>
-
- {step === 1 ? 'New agent' : 'Registration ready'} -

{step === 1 ? 'Add Server' : (mode === 'pair' ? 'Pair Agent' : 'Install Agent')}

-

{step === 1 ? 'Pair an already-running agent with a short code, or generate an install script for a new machine.' : (mode === 'pair' ? 'Enter the 6-char code shown on the agent and your passphrase.' : 'Run one command on the target machine to bring it online.')}

-
+

{step === 1 ? 'Add Server' : 'Install Agent'}

- {step === 1 && ( -
- - -
- )} + {step === 1 ? ( +
+ {error &&
{error}
} + +
+ + +
- {step === 1 && mode === 'pair' ? ( - - ) : step === 1 ? ( - -
- {error &&
{error}
} +
+ +