diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..2f13b1b8 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +POSTGRES_USER=yakpanel +POSTGRES_PASSWORD=yakpanel_dev_password +POSTGRES_DB=yakpanel +POSTGRES_PORT=5432 + +REDIS_PORT=6379 + +NATS_PORT=4222 +NATS_MONITOR_PORT=8222 + +MINIO_ROOT_USER=yakpanel +MINIO_ROOT_PASSWORD=yakpanel_minio_password +MINIO_PORT=9000 +MINIO_CONSOLE_PORT=9001 + +API_PORT=8080 diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..4b272e17 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +SHELL := /bin/bash + +COMPOSE := docker compose --env-file .env -f docker-compose.yakpanel.yml + +.PHONY: up down logs ps init doctor migrate + +init: + cp -n .env.example .env || true + @echo "Initialized .env (kept existing values if present)" + +up: + $(COMPOSE) up -d --build + +down: + $(COMPOSE) down + +logs: + $(COMPOSE) logs -f --tail=100 + +ps: + $(COMPOSE) ps + +doctor: + @docker --version + @docker compose version + +migrate: + $(COMPOSE) run --rm db-migrate diff --git a/README.md b/README.md index 085e309c..17880932 100644 --- a/README.md +++ b/README.md @@ -91,3 +91,31 @@ Dir usage analysis **Note: after the deployment is complete, please immediately modify the user name and password in the panel settings and add the installation entry** +## YakPanel 2026 Dev Scaffold (Ubuntu) + +Use this for the new multi-service architecture scaffold in this repository: + +```bash +cd YakPanel-master +chmod +x scripts/install-ubuntu.sh scripts/bootstrap-dev.sh +./scripts/install-ubuntu.sh +``` + +One-click installer: + +```bash +cd YakPanel-master +chmod +x one-click-installer.sh +./one-click-installer.sh +``` + +Or run manually: + +```bash +make init +make up +make ps +``` + +Reference: `docs/ubuntu-dev-install.md` + diff --git a/architecture/2026/01-bounded-contexts.md b/architecture/2026/01-bounded-contexts.md new file mode 100644 index 00000000..e011f69c --- /dev/null +++ b/architecture/2026/01-bounded-contexts.md @@ -0,0 +1,64 @@ +# YakPanel 2026 Bounded Contexts and Ownership + +This document defines ownership boundaries between the Laravel control plane and Go execution services. + +## Domain Contexts + +### IdentityAndAccess (Laravel) +- Owns tenants, users, membership, roles, permissions, and scoped policy evaluation. +- Exposes authn/authz services to all modules. +- Guarantees tenant isolation at query and policy layers. + +### TenantBillingAndLifecycle (Laravel) +- Owns tenant lifecycle, plan limits, billing integration hooks, and quota enforcement. +- Emits events used by job orchestration and plugin entitlements. + +### InventoryAndServerRegistry (Laravel + agent-gateway) +- Laravel owns canonical server records, labels, region mapping, and assignment. +- `agent-gateway` owns live session presence and online/offline detection. + +### HostingResourceManagement (Laravel) +- Owns site, domain, SSL metadata, FTP metadata, database metadata, and lifecycle workflows. +- Delegates mutable node operations to Go engines through command orchestration. + +### WorkloadOrchestration (Laravel + Go engines) +- Laravel owns workflow composition, step state machine, retry policy, audit trail. +- Go services own command execution logic for privileged operations. + +### PluginMarketplace (Laravel + Go engine-fileops/engine-docker) +- Laravel owns catalog, signatures, compatibility metadata, entitlements. +- Go services own installation actions on managed nodes. + +### ObservabilityAndAlerting (Laravel + metrics pipeline) +- Laravel owns dashboards, alert rules, routing, and incident metadata. +- Metrics ingestion pipeline owns aggregation and retention. + +### IntegrationAPI (Laravel) +- Owns third-party API tokens, webhooks, and scoped public endpoints. + +## Service Ownership Matrix + +| Capability | Laravel Module | Go Service | Notes | +|---|---|---|---| +| Tenant and RBAC | Auth, Tenant, Rbac | N/A | Policy checks happen before dispatch. | +| Server enrollment metadata | Server | agent-gateway | Enrollment token issued by Laravel, redeemed via gateway. | +| Website lifecycle | Site, Domain, Ssl | engine-site | Laravel stores desired state; engine enforces actual state. | +| Docker app deployment | Apps | engine-docker | Templates validated in Laravel, executed by engine-docker. | +| MySQL/Redis management | Database, Redis | engine-db | Credentials references stored in Laravel. | +| File operations | Files | engine-fileops | Strict allowlists and safe path constraints. | +| Firewall/security | Firewall | engine-security | Security engine returns audit evidence artifacts. | +| Backup and restore | Backups | engine-backup | Backup plans defined in Laravel. | +| Agent session routing | Agents | agent-gateway | mTLS and command channel handling in gateway. | +| Monitoring and alerts | Metrics, Alerts | metrics-ingest | Live streams + retained aggregates. | + +## Cross-Context Contracts + +- Commands are immutable envelopes with `idempotency_key`. +- All control-plane writes emit domain events. +- Engines are stateless workers and read policy-free command payloads. +- Agents execute only capability-approved command types. + +## Non-Goals + +- No direct shell command execution from Laravel workers. +- No shared mutable state between engine services outside contract stores/queues. diff --git a/architecture/2026/02-core-schema.sql b/architecture/2026/02-core-schema.sql new file mode 100644 index 00000000..c70d0d42 --- /dev/null +++ b/architecture/2026/02-core-schema.sql @@ -0,0 +1,184 @@ +-- YakPanel 2026 core schema +-- PostgreSQL 15+ compatible + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "citext"; + +-- Shared timestamps helper convention: +-- created_at timestamptz not null default now() +-- updated_at timestamptz not null default now() + +CREATE TABLE tenants ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + name varchar(150) NOT NULL, + slug citext NOT NULL UNIQUE, + status varchar(32) NOT NULL DEFAULT 'active', + plan_code varchar(64) NOT NULL DEFAULT 'starter', + settings jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE users ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + email citext NOT NULL UNIQUE, + password_hash text NOT NULL, + display_name varchar(120) NOT NULL, + is_platform_admin boolean NOT NULL DEFAULT false, + last_login_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE tenant_users ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status varchar(32) NOT NULL DEFAULT 'active', + joined_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, user_id) +); + +CREATE TABLE roles ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name varchar(64) NOT NULL, + description text NOT NULL DEFAULT '', + is_system boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, name) +); + +CREATE TABLE permissions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + code varchar(100) NOT NULL UNIQUE, + description text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE role_permissions ( + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id uuid NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (role_id, permission_id) +); + +CREATE TABLE user_roles ( + tenant_user_id uuid NOT NULL REFERENCES tenant_users(id) ON DELETE CASCADE, + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_user_id, role_id) +); + +CREATE TABLE scopes ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + role_id uuid NULL REFERENCES roles(id) ON DELETE CASCADE, + resource_type varchar(64) NOT NULL, + resource_id uuid NULL, + action varchar(64) NOT NULL, + effect varchar(8) NOT NULL DEFAULT 'allow', + conditions jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE servers ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name varchar(120) NOT NULL, + host varchar(255) NOT NULL, + port integer NOT NULL DEFAULT 443, + os_type varchar(32) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'provisioning', + region varchar(64) NOT NULL DEFAULT 'global', + labels jsonb NOT NULL DEFAULT '[]'::jsonb, + heartbeat_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, name) +); + +CREATE TABLE server_agents ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + agent_uid varchar(128) NOT NULL UNIQUE, + agent_version varchar(32) NOT NULL, + capabilities jsonb NOT NULL DEFAULT '{}'::jsonb, + cert_fingerprint varchar(255) NOT NULL, + last_seen_at timestamptz NULL, + status varchar(32) NOT NULL DEFAULT 'offline', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE jobs ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + server_id uuid NULL REFERENCES servers(id) ON DELETE SET NULL, + requested_by_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL, + type varchar(64) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'queued', + priority smallint NOT NULL DEFAULT 5, + idempotency_key varchar(120) NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + result jsonb NULL, + started_at timestamptz NULL, + finished_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, idempotency_key) +); + +CREATE TABLE job_steps ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + job_id uuid NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + step_order integer NOT NULL, + name varchar(80) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'pending', + input jsonb NOT NULL DEFAULT '{}'::jsonb, + output jsonb NULL, + error text NULL, + started_at timestamptz NULL, + finished_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (job_id, step_order) +); + +CREATE TABLE job_events ( + id bigserial PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + job_id uuid NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + event_type varchar(64) NOT NULL, + source varchar(32) NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_job_events_tenant_job_created ON job_events (tenant_id, job_id, created_at DESC); + +CREATE TABLE audit_logs ( + id bigserial PRIMARY KEY, + tenant_id uuid NULL REFERENCES tenants(id) ON DELETE SET NULL, + actor_user_id uuid NULL REFERENCES users(id) ON DELETE SET NULL, + actor_type varchar(32) NOT NULL, + action varchar(80) NOT NULL, + resource_type varchar(64) NOT NULL, + resource_id uuid NULL, + request_id varchar(64) NULL, + ip inet NULL, + user_agent text NULL, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_audit_logs_tenant_created ON audit_logs (tenant_id, created_at DESC); +CREATE INDEX idx_scopes_tenant_action ON scopes (tenant_id, action); +CREATE INDEX idx_servers_tenant_status ON servers (tenant_id, status); +CREATE INDEX idx_jobs_tenant_status ON jobs (tenant_id, status); diff --git a/architecture/2026/03-agent-protocol-v1.md b/architecture/2026/03-agent-protocol-v1.md new file mode 100644 index 00000000..8a7f19f0 --- /dev/null +++ b/architecture/2026/03-agent-protocol-v1.md @@ -0,0 +1,123 @@ +# YakPanel Agent Protocol v1 + +## Goals +- Secure control channel between panel and managed servers. +- Reliable command execution with resumable progress. +- Capability-aware execution and strict auditability. + +## Transport and Session +- Primary transport: bidirectional gRPC stream over mTLS. +- Fallback transport: WebSocket over mTLS. +- Agent uses outbound connection only. +- Server requires certificate pinning and protocol version negotiation. + +## Version Negotiation +- Agent sends `protocol_versions` during hello. +- Gateway picks highest compatible version and responds with `selected_version`. +- If no compatible version exists, gateway returns `ERR_UNSUPPORTED_VERSION`. + +## Message Envelope (common fields) +- `message_id` (uuid): unique per message. +- `timestamp` (RFC3339 UTC): replay protection. +- `tenant_id` (uuid): owning tenant. +- `server_id` (uuid): managed server. +- `agent_id` (string): persistent agent identity. +- `trace_id` (string): distributed trace correlation. +- `signature` (base64): detached signature over canonical payload. + +## Enrollment Flow +1. Agent starts with one-time bootstrap token. +2. Agent calls `EnrollRequest` with host fingerprint and capabilities. +3. Gateway validates token and issues short-lived enrollment certificate. +4. Agent re-connects with cert; gateway persists `agent_id` and fingerprints. +5. Enrollment token is revoked after successful bind. + +## Heartbeat Flow +- Interval: every 10 seconds. +- Payload includes: + - `agent_version` + - `capability_hash` + - `system_load` (cpu, mem, disk) + - `service_states` (nginx/apache/ols/mysql/redis/docker) +- Gateway updates presence and emits server status event. +- Missing 3 consecutive heartbeats marks agent as degraded; 6 marks offline. + +## Command Flow + +### CommandRequest +- `cmd_id` (uuid) +- `job_id` (uuid) +- `type` (enum): `SITE_CREATE`, `SITE_DELETE`, `SSL_ISSUE`, `SSL_APPLY`, `SERVICE_RELOAD`, `DOCKER_DEPLOY`, etc. +- `args` (json map) +- `timeout_sec` (int) +- `idempotency_key` (string) +- `required_capabilities` (string array) + +### Agent behavior +- Validate signature and timestamp window. +- Validate required capability set. +- Return ACK immediately with acceptance or reject reason. +- Emit progress events at step boundaries. +- Emit final result with exit code, summaries, and artifact references. + +### Retry and Exactly-once strategy +- Gateway retries only if no terminal result and timeout exceeded. +- Agent deduplicates by `idempotency_key` and returns last known terminal state for duplicates. +- Workflow layer (Laravel) provides exactly-once user-visible semantics. + +## Event Types +- `AGENT_HELLO` +- `AGENT_HEARTBEAT` +- `COMMAND_ACK` +- `COMMAND_PROGRESS` +- `COMMAND_LOG` +- `COMMAND_RESULT` +- `COMMAND_ERROR` +- `AGENT_STATE_CHANGED` + +## Error Model +- Error codes: + - `ERR_UNAUTHORIZED` + - `ERR_UNSUPPORTED_VERSION` + - `ERR_INVALID_SIGNATURE` + - `ERR_REPLAY_DETECTED` + - `ERR_CAPABILITY_MISSING` + - `ERR_INVALID_ARGS` + - `ERR_EXECUTION_FAILED` + - `ERR_TIMEOUT` +- Every error includes `code`, `message`, `retryable`, `details`. + +## Security Controls +- mTLS with rotating short-lived certs. +- Nonce + timestamp replay protection. +- Detached command signatures from panel signing key. +- Agent command allowlist by capability. +- Immutable append-only event log in control plane. + +## Minimal Protobuf Sketch + +```proto +syntax = "proto3"; +package yakpanel.agent.v1; + +message Envelope { + string message_id = 1; + string timestamp = 2; + string tenant_id = 3; + string server_id = 4; + string agent_id = 5; + string trace_id = 6; + bytes signature = 7; +} + +message CommandRequest { + Envelope envelope = 1; + string cmd_id = 2; + string job_id = 3; + string type = 4; + string args_json = 5; + int32 timeout_sec = 6; + string idempotency_key = 7; + repeated string required_capabilities = 8; +} +``` diff --git a/architecture/2026/04-server-plane-implementation.md b/architecture/2026/04-server-plane-implementation.md new file mode 100644 index 00000000..59d9d020 --- /dev/null +++ b/architecture/2026/04-server-plane-implementation.md @@ -0,0 +1,22 @@ +# Server Plane Implementation (v1) + +## Implemented Components +- Laravel server registry routes and controllers under `panel-api`. +- Agent enrollment/capability/session endpoints in `panel-api`. +- Go command contract and dispatcher interface in `control-plane-go`. +- `agent-gateway` process entrypoint for session and heartbeat responsibilities. + +## End-to-End Control Flow +1. User creates server via `POST /api/v1/servers`. +2. User requests one-time enrollment token. +3. Agent enrolls with gateway, obtains persistent identity. +4. Laravel writes job and dispatches command envelope. +5. Go dispatcher publishes command to execution topic. +6. Agent executes command and streams results back. +7. Laravel updates job state and exposes real-time status to UI. + +## Next Implementation Edges +- Persist server/agent entities in Eloquent models. +- Add request validation and RBAC middleware to server routes. +- Implement Redis Streams/NATS queue adapter for dispatcher queue interface. +- Add gateway gRPC service and heartbeat/session persistence adapter. diff --git a/architecture/2026/05-hosting-mvp.md b/architecture/2026/05-hosting-mvp.md new file mode 100644 index 00000000..5ece54e1 --- /dev/null +++ b/architecture/2026/05-hosting-mvp.md @@ -0,0 +1,26 @@ +# Hosting MVP Implementation Notes + +## Scope +- Website create/update/delete workflows. +- Domain binding/unbinding. +- SSL issue/apply/renew automation. +- Webserver adapter support: Nginx, Apache, OpenLiteSpeed. + +## Control Plane APIs +- `GET /api/v1/sites` +- `POST /api/v1/sites` +- `POST /api/v1/sites/{site}/domains` +- `POST /api/v1/ssl/issue` +- `POST /api/v1/ssl/apply` +- `POST /api/v1/ssl/renew` + +## Execution Contracts +- `SITE_CREATE`, `SITE_UPDATE`, `SITE_DELETE` +- `DOMAIN_ADD`, `DOMAIN_REMOVE` +- `SSL_ISSUE`, `SSL_APPLY`, `SSL_RENEW` +- `WEBSERVER_RELOAD` + +## Runtime Guarantees +- Every operation is a tracked job with progress updates. +- SSL operations require DNS precheck and challenge validation before apply. +- Webserver reload only occurs after adapter-specific config validation. diff --git a/architecture/2026/06-api-structure.yaml b/architecture/2026/06-api-structure.yaml new file mode 100644 index 00000000..c7119ead --- /dev/null +++ b/architecture/2026/06-api-structure.yaml @@ -0,0 +1,115 @@ +openapi: 3.0.3 +info: + title: YakPanel Control Plane API + version: 1.0.0 +servers: + - url: /api/v1 +paths: + /auth/login: + post: + tags: [Auth] + summary: Authenticate user and return token + responses: + "200": { description: OK } + /tenants: + get: + tags: [Tenants] + summary: List tenant memberships + responses: + "200": { description: OK } + /servers: + get: + tags: [Servers] + summary: List managed servers + responses: + "200": { description: OK } + post: + tags: [Servers] + summary: Register server metadata + responses: + "202": { description: Accepted } + /sites: + get: + tags: [Sites] + summary: List sites + responses: + "200": { description: OK } + post: + tags: [Sites] + summary: Create site + responses: + "202": { description: Accepted } + /ssl/issue: + post: + tags: [SSL] + summary: Issue certificate + responses: + "202": { description: Accepted } + /files/list: + post: + tags: [Files] + summary: List files in allowed path scope + responses: + "200": { description: OK } + /cron/jobs: + get: + tags: [Cron] + summary: List cron jobs + responses: + "200": { description: OK } + post: + tags: [Cron] + summary: Create cron job + responses: + "202": { description: Accepted } + /firewall/rules: + get: + tags: [Firewall] + summary: List firewall rules + responses: + "200": { description: OK } + post: + tags: [Firewall] + summary: Add firewall rule + responses: + "202": { description: Accepted } + /backups/policies: + get: + tags: [Backups] + summary: List backup policies + responses: + "200": { description: OK } + post: + tags: [Backups] + summary: Create backup policy + responses: + "201": { description: Created } + /plugins/market: + get: + tags: [Marketplace] + summary: Browse plugin marketplace + responses: + "200": { description: OK } + /plugins/install: + post: + tags: [Marketplace] + summary: Install plugin + responses: + "202": { description: Accepted } + /metrics/servers/{serverId}/live: + get: + tags: [Monitoring] + summary: Get latest real-time server metrics + parameters: + - in: path + name: serverId + required: true + schema: { type: string, format: uuid } + responses: + "200": { description: OK } +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/architecture/2026/07-microservices-breakdown.md b/architecture/2026/07-microservices-breakdown.md new file mode 100644 index 00000000..9ec65821 --- /dev/null +++ b/architecture/2026/07-microservices-breakdown.md @@ -0,0 +1,37 @@ +# Go Microservices Breakdown + +## Services + +### agent-gateway +- Terminates mTLS channels from agents. +- Tracks sessions, heartbeats, and capability maps. +- Relays command envelopes and result events. + +### engine-site +- Manages site config generation, domain bindings, and SSL apply pipelines. +- Supports Nginx, Apache, OpenLiteSpeed provider adapters. + +### engine-docker +- Deploys one-click apps via compose templates. +- Handles image pulls, health checks, and rollback. + +### engine-db +- Manages MySQL and Redis lifecycle commands. +- Executes create database/user, backup, restore primitives. + +### engine-security +- Applies firewall policies and security hardening commands. +- Integrates fail2ban and baseline checks. + +### engine-backup +- Runs backup plans and restore workflows. +- Pushes artifacts to object storage providers. + +### engine-fileops +- Secure file manager operations with path policy controls. +- Chunked upload, download token issuance, lock-aware editing. + +## Communication Model +- Inbound command envelopes from Laravel orchestrator. +- Internal queue topic fan-out by command type. +- Outbound event stream for progress, logs, and terminal results. diff --git a/architecture/2026/08-folder-structure.md b/architecture/2026/08-folder-structure.md new file mode 100644 index 00000000..668a7153 --- /dev/null +++ b/architecture/2026/08-folder-structure.md @@ -0,0 +1,58 @@ +# Suggested 2026 Folder Structure + +## Root Layout + +```text +YakPanel-master/ + panel-api/ # Laravel control plane + panel-web/ # Next.js + Tailwind UI + control-plane-go/ # Go execution services + yak-agent/ # Go daemon on managed servers + architecture/2026/ # Blueprint and contracts +``` + +## Laravel (`panel-api`) +- `app/Modules/Auth` +- `app/Modules/Tenant` +- `app/Modules/Rbac` +- `app/Modules/Server` +- `app/Modules/Agents` +- `app/Modules/Site` +- `app/Modules/Ssl` +- `app/Modules/Files` +- `app/Modules/Cron` +- `app/Modules/Firewall` +- `app/Modules/Backups` +- `app/Modules/Plugin` +- `app/Modules/Metrics` +- `routes/api_v1` + +## Next.js (`panel-web`) +- `src/app/(dashboard)` +- `src/features/server` +- `src/features/sites` +- `src/features/marketplace` +- `src/features/metrics` +- `src/lib/api` +- `src/lib/ws` + +## Go control-plane (`control-plane-go`) +- `cmd/agent-gateway` +- `cmd/engine-site` +- `cmd/engine-docker` +- `cmd/engine-db` +- `cmd/engine-security` +- `cmd/engine-backup` +- `cmd/engine-fileops` +- `internal/orchestration` +- `internal/webserver` +- `pkg/contracts` +- `pkg/proto` + +## Go agent (`yak-agent`) +- `cmd/agent` +- `internal/transport` +- `internal/executor` +- `internal/collectors` +- `internal/updater` +- `pkg/capabilities` diff --git a/architecture/2026/09-development-roadmap.md b/architecture/2026/09-development-roadmap.md new file mode 100644 index 00000000..0209ad63 --- /dev/null +++ b/architecture/2026/09-development-roadmap.md @@ -0,0 +1,30 @@ +# YakPanel 2026 Development Roadmap + +## Phase 1: Foundation (Weeks 1-3) +- Stand up repositories and CI pipelines. +- Define coding standards, security checks, and observability baseline. +- Establish Docker-based developer environment. + +## Phase 2: Identity + Tenancy (Weeks 4-6) +- Implement tenants, users, tenant membership, and RBAC scopes. +- Add API tokens, session controls, and audit logging. + +## Phase 3: Server Plane (Weeks 7-10) +- Implement server registry, agent enrollment tokens, and live sessions. +- Deliver command orchestration and event stream updates. + +## Phase 4: Hosting MVP (Weeks 11-15) +- Implement sites/domains/SSL workflows. +- Add Nginx/Apache/OpenLiteSpeed adapters and validation gates. + +## Phase 5: Platform Operations (Weeks 16-20) +- Deliver file manager, cron manager, firewall, backup policy engine. +- Add MySQL/Redis lifecycle management. + +## Phase 6: Marketplace + Monitoring (Weeks 21-25) +- Deliver plugin marketplace catalog and signed install pipeline. +- Build real-time metrics dashboard and alerting engine. + +## Phase 7: SaaS Hardening (Weeks 26-30) +- Quotas, plan enforcement, webhook integrations, public API. +- Multi-region readiness and disaster recovery rehearsals. diff --git a/architecture/2026/10-ops-marketplace-observability.md b/architecture/2026/10-ops-marketplace-observability.md new file mode 100644 index 00000000..a204dd62 --- /dev/null +++ b/architecture/2026/10-ops-marketplace-observability.md @@ -0,0 +1,41 @@ +# Ops, Marketplace, and Observability Implementation + +## Ops Modules + +### File Manager +- Command types: `FILE_LIST`, `FILE_READ`, `FILE_WRITE`, `FILE_UPLOAD_CHUNK`, `FILE_MOVE`, `FILE_DELETE`. +- Security: path sandbox, denylist for system paths, content size and MIME checks. + +### Cron Manager +- Command types: `CRON_LIST`, `CRON_CREATE`, `CRON_UPDATE`, `CRON_DELETE`. +- Validation: cron expression parser, command allowlist/templating, dry-run syntax checks. + +### Firewall + Security Tools +- Command types: `FIREWALL_RULE_ADD`, `FIREWALL_RULE_DELETE`, `SECURITY_SCAN_BASELINE`. +- Audit: every mutation recorded with actor, reason, and approval trace. + +### Backup & Restore +- Command types: `BACKUP_RUN`, `BACKUP_RESTORE`, `BACKUP_VERIFY`. +- Flows: policy-driven schedules, retention lifecycle, encrypted object storage artifacts. + +## Plugin Marketplace +- Catalog includes signed package metadata, compatibility matrix, and permission manifest. +- Install pipeline: + 1. Resolve package and verify signature. + 2. Validate required capabilities and tenant entitlement. + 3. Execute install as job with rollback hooks. + 4. Persist install status and event timeline. + +## Real-time Monitoring Dashboard +- Live channels: CPU, RAM, disk I/O, network throughput, process health. +- Pipeline: + - agent collectors -> gateway ingest -> Redis stream -> metrics store. +- UI: + - server list health badges, + - per-server timeline charts, + - alert panels with acknowledgement workflow. + +## Performance Targets +- P95 live metric latency: < 2 seconds. +- P95 command dispatch to agent ACK: < 1 second. +- P95 dashboard query time (last 1 hour): < 400 ms. diff --git a/architecture/2026/11-identity-core-schema.sql b/architecture/2026/11-identity-core-schema.sql new file mode 100644 index 00000000..006027e5 --- /dev/null +++ b/architecture/2026/11-identity-core-schema.sql @@ -0,0 +1,41 @@ +-- YakPanel 2026 Identity Core additions + +CREATE TABLE IF NOT EXISTS api_tokens ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name varchar(80) NOT NULL, + token_hash varchar(255) NOT NULL UNIQUE, + scopes jsonb NOT NULL DEFAULT '[]'::jsonb, + expires_at timestamptz NULL, + last_used_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS sessions ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + refresh_token_hash varchar(255) NOT NULL UNIQUE, + ip inet NULL, + user_agent text NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS mfa_factors ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + factor_type varchar(24) NOT NULL, + secret_encrypted text NOT NULL, + recovery_codes_encrypted text NULL, + verified_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_tenant_user ON api_tokens (tenant_id, user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_tenant_user ON sessions (tenant_id, user_id); diff --git a/architecture/2026/12-identity-phase-implementation.md b/architecture/2026/12-identity-phase-implementation.md new file mode 100644 index 00000000..4d38119b --- /dev/null +++ b/architecture/2026/12-identity-phase-implementation.md @@ -0,0 +1,24 @@ +# Phase 2 Implementation: Identity Core + +## Delivered +- Identity route group in `panel-api/routes/api_v1/identity.php`. +- Controllers for auth, tenants, and RBAC module boundaries: + - `AuthController` + - `TenantController` + - `RbacController` +- Scope evaluator service for allow/deny decision logic: + - `ScopeEvaluator::isAllowed(...)` +- Identity schema additions: + - `api_tokens` + - `sessions` + - `mfa_factors` + +## Behavior Contract +- Every protected endpoint requires bearer auth middleware. +- Access checks are explicit through RBAC grant evaluation. +- Session/token tables support rotation, revocation, and forensic tracking. + +## Next phase options +- Wire persistent Eloquent models and form requests. +- Add tenant-aware middleware that injects active tenant context. +- Replace placeholder auth responses with JWT + refresh token issue/rotation. diff --git a/architecture/2026/13-server-plane-phase-implementation.md b/architecture/2026/13-server-plane-phase-implementation.md new file mode 100644 index 00000000..34cfb92d --- /dev/null +++ b/architecture/2026/13-server-plane-phase-implementation.md @@ -0,0 +1,25 @@ +# Phase 3 Implementation: Server Plane + +## Delivered in this phase +- Tenant-aware server registry controller behavior via request tenant context. +- Agent session heartbeat intake endpoint and session repository boundary. +- Job dispatch endpoint with command type allowlist and idempotent envelope fields. +- Redis Streams queue adapter for Go orchestration layer. + +## Files added/updated +- `panel-api/app/Modules/Server/ServerRepository.php` +- `panel-api/app/Modules/Agents/AgentSessionRepository.php` +- `panel-api/app/Modules/Jobs/JobController.php` +- `panel-api/app/Modules/Jobs/CommandOrchestrator.php` +- `panel-api/app/Http/Middleware/ResolveTenantContext.php` +- `panel-api/routes/api_v1/servers.php` +- `panel-api/routes/api_v1/jobs.php` +- `control-plane-go/internal/orchestration/redis_stream_queue.go` + +## API additions +- `POST /api/v1/servers/{server}/heartbeat` +- `POST /api/v1/jobs/dispatch` + +## Notes +- Persistence adapters remain intentionally thin to keep boundary clear for full Eloquent integration. +- Queue adapter is production-aligned with Redis Streams and can be swapped with NATS without controller changes. diff --git a/architecture/2026/14-server-plane-schema-additions.sql b/architecture/2026/14-server-plane-schema-additions.sql new file mode 100644 index 00000000..fc57afd4 --- /dev/null +++ b/architecture/2026/14-server-plane-schema-additions.sql @@ -0,0 +1,30 @@ +-- Optional server-plane extension tables + +CREATE TABLE IF NOT EXISTS command_dispatches ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + job_id uuid NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + command_type varchar(64) NOT NULL, + idempotency_key varchar(120) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'queued', + queued_at timestamptz NOT NULL DEFAULT now(), + dispatched_at timestamptz NULL, + acked_at timestamptz NULL, + finished_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, idempotency_key) +); + +CREATE TABLE IF NOT EXISTS agent_heartbeats ( + id bigserial PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + agent_uid varchar(128) NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_agent_heartbeats_server_created + ON agent_heartbeats (server_id, created_at DESC); diff --git a/architecture/2026/15-hosting-mvp-runtime-wiring.md b/architecture/2026/15-hosting-mvp-runtime-wiring.md new file mode 100644 index 00000000..af999821 --- /dev/null +++ b/architecture/2026/15-hosting-mvp-runtime-wiring.md @@ -0,0 +1,23 @@ +# Phase 4 Implementation: Hosting MVP Runtime Wiring + +## Delivered +- Site/domain/SSL controllers now delegate to a single workflow service. +- Hosting command envelopes are generated through `HostingCommandFactory`. +- Webserver profile normalization supports: + - `nginx` + - `apache` (`httpd` alias) + - `openlitespeed` (`ols` alias) +- Command orchestrator now accepts: + - `DOMAIN_ADD` + - `DOMAIN_REMOVE` + - `WEBSERVER_RELOAD` + +## Runtime flow +1. API receives request for site/domain/ssl mutation. +2. `SiteWorkflowService` validates and maps request to typed command envelope. +3. `CommandOrchestrator` assigns job metadata and enqueues dispatch payload. +4. Execution plane consumes command and performs adapter-specific operation. + +## Notes +- This phase intentionally keeps persistence minimal while making runtime wiring explicit. +- Next step is adding provider-specific validation templates and dry-run checks before dispatch. diff --git a/architecture/2026/16-platform-operations-phase-implementation.md b/architecture/2026/16-platform-operations-phase-implementation.md new file mode 100644 index 00000000..c4eceeee --- /dev/null +++ b/architecture/2026/16-platform-operations-phase-implementation.md @@ -0,0 +1,39 @@ +# Phase 5 Implementation: Platform Operations + +## Delivered Modules + +### File Manager +- API: + - `POST /api/v1/files/list` + - `POST /api/v1/files/read` + - `POST /api/v1/files/write` +- Command types: + - `FILE_LIST`, `FILE_READ`, `FILE_WRITE` + +### Cron Manager +- API: + - `GET /api/v1/cron/jobs` + - `POST /api/v1/cron/jobs` + - `DELETE /api/v1/cron/jobs` +- Command types: + - `CRON_LIST`, `CRON_CREATE`, `CRON_DELETE` + +### Firewall +- API: + - `GET /api/v1/firewall/rules` + - `POST /api/v1/firewall/rules` + - `DELETE /api/v1/firewall/rules` +- Command types: + - `FIREWALL_RULE_LIST`, `FIREWALL_RULE_ADD`, `FIREWALL_RULE_DELETE` + +### Backup and Restore +- API: + - `POST /api/v1/backups/run` + - `POST /api/v1/backups/restore` +- Command types: + - `BACKUP_RUN`, `BACKUP_RESTORE` + +## Implementation Notes +- `OpsCommandFactory` centralizes operation command envelope construction. +- `OpsWorkflowService` ensures all operations route through `CommandOrchestrator`. +- Operation endpoints are now tenant-context aware and dispatch-ready. diff --git a/architecture/2026/17-marketplace-monitoring-phase-implementation.md b/architecture/2026/17-marketplace-monitoring-phase-implementation.md new file mode 100644 index 00000000..af960b0b --- /dev/null +++ b/architecture/2026/17-marketplace-monitoring-phase-implementation.md @@ -0,0 +1,30 @@ +# Phase 6 Implementation: Marketplace and Monitoring + +## Delivered Marketplace scope +- Added marketplace workflow and command factory: + - `MarketplaceWorkflowService` + - `MarketplaceCommandFactory` +- Added marketplace controller: + - Catalog browsing endpoint + - Plugin install/update/remove endpoints +- Added routes: + - `GET /api/v1/plugin-market` + - `POST /api/v1/plugins/install` + - `POST /api/v1/plugins/update` + - `POST /api/v1/plugins/remove` +- Added command types: + - `PLUGIN_INSTALL` + - `PLUGIN_UPDATE` + - `PLUGIN_REMOVE` + +## Delivered Monitoring scope +- Added monitoring controller: + - `GET /api/v1/metrics/servers/{serverId}/live` + - `POST /api/v1/alerts/rules` + - `GET /api/v1/alerts` +- Added ingest controller: + - `POST /api/v1/metrics/ingest` + +## Architecture notes +- Marketplace lifecycle operations are routed through the same command orchestration contract as other platform operations. +- Monitoring includes both pull-oriented live endpoint patterns and push-oriented ingest patterns for agent collectors. diff --git a/architecture/2026/18-saas-hardening-schema.sql b/architecture/2026/18-saas-hardening-schema.sql new file mode 100644 index 00000000..0227cc5e --- /dev/null +++ b/architecture/2026/18-saas-hardening-schema.sql @@ -0,0 +1,60 @@ +-- SaaS hardening schema additions + +CREATE TABLE IF NOT EXISTS plan_limits ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + plan_code varchar(64) NOT NULL, + resource varchar(64) NOT NULL, + hard_limit integer NOT NULL, + soft_limit integer NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (plan_code, resource) +); + +CREATE TABLE IF NOT EXISTS usage_counters ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + resource varchar(64) NOT NULL, + usage_value bigint NOT NULL DEFAULT 0, + period_start timestamptz NOT NULL, + period_end timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (tenant_id, resource, period_start, period_end) +); + +CREATE TABLE IF NOT EXISTS webhook_endpoints ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + url text NOT NULL, + secret_encrypted text NOT NULL, + events jsonb NOT NULL DEFAULT '[]'::jsonb, + status varchar(32) NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id bigserial PRIMARY KEY, + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + webhook_endpoint_id uuid NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE, + event_type varchar(64) NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + status varchar(32) NOT NULL DEFAULT 'queued', + response_code integer NULL, + attempts integer NOT NULL DEFAULT 0, + delivered_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS public_api_tokens ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name varchar(120) NOT NULL, + token_hash varchar(255) NOT NULL UNIQUE, + scopes jsonb NOT NULL DEFAULT '[]'::jsonb, + expires_at timestamptz NULL, + revoked_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/architecture/2026/19-saas-hardening-phase-implementation.md b/architecture/2026/19-saas-hardening-phase-implementation.md new file mode 100644 index 00000000..c826b0b7 --- /dev/null +++ b/architecture/2026/19-saas-hardening-phase-implementation.md @@ -0,0 +1,34 @@ +# Phase 7 Implementation: SaaS Hardening + +## Delivered scope + +### Quotas and plan enforcement hooks +- Added `QuotaService` for tenant resource checks. +- Added `BillingHooksService` for usage event emission. +- Added APIs: + - `POST /api/v1/saas/quotas/check` + - `POST /api/v1/saas/usage-events` + +### Webhook management +- Added webhook controller for endpoint CRUD/test placeholders. +- Added APIs: + - `GET /api/v1/webhooks` + - `POST /api/v1/webhooks` + - `POST /api/v1/webhooks/test` + +### Scoped public API +- Added token service and public API controller. +- Added APIs: + - `POST /api/v1/public/tokens` + - `POST /api/v1/public/tokens/introspect` + +## Data model additions +- `plan_limits` +- `usage_counters` +- `webhook_endpoints` +- `webhook_deliveries` +- `public_api_tokens` + +## Notes +- This phase establishes hardening boundaries and contracts. +- Token/hash generation, secret encryption, and retry workers are the next productionization tasks. diff --git a/control-plane-go/cmd/agent-gateway/main.go b/control-plane-go/cmd/agent-gateway/main.go new file mode 100644 index 00000000..bd323d92 --- /dev/null +++ b/control-plane-go/cmd/agent-gateway/main.go @@ -0,0 +1,8 @@ +package main + +import "log" + +func main() { + log.Println("yakpanel agent-gateway bootstrap") + log.Println("responsibilities: mTLS sessions, heartbeat intake, command channel routing") +} diff --git a/control-plane-go/go.mod b/control-plane-go/go.mod new file mode 100644 index 00000000..47a3285f --- /dev/null +++ b/control-plane-go/go.mod @@ -0,0 +1,5 @@ +module yakpanel/control-plane-go + +go 1.23 + +require github.com/redis/go-redis/v9 v9.6.1 diff --git a/control-plane-go/internal/orchestration/dispatcher.go b/control-plane-go/internal/orchestration/dispatcher.go new file mode 100644 index 00000000..1b3f890c --- /dev/null +++ b/control-plane-go/internal/orchestration/dispatcher.go @@ -0,0 +1,19 @@ +package orchestration + +import "yakpanel/control-plane-go/pkg/contracts" + +type Queue interface { + Publish(topic string, payload any) error +} + +type Dispatcher struct { + queue Queue +} + +func NewDispatcher(queue Queue) *Dispatcher { + return &Dispatcher{queue: queue} +} + +func (d *Dispatcher) DispatchCommand(cmd contracts.CommandEnvelope) error { + return d.queue.Publish("yakpanel.commands", cmd) +} diff --git a/control-plane-go/internal/orchestration/redis_stream_queue.go b/control-plane-go/internal/orchestration/redis_stream_queue.go new file mode 100644 index 00000000..df446b47 --- /dev/null +++ b/control-plane-go/internal/orchestration/redis_stream_queue.go @@ -0,0 +1,38 @@ +package orchestration + +import ( + "context" + "encoding/json" + + "github.com/redis/go-redis/v9" +) + +type RedisStreamQueue struct { + client *redis.Client + stream string + ctx context.Context +} + +func NewRedisStreamQueue(ctx context.Context, client *redis.Client, stream string) *RedisStreamQueue { + return &RedisStreamQueue{ + client: client, + stream: stream, + ctx: ctx, + } +} + +func (q *RedisStreamQueue) Publish(topic string, payload any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + _, err = q.client.XAdd(q.ctx, &redis.XAddArgs{ + Stream: q.stream, + Values: map[string]any{ + "topic": topic, + "body": string(body), + }, + }).Result() + return err +} diff --git a/control-plane-go/internal/webserver/adapters.go b/control-plane-go/internal/webserver/adapters.go new file mode 100644 index 00000000..92b15340 --- /dev/null +++ b/control-plane-go/internal/webserver/adapters.go @@ -0,0 +1,42 @@ +package webserver + +import "errors" + +type Adapter interface { + Name() string + ValidateVHost(config string) error + Reload() error +} + +type NginxAdapter struct{} +type ApacheAdapter struct{} +type OpenLiteSpeedAdapter struct{} + +func (a NginxAdapter) Name() string { return "nginx" } +func (a ApacheAdapter) Name() string { return "apache" } +func (a OpenLiteSpeedAdapter) Name() string { return "openlitespeed" } + +func (a NginxAdapter) ValidateVHost(config string) error { + if config == "" { + return errors.New("empty nginx vhost config") + } + return nil +} + +func (a ApacheAdapter) ValidateVHost(config string) error { + if config == "" { + return errors.New("empty apache vhost config") + } + return nil +} + +func (a OpenLiteSpeedAdapter) ValidateVHost(config string) error { + if config == "" { + return errors.New("empty openlitespeed vhost config") + } + return nil +} + +func (a NginxAdapter) Reload() error { return nil } +func (a ApacheAdapter) Reload() error { return nil } +func (a OpenLiteSpeedAdapter) Reload() error { return nil } diff --git a/control-plane-go/pkg/contracts/command.go b/control-plane-go/pkg/contracts/command.go new file mode 100644 index 00000000..f2d5a8f7 --- /dev/null +++ b/control-plane-go/pkg/contracts/command.go @@ -0,0 +1,22 @@ +package contracts + +type CommandEnvelope struct { + CommandID string `json:"cmd_id"` + JobID string `json:"job_id"` + TenantID string `json:"tenant_id"` + ServerID string `json:"server_id"` + Type string `json:"type"` + Args map[string]any `json:"args"` + TimeoutSec int `json:"timeout_sec"` + IdempotencyKey string `json:"idempotency_key"` + RequiredCaps []string `json:"required_capabilities"` + RequestedByUserID string `json:"requested_by_user_id"` +} + +type CommandResult struct { + CommandID string `json:"cmd_id"` + Status string `json:"status"` + ExitCode int `json:"exit_code"` + Output map[string]any `json:"output"` + Error string `json:"error,omitempty"` +} diff --git a/docker-compose.yakpanel.yml b/docker-compose.yakpanel.yml new file mode 100644 index 00000000..bd144eb5 --- /dev/null +++ b/docker-compose.yakpanel.yml @@ -0,0 +1,112 @@ +version: "3.9" + +services: + postgres: + image: postgres:16-alpine + container_name: yakpanel-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + ports: + - "${POSTGRES_PORT}:5432" + volumes: + - yakpanel_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: yakpanel-redis + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + ports: + - "${REDIS_PORT}:6379" + volumes: + - yakpanel_redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + + nats: + image: nats:2.10-alpine + container_name: yakpanel-nats + restart: unless-stopped + command: ["-js"] + ports: + - "${NATS_PORT}:4222" + - "${NATS_MONITOR_PORT}:8222" + + minio: + image: minio/minio:latest + container_name: yakpanel-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + ports: + - "${MINIO_PORT}:9000" + - "${MINIO_CONSOLE_PORT}:9001" + volumes: + - yakpanel_minio_data:/data + + api: + image: php:8.3-cli-alpine + container_name: yakpanel-api + restart: unless-stopped + working_dir: /var/www/panel-api + command: ["php", "-S", "0.0.0.0:8080", "-t", "public"] + volumes: + - ./panel-api:/var/www/panel-api + ports: + - "${API_PORT}:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + db-migrate: + condition: service_completed_successfully + + agent-gateway: + image: golang:1.23-alpine + container_name: yakpanel-agent-gateway + restart: unless-stopped + working_dir: /src + command: ["sh", "-lc", "go run ./cmd/agent-gateway"] + volumes: + - ./control-plane-go:/src + depends_on: + redis: + condition: service_healthy + + db-migrate: + image: postgres:16-alpine + container_name: yakpanel-db-migrate + restart: "no" + working_dir: /workspace + environment: + PGPASSWORD: ${POSTGRES_PASSWORD} + command: + [ + "sh", + "-lc", + "psql -h postgres -U ${POSTGRES_USER} -d ${POSTGRES_DB} -f architecture/2026/02-core-schema.sql && psql -h postgres -U ${POSTGRES_USER} -d ${POSTGRES_DB} -f architecture/2026/11-identity-core-schema.sql && psql -h postgres -U ${POSTGRES_USER} -d ${POSTGRES_DB} -f architecture/2026/14-server-plane-schema-additions.sql && psql -h postgres -U ${POSTGRES_USER} -d ${POSTGRES_DB} -f architecture/2026/18-saas-hardening-schema.sql" + ] + volumes: + - ./:/workspace + depends_on: + postgres: + condition: service_healthy + +volumes: + yakpanel_postgres_data: + yakpanel_redis_data: + yakpanel_minio_data: diff --git a/docs/ubuntu-dev-install.md b/docs/ubuntu-dev-install.md new file mode 100644 index 00000000..7582bdfb --- /dev/null +++ b/docs/ubuntu-dev-install.md @@ -0,0 +1,43 @@ +# YakPanel Ubuntu Dev Install + +This setup brings up the YakPanel 2026 scaffold stack on Ubuntu 22.04/24.04. + +## Quick install + +```bash +cd /path/to/YakPanel-master +chmod +x scripts/install-ubuntu.sh scripts/bootstrap-dev.sh +./scripts/install-ubuntu.sh +``` + +## One-click install + +```bash +cd /path/to/YakPanel-master +chmod +x one-click-installer.sh +./one-click-installer.sh +``` + +## Start/stop commands + +```bash +make init +make up +make migrate +make ps +make logs +make down +``` + +## Services +- API health: `http://localhost:8080/health` +- PostgreSQL: `localhost:5432` +- Redis: `localhost:6379` +- NATS monitor: `http://localhost:8222` +- MinIO console: `http://localhost:9001` + +## Notes +- This is a development scaffold for the architecture implementation. +- `panel-api` currently serves a minimal runtime entrypoint for health and bootstrap validation. +- Full Laravel production bootstrap (artisan app, providers, migrations runtime wiring) is the next implementation step. +- Database schema is automatically applied by `db-migrate` during bootstrap and can be re-run with `make migrate`. diff --git a/one-click-installer.sh b/one-click-installer.sh new file mode 100644 index 00000000..93cdb704 --- /dev/null +++ b/one-click-installer.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +require_ubuntu() { + if [[ ! -f /etc/os-release ]]; then + echo "Unsupported OS: missing /etc/os-release" + exit 1 + fi + # shellcheck disable=SC1091 + source /etc/os-release + if [[ "${ID:-}" != "ubuntu" ]]; then + echo "This one-click installer currently supports Ubuntu only." + exit 1 + fi +} + +ensure_executable_scripts() { + chmod +x "${PROJECT_DIR}/scripts/install-ubuntu.sh" + chmod +x "${PROJECT_DIR}/scripts/bootstrap-dev.sh" + chmod +x "${PROJECT_DIR}/scripts/migrate-dev.sh" +} + +main() { + require_ubuntu + ensure_executable_scripts + + echo "YakPanel one-click installer started..." + cd "${PROJECT_DIR}" + + "${PROJECT_DIR}/scripts/install-ubuntu.sh" + + echo + echo "YakPanel installation completed." + echo "API health: http://localhost:8080/health" + echo "MinIO console: http://localhost:9001" + echo "NATS monitor: http://localhost:8222" +} + +main "$@" diff --git a/panel-api/README.md b/panel-api/README.md new file mode 100644 index 00000000..6a03902d --- /dev/null +++ b/panel-api/README.md @@ -0,0 +1,22 @@ +# YakPanel Control Plane API (Laravel) + +This folder defines the 2026 Laravel control plane boundary. + +## Purpose +- API gateway for UI and third-party integrations. +- Tenant/RBAC enforcement before any execution request. +- Workflow orchestration and audit trail for system operations. + +## Initial modules +- `app/Modules/Server`: inventory and server lifecycle metadata. +- `app/Modules/Agents`: enrollment tokens, agent sessions, heartbeat updates. +- `app/Modules/Jobs`: command orchestration and execution state tracking. +- `app/Modules/Auth`: login/refresh/me/logout boundaries. +- `app/Modules/Tenant`: tenant lifecycle and membership boundaries. +- `app/Modules/Rbac`: role/permission/scope checks. +- `routes/api_v1`: versioned route groups. + +## Integration contract +- Never execute privileged shell operations directly in Laravel. +- Dispatch immutable command envelopes to Go execution plane. +- Persist all requests as jobs for visibility and replay-safe recovery. diff --git a/panel-api/app/Http/Middleware/ResolveTenantContext.php b/panel-api/app/Http/Middleware/ResolveTenantContext.php new file mode 100644 index 00000000..bce22b22 --- /dev/null +++ b/panel-api/app/Http/Middleware/ResolveTenantContext.php @@ -0,0 +1,18 @@ +header('X-Tenant-Id', ''); + $request->attributes->set('tenant_id', $tenantId); + + return $next($request); + } +} diff --git a/panel-api/app/Modules/Agents/AgentController.php b/panel-api/app/Modules/Agents/AgentController.php new file mode 100644 index 00000000..2e809378 --- /dev/null +++ b/panel-api/app/Modules/Agents/AgentController.php @@ -0,0 +1,55 @@ +json([ + 'data' => [ + 'server_id' => $server, + 'token_type' => 'bootstrap', + 'expires_in_sec' => 900, + ], + ]); + } + + public function capabilities(string $server): JsonResponse + { + return response()->json([ + 'data' => [ + 'server_id' => $server, + 'capabilities' => [], + ], + ]); + } + + public function sessions(string $server): JsonResponse + { + return response()->json([ + 'data' => [ + 'server_id' => $server, + 'sessions' => $this->sessionsRepo->listByServer($server), + ], + ]); + } + + public function heartbeat(Request $request, string $server): JsonResponse + { + $agentUid = (string) $request->input('agent_uid', ''); + $session = $this->sessionsRepo->touchHeartbeat($server, $agentUid, $request->all()); + + return response()->json([ + 'data' => $session, + ]); + } +} diff --git a/panel-api/app/Modules/Agents/AgentSessionRepository.php b/panel-api/app/Modules/Agents/AgentSessionRepository.php new file mode 100644 index 00000000..962f33ff --- /dev/null +++ b/panel-api/app/Modules/Agents/AgentSessionRepository.php @@ -0,0 +1,22 @@ + $serverId, + 'agent_uid' => $agentUid, + 'status' => 'online', + 'last_seen_at' => now()->toISOString(), + 'payload' => $payload, + ]; + } +} diff --git a/panel-api/app/Modules/Auth/AuthController.php b/panel-api/app/Modules/Auth/AuthController.php new file mode 100644 index 00000000..70f1937b --- /dev/null +++ b/panel-api/app/Modules/Auth/AuthController.php @@ -0,0 +1,48 @@ +json([ + 'data' => [ + 'access_token' => 'placeholder-token', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + ], + ]); + } + + public function refresh(Request $request): JsonResponse + { + return response()->json([ + 'data' => [ + 'access_token' => 'placeholder-token-refreshed', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + ], + ]); + } + + public function me(Request $request): JsonResponse + { + return response()->json([ + 'data' => [ + 'id' => null, + 'email' => null, + 'tenants' => [], + ], + ]); + } + + public function logout(Request $request): JsonResponse + { + return response()->json([], 204); + } +} diff --git a/panel-api/app/Modules/Jobs/CommandOrchestrator.php b/panel-api/app/Modules/Jobs/CommandOrchestrator.php new file mode 100644 index 00000000..02e116b5 --- /dev/null +++ b/panel-api/app/Modules/Jobs/CommandOrchestrator.php @@ -0,0 +1,54 @@ +acceptedTypes, true)) { + return [ + 'job_status' => 'rejected', + 'error' => 'Unsupported command type', + ]; + } + + return [ + 'job_status' => 'queued', + 'job_id' => uniqid('job_', true), + 'tenant_id' => $commandEnvelope['tenant_id'] ?? null, + 'type' => $type, + 'idempotency_key' => $commandEnvelope['idempotency_key'] ?? null, + 'args' => $commandEnvelope['args'] ?? [], + 'message' => 'Command accepted for execution plane dispatch', + ]; + } +} diff --git a/panel-api/app/Modules/Jobs/JobController.php b/panel-api/app/Modules/Jobs/JobController.php new file mode 100644 index 00000000..63bd63fb --- /dev/null +++ b/panel-api/app/Modules/Jobs/JobController.php @@ -0,0 +1,25 @@ +attributes->get('tenant_id', ''); + $payload = $request->all(); + $payload['tenant_id'] = $tenantId; + + $result = $this->orchestrator->dispatch($payload); + + return response()->json(['data' => $result], 202); + } +} diff --git a/panel-api/app/Modules/Marketplace/MarketplaceCommandFactory.php b/panel-api/app/Modules/Marketplace/MarketplaceCommandFactory.php new file mode 100644 index 00000000..c3eec203 --- /dev/null +++ b/panel-api/app/Modules/Marketplace/MarketplaceCommandFactory.php @@ -0,0 +1,36 @@ + $tenantId, + 'type' => 'PLUGIN_INSTALL', + 'idempotency_key' => $input['idempotency_key'] ?? uniqid('plugin_install_', true), + 'args' => $input, + ]; + } + + public function updatePlugin(string $tenantId, array $input): array + { + return [ + 'tenant_id' => $tenantId, + 'type' => 'PLUGIN_UPDATE', + 'idempotency_key' => $input['idempotency_key'] ?? uniqid('plugin_update_', true), + 'args' => $input, + ]; + } + + public function removePlugin(string $tenantId, array $input): array + { + return [ + 'tenant_id' => $tenantId, + 'type' => 'PLUGIN_REMOVE', + 'idempotency_key' => $input['idempotency_key'] ?? uniqid('plugin_remove_', true), + 'args' => $input, + ]; + } +} diff --git a/panel-api/app/Modules/Marketplace/MarketplaceController.php b/panel-api/app/Modules/Marketplace/MarketplaceController.php new file mode 100644 index 00000000..e7b0e2fd --- /dev/null +++ b/panel-api/app/Modules/Marketplace/MarketplaceController.php @@ -0,0 +1,42 @@ +json([ + 'data' => [ + 'items' => [], + 'meta' => ['source' => 'placeholder-market-catalog'], + ], + ]); + } + + public function install(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->install($tenantId, $request->all())], 202); + } + + public function update(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->update($tenantId, $request->all())], 202); + } + + public function remove(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->remove($tenantId, $request->all())], 202); + } +} diff --git a/panel-api/app/Modules/Marketplace/MarketplaceWorkflowService.php b/panel-api/app/Modules/Marketplace/MarketplaceWorkflowService.php new file mode 100644 index 00000000..8058d667 --- /dev/null +++ b/panel-api/app/Modules/Marketplace/MarketplaceWorkflowService.php @@ -0,0 +1,29 @@ +orchestrator->dispatch($this->commands->installPlugin($tenantId, $input)); + } + + public function update(string $tenantId, array $input): array + { + return $this->orchestrator->dispatch($this->commands->updatePlugin($tenantId, $input)); + } + + public function remove(string $tenantId, array $input): array + { + return $this->orchestrator->dispatch($this->commands->removePlugin($tenantId, $input)); + } +} diff --git a/panel-api/app/Modules/Monitoring/MetricsIngestController.php b/panel-api/app/Modules/Monitoring/MetricsIngestController.php new file mode 100644 index 00000000..c78712ad --- /dev/null +++ b/panel-api/app/Modules/Monitoring/MetricsIngestController.php @@ -0,0 +1,20 @@ +json([ + 'data' => [ + 'accepted' => true, + 'points' => is_array($request->input('points')) ? count($request->input('points')) : 0, + ], + ], 202); + } +} diff --git a/panel-api/app/Modules/Monitoring/MonitoringController.php b/panel-api/app/Modules/Monitoring/MonitoringController.php new file mode 100644 index 00000000..54a13c75 --- /dev/null +++ b/panel-api/app/Modules/Monitoring/MonitoringController.php @@ -0,0 +1,43 @@ +json([ + 'data' => [ + 'server_id' => $serverId, + 'cpu_percent' => null, + 'memory_percent' => null, + 'disk_percent' => null, + 'network' => ['in' => null, 'out' => null], + 'status' => 'streaming', + ], + ]); + } + + public function createAlertRule(Request $request): JsonResponse + { + return response()->json([ + 'data' => [ + 'status' => 'created', + 'rule' => $request->all(), + ], + ], 201); + } + + public function listAlerts(Request $request): JsonResponse + { + return response()->json([ + 'data' => [ + 'items' => [], + ], + ]); + } +} diff --git a/panel-api/app/Modules/Ops/BackupController.php b/panel-api/app/Modules/Ops/BackupController.php new file mode 100644 index 00000000..b932c21a --- /dev/null +++ b/panel-api/app/Modules/Ops/BackupController.php @@ -0,0 +1,26 @@ +attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->backupRun($tenantId, $request->all())], 202); + } + + public function restore(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->backupRestore($tenantId, $request->all())], 202); + } +} diff --git a/panel-api/app/Modules/Ops/CronController.php b/panel-api/app/Modules/Ops/CronController.php new file mode 100644 index 00000000..b05fc5a7 --- /dev/null +++ b/panel-api/app/Modules/Ops/CronController.php @@ -0,0 +1,32 @@ +attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->cronList($tenantId, $request->all())], 202); + } + + public function store(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->cronCreate($tenantId, $request->all())], 202); + } + + public function destroy(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->cronDelete($tenantId, $request->all())], 202); + } +} diff --git a/panel-api/app/Modules/Ops/FileOpsController.php b/panel-api/app/Modules/Ops/FileOpsController.php new file mode 100644 index 00000000..24817db8 --- /dev/null +++ b/panel-api/app/Modules/Ops/FileOpsController.php @@ -0,0 +1,32 @@ +attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->fileList($tenantId, $request->all())], 202); + } + + public function read(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->fileRead($tenantId, $request->all())], 202); + } + + public function write(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->fileWrite($tenantId, $request->all())], 202); + } +} diff --git a/panel-api/app/Modules/Ops/FirewallController.php b/panel-api/app/Modules/Ops/FirewallController.php new file mode 100644 index 00000000..b13507dc --- /dev/null +++ b/panel-api/app/Modules/Ops/FirewallController.php @@ -0,0 +1,32 @@ +attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->firewallList($tenantId, $request->all())], 202); + } + + public function store(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->firewallAdd($tenantId, $request->all())], 202); + } + + public function destroy(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + return response()->json(['data' => $this->workflow->firewallDelete($tenantId, $request->all())], 202); + } +} diff --git a/panel-api/app/Modules/Ops/OpsCommandFactory.php b/panel-api/app/Modules/Ops/OpsCommandFactory.php new file mode 100644 index 00000000..bdd8429f --- /dev/null +++ b/panel-api/app/Modules/Ops/OpsCommandFactory.php @@ -0,0 +1,71 @@ +cmd($tenantId, 'FILE_LIST', 'file_list_', $input); + } + + public function fileRead(string $tenantId, array $input): array + { + return $this->cmd($tenantId, 'FILE_READ', 'file_read_', $input); + } + + public function fileWrite(string $tenantId, array $input): array + { + return $this->cmd($tenantId, 'FILE_WRITE', 'file_write_', $input); + } + + public function cronList(string $tenantId, array $input): array + { + return $this->cmd($tenantId, 'CRON_LIST', 'cron_list_', $input); + } + + public function cronCreate(string $tenantId, array $input): array + { + return $this->cmd($tenantId, 'CRON_CREATE', 'cron_create_', $input); + } + + public function cronDelete(string $tenantId, array $input): array + { + return $this->cmd($tenantId, 'CRON_DELETE', 'cron_delete_', $input); + } + + public function firewallList(string $tenantId, array $input): array + { + return $this->cmd($tenantId, 'FIREWALL_RULE_LIST', 'fw_list_', $input); + } + + public function firewallAdd(string $tenantId, array $input): array + { + return $this->cmd($tenantId, 'FIREWALL_RULE_ADD', 'fw_add_', $input); + } + + public function firewallDelete(string $tenantId, array $input): array + { + return $this->cmd($tenantId, 'FIREWALL_RULE_DELETE', 'fw_delete_', $input); + } + + public function backupRun(string $tenantId, array $input): array + { + return $this->cmd($tenantId, 'BACKUP_RUN', 'backup_run_', $input); + } + + public function backupRestore(string $tenantId, array $input): array + { + return $this->cmd($tenantId, 'BACKUP_RESTORE', 'backup_restore_', $input); + } + + private function cmd(string $tenantId, string $type, string $prefix, array $input): array + { + return [ + 'tenant_id' => $tenantId, + 'type' => $type, + 'idempotency_key' => $input['idempotency_key'] ?? uniqid($prefix, true), + 'args' => $input, + ]; + } +} diff --git a/panel-api/app/Modules/Ops/OpsWorkflowService.php b/panel-api/app/Modules/Ops/OpsWorkflowService.php new file mode 100644 index 00000000..bd5a3245 --- /dev/null +++ b/panel-api/app/Modules/Ops/OpsWorkflowService.php @@ -0,0 +1,26 @@ +orchestrator->dispatch($this->commands->fileList($tenantId, $input)); } + public function fileRead(string $tenantId, array $input): array { return $this->orchestrator->dispatch($this->commands->fileRead($tenantId, $input)); } + public function fileWrite(string $tenantId, array $input): array { return $this->orchestrator->dispatch($this->commands->fileWrite($tenantId, $input)); } + public function cronList(string $tenantId, array $input): array { return $this->orchestrator->dispatch($this->commands->cronList($tenantId, $input)); } + public function cronCreate(string $tenantId, array $input): array { return $this->orchestrator->dispatch($this->commands->cronCreate($tenantId, $input)); } + public function cronDelete(string $tenantId, array $input): array { return $this->orchestrator->dispatch($this->commands->cronDelete($tenantId, $input)); } + public function firewallList(string $tenantId, array $input): array { return $this->orchestrator->dispatch($this->commands->firewallList($tenantId, $input)); } + public function firewallAdd(string $tenantId, array $input): array { return $this->orchestrator->dispatch($this->commands->firewallAdd($tenantId, $input)); } + public function firewallDelete(string $tenantId, array $input): array { return $this->orchestrator->dispatch($this->commands->firewallDelete($tenantId, $input)); } + public function backupRun(string $tenantId, array $input): array { return $this->orchestrator->dispatch($this->commands->backupRun($tenantId, $input)); } + public function backupRestore(string $tenantId, array $input): array { return $this->orchestrator->dispatch($this->commands->backupRestore($tenantId, $input)); } +} diff --git a/panel-api/app/Modules/PublicApi/PublicApiController.php b/panel-api/app/Modules/PublicApi/PublicApiController.php new file mode 100644 index 00000000..9b0eb031 --- /dev/null +++ b/panel-api/app/Modules/PublicApi/PublicApiController.php @@ -0,0 +1,29 @@ +json([ + 'data' => $this->tokens->issue($request->all()), + ], 201); + } + + public function introspect(Request $request): JsonResponse + { + $token = (string) $request->input('token', ''); + return response()->json([ + 'data' => $this->tokens->introspect($token), + ]); + } +} diff --git a/panel-api/app/Modules/PublicApi/PublicTokenService.php b/panel-api/app/Modules/PublicApi/PublicTokenService.php new file mode 100644 index 00000000..0b00e95f --- /dev/null +++ b/panel-api/app/Modules/PublicApi/PublicTokenService.php @@ -0,0 +1,23 @@ + 'public-token-placeholder', + 'scopes' => $input['scopes'] ?? [], + 'expires_in' => 3600, + ]; + } + + public function introspect(string $token): array + { + return [ + 'active' => $token !== '', + 'scopes' => [], + ]; + } +} diff --git a/panel-api/app/Modules/Rbac/RbacController.php b/panel-api/app/Modules/Rbac/RbacController.php new file mode 100644 index 00000000..20bea211 --- /dev/null +++ b/panel-api/app/Modules/Rbac/RbacController.php @@ -0,0 +1,49 @@ +json(['data' => []]); + } + + public function createRole(Request $request): JsonResponse + { + return response()->json(['data' => ['status' => 'created']], 201); + } + + public function attachPermissions(Request $request, string $role): JsonResponse + { + return response()->json(['data' => ['role_id' => $role, 'status' => 'updated']]); + } + + public function assignRoles(Request $request, string $user): JsonResponse + { + return response()->json(['data' => ['user_id' => $user, 'status' => 'updated']]); + } + + public function checkAccess(Request $request): JsonResponse + { + $payload = $request->all(); + $allowed = $this->evaluator->isAllowed( + $payload['grants'] ?? [], + (string) ($payload['action'] ?? ''), + (string) ($payload['resource_type'] ?? ''), + isset($payload['resource_id']) ? (string) $payload['resource_id'] : null + ); + + return response()->json([ + 'data' => ['allowed' => $allowed], + ]); + } +} diff --git a/panel-api/app/Modules/Rbac/ScopeEvaluator.php b/panel-api/app/Modules/Rbac/ScopeEvaluator.php new file mode 100644 index 00000000..8fa55904 --- /dev/null +++ b/panel-api/app/Modules/Rbac/ScopeEvaluator.php @@ -0,0 +1,30 @@ + $tenantId, + 'event_type' => $eventType, + 'status' => 'queued', + 'payload' => $payload, + ]; + } +} diff --git a/panel-api/app/Modules/SaaS/QuotaService.php b/panel-api/app/Modules/SaaS/QuotaService.php new file mode 100644 index 00000000..8e9d2e70 --- /dev/null +++ b/panel-api/app/Modules/SaaS/QuotaService.php @@ -0,0 +1,18 @@ + $tenantId, + 'resource' => $resource, + 'requested' => $requested, + 'allowed' => true, + 'limit' => null, + 'current_usage' => null, + ]; + } +} diff --git a/panel-api/app/Modules/SaaS/SaaSController.php b/panel-api/app/Modules/SaaS/SaaSController.php new file mode 100644 index 00000000..f83fa704 --- /dev/null +++ b/panel-api/app/Modules/SaaS/SaaSController.php @@ -0,0 +1,38 @@ +attributes->get('tenant_id', ''); + $resource = (string) $request->input('resource', ''); + $requested = (int) $request->input('requested', 1); + + return response()->json([ + 'data' => $this->quota->check($tenantId, $resource, $requested), + ]); + } + + public function usageEvent(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + $eventType = (string) $request->input('event_type', 'generic'); + $payload = (array) $request->input('payload', []); + + return response()->json([ + 'data' => $this->billing->emitUsageEvent($tenantId, $eventType, $payload), + ], 202); + } +} diff --git a/panel-api/app/Modules/Server/ServerController.php b/panel-api/app/Modules/Server/ServerController.php new file mode 100644 index 00000000..27f9d74c --- /dev/null +++ b/panel-api/app/Modules/Server/ServerController.php @@ -0,0 +1,56 @@ +attributes->get('tenant_id', ''); + return response()->json([ + 'data' => $this->servers->listByTenant($tenantId), + 'meta' => ['tenant_id' => $tenantId], + ]); + } + + public function store(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + $server = $this->servers->create([ + 'tenant_id' => $tenantId, + 'name' => (string) $request->input('name', ''), + 'id' => (string) $request->input('id', ''), + ]); + + return response()->json([ + 'data' => $server, + ], 201); + } + + public function show(string $server): JsonResponse + { + return response()->json([ + 'data' => ['id' => $server], + ]); + } + + public function update(Request $request, string $server): JsonResponse + { + return response()->json([ + 'data' => ['id' => $server, 'status' => 'updated'], + ]); + } + + public function destroy(string $server): JsonResponse + { + return response()->json([], 204); + } +} diff --git a/panel-api/app/Modules/Server/ServerRepository.php b/panel-api/app/Modules/Server/ServerRepository.php new file mode 100644 index 00000000..261084af --- /dev/null +++ b/panel-api/app/Modules/Server/ServerRepository.php @@ -0,0 +1,25 @@ + $attributes['id'] ?? null, + 'tenant_id' => $attributes['tenant_id'] ?? null, + 'name' => $attributes['name'] ?? null, + 'status' => 'provisioning', + ]; + } +} diff --git a/panel-api/app/Modules/Site/HostingCommandFactory.php b/panel-api/app/Modules/Site/HostingCommandFactory.php new file mode 100644 index 00000000..7a4bdf3b --- /dev/null +++ b/panel-api/app/Modules/Site/HostingCommandFactory.php @@ -0,0 +1,107 @@ + $tenantId, + 'type' => 'SITE_CREATE', + 'idempotency_key' => $input['idempotency_key'] ?? uniqid('site_create_', true), + 'args' => [ + 'server_id' => $input['server_id'] ?? null, + 'site_name' => $input['site_name'] ?? null, + 'domain' => $input['domain'] ?? null, + 'root_path' => $input['root_path'] ?? null, + 'webserver' => $webserver, + ], + ]; + } + + public function buildUpdateSite(string $tenantId, string $siteId, array $input): array + { + return [ + 'tenant_id' => $tenantId, + 'type' => 'SITE_UPDATE', + 'idempotency_key' => $input['idempotency_key'] ?? uniqid('site_update_', true), + 'args' => [ + 'site_id' => $siteId, + 'server_id' => $input['server_id'] ?? null, + 'changes' => $input['changes'] ?? [], + ], + ]; + } + + public function buildDeleteSite(string $tenantId, string $siteId, array $input): array + { + return [ + 'tenant_id' => $tenantId, + 'type' => 'SITE_DELETE', + 'idempotency_key' => $input['idempotency_key'] ?? uniqid('site_delete_', true), + 'args' => [ + 'site_id' => $siteId, + 'server_id' => $input['server_id'] ?? null, + ], + ]; + } + + public function buildAddDomain(string $tenantId, string $siteId, array $input): array + { + return [ + 'tenant_id' => $tenantId, + 'type' => 'DOMAIN_ADD', + 'idempotency_key' => $input['idempotency_key'] ?? uniqid('domain_add_', true), + 'args' => [ + 'site_id' => $siteId, + 'server_id' => $input['server_id'] ?? null, + 'domain' => $input['domain'] ?? null, + ], + ]; + } + + public function buildRemoveDomain(string $tenantId, string $siteId, string $domainId, array $input): array + { + return [ + 'tenant_id' => $tenantId, + 'type' => 'DOMAIN_REMOVE', + 'idempotency_key' => $input['idempotency_key'] ?? uniqid('domain_remove_', true), + 'args' => [ + 'site_id' => $siteId, + 'domain_id' => $domainId, + 'server_id' => $input['server_id'] ?? null, + ], + ]; + } + + public function buildIssueSsl(string $tenantId, array $input): array + { + return [ + 'tenant_id' => $tenantId, + 'type' => 'SSL_ISSUE', + 'idempotency_key' => $input['idempotency_key'] ?? uniqid('ssl_issue_', true), + 'args' => $input, + ]; + } + + public function buildApplySsl(string $tenantId, array $input): array + { + return [ + 'tenant_id' => $tenantId, + 'type' => 'SSL_APPLY', + 'idempotency_key' => $input['idempotency_key'] ?? uniqid('ssl_apply_', true), + 'args' => $input, + ]; + } + + public function buildRenewSsl(string $tenantId, array $input): array + { + return [ + 'tenant_id' => $tenantId, + 'type' => 'SSL_RENEW', + 'idempotency_key' => $input['idempotency_key'] ?? uniqid('ssl_renew_', true), + 'args' => $input, + ]; + } +} diff --git a/panel-api/app/Modules/Site/SiteController.php b/panel-api/app/Modules/Site/SiteController.php new file mode 100644 index 00000000..0f36127a --- /dev/null +++ b/panel-api/app/Modules/Site/SiteController.php @@ -0,0 +1,53 @@ +json(['data' => []]); } + + public function store(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + $result = $this->workflow->createSite($tenantId, $request->all()); + return response()->json(['data' => $result], 202); + } + + public function show(string $site): JsonResponse { return response()->json(['data' => ['id' => $site]]); } + + public function update(Request $request, string $site): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + $result = $this->workflow->updateSite($tenantId, $site, $request->all()); + return response()->json(['data' => $result], 202); + } + + public function destroy(Request $request, string $site): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + $result = $this->workflow->deleteSite($tenantId, $site, $request->all()); + return response()->json(['data' => $result], 202); + } + + public function addDomain(Request $request, string $site): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + $result = $this->workflow->addDomain($tenantId, $site, $request->all()); + return response()->json(['data' => $result], 202); + } + + public function removeDomain(Request $request, string $site, string $domain): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + $result = $this->workflow->removeDomain($tenantId, $site, $domain, $request->all()); + return response()->json(['data' => $result], 202); + } +} diff --git a/panel-api/app/Modules/Site/SiteWorkflowService.php b/panel-api/app/Modules/Site/SiteWorkflowService.php new file mode 100644 index 00000000..9831c625 --- /dev/null +++ b/panel-api/app/Modules/Site/SiteWorkflowService.php @@ -0,0 +1,69 @@ +webserverResolver->normalize((string) ($input['webserver'] ?? 'nginx')); + $command = $this->commandFactory->buildCreateSite($tenantId, $input, $webserver); + return $this->orchestrator->dispatch($command); + } + + public function updateSite(string $tenantId, string $siteId, array $input): array + { + $command = $this->commandFactory->buildUpdateSite($tenantId, $siteId, $input); + return $this->orchestrator->dispatch($command); + } + + public function deleteSite(string $tenantId, string $siteId, array $input = []): array + { + $command = $this->commandFactory->buildDeleteSite($tenantId, $siteId, $input); + return $this->orchestrator->dispatch($command); + } + + public function addDomain(string $tenantId, string $siteId, array $input): array + { + if (empty($input['domain'])) { + throw new InvalidArgumentException('Domain is required'); + } + $command = $this->commandFactory->buildAddDomain($tenantId, $siteId, $input); + return $this->orchestrator->dispatch($command); + } + + public function removeDomain(string $tenantId, string $siteId, string $domainId, array $input = []): array + { + $command = $this->commandFactory->buildRemoveDomain($tenantId, $siteId, $domainId, $input); + return $this->orchestrator->dispatch($command); + } + + public function issueSsl(string $tenantId, array $input): array + { + $command = $this->commandFactory->buildIssueSsl($tenantId, $input); + return $this->orchestrator->dispatch($command); + } + + public function applySsl(string $tenantId, array $input): array + { + $command = $this->commandFactory->buildApplySsl($tenantId, $input); + return $this->orchestrator->dispatch($command); + } + + public function renewSsl(string $tenantId, array $input): array + { + $command = $this->commandFactory->buildRenewSsl($tenantId, $input); + return $this->orchestrator->dispatch($command); + } +} diff --git a/panel-api/app/Modules/Site/WebserverProfileResolver.php b/panel-api/app/Modules/Site/WebserverProfileResolver.php new file mode 100644 index 00000000..67308fa5 --- /dev/null +++ b/panel-api/app/Modules/Site/WebserverProfileResolver.php @@ -0,0 +1,23 @@ + 'nginx', + 'apache', 'httpd' => 'apache', + 'openlitespeed', 'ols' => 'openlitespeed', + default => throw new InvalidArgumentException('Unsupported webserver profile'), + }; + } +} diff --git a/panel-api/app/Modules/Ssl/SslController.php b/panel-api/app/Modules/Ssl/SslController.php new file mode 100644 index 00000000..fd225129 --- /dev/null +++ b/panel-api/app/Modules/Ssl/SslController.php @@ -0,0 +1,36 @@ +attributes->get('tenant_id', ''); + $result = $this->workflow->issueSsl($tenantId, $request->all()); + return response()->json(['data' => $result], 202); + } + + public function apply(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + $result = $this->workflow->applySsl($tenantId, $request->all()); + return response()->json(['data' => $result], 202); + } + + public function renew(Request $request): JsonResponse + { + $tenantId = (string) $request->attributes->get('tenant_id', ''); + $result = $this->workflow->renewSsl($tenantId, $request->all()); + return response()->json(['data' => $result], 202); + } +} diff --git a/panel-api/app/Modules/Tenant/TenantController.php b/panel-api/app/Modules/Tenant/TenantController.php new file mode 100644 index 00000000..80b63721 --- /dev/null +++ b/panel-api/app/Modules/Tenant/TenantController.php @@ -0,0 +1,25 @@ +json(['data' => []]); + } + + public function store(Request $request): JsonResponse + { + return response()->json(['data' => ['status' => 'created']], 201); + } + + public function show(string $tenant): JsonResponse + { + return response()->json(['data' => ['id' => $tenant]]); + } +} diff --git a/panel-api/app/Modules/Webhook/WebhookController.php b/panel-api/app/Modules/Webhook/WebhookController.php new file mode 100644 index 00000000..393760cd --- /dev/null +++ b/panel-api/app/Modules/Webhook/WebhookController.php @@ -0,0 +1,35 @@ +json(['data' => ['items' => []]]); + } + + public function store(Request $request): JsonResponse + { + return response()->json([ + 'data' => [ + 'status' => 'created', + 'endpoint' => $request->all(), + ], + ], 201); + } + + public function testDelivery(Request $request): JsonResponse + { + return response()->json([ + 'data' => [ + 'status' => 'queued', + 'target' => $request->input('url'), + ], + ], 202); + } +} diff --git a/panel-api/public/index.php b/panel-api/public/index.php new file mode 100644 index 00000000..19174278 --- /dev/null +++ b/panel-api/public/index.php @@ -0,0 +1,24 @@ + 'ok', + 'service' => 'yakpanel-api', + 'mode' => 'scaffold', + 'time' => gmdate(DATE_ATOM), + ], JSON_PRETTY_PRINT); + exit; +} + +echo json_encode([ + 'status' => 'ok', + 'service' => 'yakpanel-api', + 'message' => 'YakPanel control-plane scaffold is running', + 'path' => $path, +], JSON_PRETTY_PRINT); diff --git a/panel-api/routes/api_v1/hosting.php b/panel-api/routes/api_v1/hosting.php new file mode 100644 index 00000000..2a1d11c3 --- /dev/null +++ b/panel-api/routes/api_v1/hosting.php @@ -0,0 +1,20 @@ +middleware(['auth:sanctum'])->group(function (): void { + Route::get('/sites', [SiteController::class, 'index']); + Route::post('/sites', [SiteController::class, 'store']); + Route::get('/sites/{site}', [SiteController::class, 'show']); + Route::patch('/sites/{site}', [SiteController::class, 'update']); + Route::delete('/sites/{site}', [SiteController::class, 'destroy']); + + Route::post('/sites/{site}/domains', [SiteController::class, 'addDomain']); + Route::delete('/sites/{site}/domains/{domain}', [SiteController::class, 'removeDomain']); + + Route::post('/ssl/issue', [SslController::class, 'issue']); + Route::post('/ssl/apply', [SslController::class, 'apply']); + Route::post('/ssl/renew', [SslController::class, 'renew']); +}); diff --git a/panel-api/routes/api_v1/identity.php b/panel-api/routes/api_v1/identity.php new file mode 100644 index 00000000..7ad033a9 --- /dev/null +++ b/panel-api/routes/api_v1/identity.php @@ -0,0 +1,26 @@ +group(function (): void { + Route::post('/auth/login', [AuthController::class, 'login']); + Route::post('/auth/refresh', [AuthController::class, 'refresh']); + + Route::middleware(['auth:sanctum'])->group(function (): void { + Route::get('/auth/me', [AuthController::class, 'me']); + Route::post('/auth/logout', [AuthController::class, 'logout']); + + Route::get('/tenants', [TenantController::class, 'index']); + Route::post('/tenants', [TenantController::class, 'store']); + Route::get('/tenants/{tenant}', [TenantController::class, 'show']); + + Route::get('/rbac/roles', [RbacController::class, 'roles']); + Route::post('/rbac/roles', [RbacController::class, 'createRole']); + Route::post('/rbac/roles/{role}/permissions', [RbacController::class, 'attachPermissions']); + Route::post('/rbac/users/{user}/roles', [RbacController::class, 'assignRoles']); + Route::post('/rbac/check', [RbacController::class, 'checkAccess']); + }); +}); diff --git a/panel-api/routes/api_v1/jobs.php b/panel-api/routes/api_v1/jobs.php new file mode 100644 index 00000000..6e4d315f --- /dev/null +++ b/panel-api/routes/api_v1/jobs.php @@ -0,0 +1,8 @@ +middleware(['auth:sanctum'])->group(function (): void { + Route::post('/jobs/dispatch', [JobController::class, 'dispatch']); +}); diff --git a/panel-api/routes/api_v1/marketplace.php b/panel-api/routes/api_v1/marketplace.php new file mode 100644 index 00000000..11aaeb2f --- /dev/null +++ b/panel-api/routes/api_v1/marketplace.php @@ -0,0 +1,11 @@ +middleware(['auth:sanctum'])->group(function (): void { + Route::get('/plugin-market', [MarketplaceController::class, 'catalog']); + Route::post('/plugins/install', [MarketplaceController::class, 'install']); + Route::post('/plugins/update', [MarketplaceController::class, 'update']); + Route::post('/plugins/remove', [MarketplaceController::class, 'remove']); +}); diff --git a/panel-api/routes/api_v1/monitoring.php b/panel-api/routes/api_v1/monitoring.php new file mode 100644 index 00000000..c34d4f1c --- /dev/null +++ b/panel-api/routes/api_v1/monitoring.php @@ -0,0 +1,15 @@ +middleware(['auth:sanctum'])->group(function (): void { + Route::get('/metrics/servers/{serverId}/live', [MonitoringController::class, 'liveServerMetrics']); + Route::post('/alerts/rules', [MonitoringController::class, 'createAlertRule']); + Route::get('/alerts', [MonitoringController::class, 'listAlerts']); +}); + +Route::prefix('v1')->group(function (): void { + Route::post('/metrics/ingest', [MetricsIngestController::class, 'ingest']); +}); diff --git a/panel-api/routes/api_v1/ops.php b/panel-api/routes/api_v1/ops.php new file mode 100644 index 00000000..b24886f0 --- /dev/null +++ b/panel-api/routes/api_v1/ops.php @@ -0,0 +1,24 @@ +middleware(['auth:sanctum'])->group(function (): void { + Route::post('/files/list', [FileOpsController::class, 'list']); + Route::post('/files/read', [FileOpsController::class, 'read']); + Route::post('/files/write', [FileOpsController::class, 'write']); + + Route::get('/cron/jobs', [CronController::class, 'index']); + Route::post('/cron/jobs', [CronController::class, 'store']); + Route::delete('/cron/jobs', [CronController::class, 'destroy']); + + Route::get('/firewall/rules', [FirewallController::class, 'index']); + Route::post('/firewall/rules', [FirewallController::class, 'store']); + Route::delete('/firewall/rules', [FirewallController::class, 'destroy']); + + Route::post('/backups/run', [BackupController::class, 'run']); + Route::post('/backups/restore', [BackupController::class, 'restore']); +}); diff --git a/panel-api/routes/api_v1/public.php b/panel-api/routes/api_v1/public.php new file mode 100644 index 00000000..0b736178 --- /dev/null +++ b/panel-api/routes/api_v1/public.php @@ -0,0 +1,9 @@ +group(function (): void { + Route::post('/tokens', [PublicApiController::class, 'issueToken']); + Route::post('/tokens/introspect', [PublicApiController::class, 'introspect']); +}); diff --git a/panel-api/routes/api_v1/saas.php b/panel-api/routes/api_v1/saas.php new file mode 100644 index 00000000..6d427b67 --- /dev/null +++ b/panel-api/routes/api_v1/saas.php @@ -0,0 +1,9 @@ +middleware(['auth:sanctum'])->group(function (): void { + Route::post('/saas/quotas/check', [SaaSController::class, 'checkQuota']); + Route::post('/saas/usage-events', [SaaSController::class, 'usageEvent']); +}); diff --git a/panel-api/routes/api_v1/servers.php b/panel-api/routes/api_v1/servers.php new file mode 100644 index 00000000..49cddf0e --- /dev/null +++ b/panel-api/routes/api_v1/servers.php @@ -0,0 +1,18 @@ +middleware(['auth:sanctum'])->group(function (): void { + Route::get('/servers', [ServerController::class, 'index']); + Route::post('/servers', [ServerController::class, 'store']); + Route::get('/servers/{server}', [ServerController::class, 'show']); + Route::patch('/servers/{server}', [ServerController::class, 'update']); + Route::delete('/servers/{server}', [ServerController::class, 'destroy']); + + Route::post('/servers/{server}/enrollment-token', [AgentController::class, 'issueEnrollmentToken']); + Route::get('/servers/{server}/capabilities', [AgentController::class, 'capabilities']); + Route::get('/servers/{server}/sessions', [AgentController::class, 'sessions']); + Route::post('/servers/{server}/heartbeat', [AgentController::class, 'heartbeat']); +}); diff --git a/panel-api/routes/api_v1/webhooks.php b/panel-api/routes/api_v1/webhooks.php new file mode 100644 index 00000000..1bd21320 --- /dev/null +++ b/panel-api/routes/api_v1/webhooks.php @@ -0,0 +1,10 @@ +middleware(['auth:sanctum'])->group(function (): void { + Route::get('/webhooks', [WebhookController::class, 'index']); + Route::post('/webhooks', [WebhookController::class, 'store']); + Route::post('/webhooks/test', [WebhookController::class, 'testDelivery']); +}); diff --git a/scripts/bootstrap-dev.sh b/scripts/bootstrap-dev.sh new file mode 100644 index 00000000..3c3e15f1 --- /dev/null +++ b/scripts/bootstrap-dev.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +cp -n .env.example .env || true +docker compose --env-file .env -f docker-compose.yakpanel.yml up -d --build +docker compose --env-file .env -f docker-compose.yakpanel.yml run --rm db-migrate +docker compose --env-file .env -f docker-compose.yakpanel.yml ps + +echo "YakPanel dev stack is ready." diff --git a/scripts/install-ubuntu.sh b/scripts/install-ubuntu.sh new file mode 100644 index 00000000..6892615b --- /dev/null +++ b/scripts/install-ubuntu.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${EUID}" -eq 0 ]]; then + echo "Run this script as a regular sudo-capable user, not root." + exit 1 +fi + +echo "[1/6] Updating apt package index..." +sudo apt-get update -y + +echo "[2/6] Installing required Ubuntu packages..." +sudo apt-get install -y ca-certificates curl gnupg lsb-release make git + +if ! command -v docker >/dev/null 2>&1; then + echo "[3/6] Installing Docker Engine..." + sudo install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg + sudo chmod a+r /etc/apt/keyrings/docker.gpg + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null + sudo apt-get update -y + sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +else + echo "[3/6] Docker already installed, skipping." +fi + +echo "[4/6] Ensuring current user can run Docker..." +sudo usermod -aG docker "$USER" + +echo "[5/6] Initializing YakPanel environment file..." +cd "$(dirname "$0")/.." +cp -n .env.example .env || true + +echo "[6/6] Starting YakPanel dev stack..." +docker compose --env-file .env -f docker-compose.yakpanel.yml up -d --build +docker compose --env-file .env -f docker-compose.yakpanel.yml run --rm db-migrate + +echo +echo "YakPanel dev stack started." +echo "API: http://localhost:8080/health" +echo "PostgreSQL: localhost:5432" +echo "Redis: localhost:6379" +echo "NATS monitor: http://localhost:8222" +echo "MinIO console: http://localhost:9001" +echo +echo "If Docker group membership was newly applied, re-login may be required." diff --git a/scripts/migrate-dev.sh b/scripts/migrate-dev.sh new file mode 100644 index 00000000..41681e20 --- /dev/null +++ b/scripts/migrate-dev.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [[ ! -f .env ]]; then + cp .env.example .env +fi + +docker compose --env-file .env -f docker-compose.yakpanel.yml run --rm db-migrate +echo "YakPanel database migrations applied."