commit b38c39030ba93b7feb918777cceda4b6bc135987 Author: pavel Date: Thu May 14 19:21:15 2026 +0200 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..058fa70 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +DATABASE_URL=postgres://postgres:postgres@localhost:5432/box?sslmode=disable +LISTEN_ADDR=:8080 +UNIX_SOCKET_PATH= +DEV_MODE=true +DEV_DEMO_USER=demo +DEV_DEMO_EMAIL=demo@example.local +AUTH_HEADER_USER=X-authentik-username +AUTH_HEADER_EMAIL=X-authentik-email +FORGEJO_BASE_URL= +FORGEJO_TOKEN= +FORGEJO_ORG= +PROJECTS_ROOT=$HOME/projects +DEFAULT_RUN_COMMAND=/usr/bin/env bash -lc 'sleep infinity' +CADDY_ROUTE_ENABLED=false +CADDY_ADMIN_URL=http://localhost:2019 +CADDY_SERVER_ID=srv0 +CADDY_DOMAIN_SUFFIX= +WEBHOOK_BASE_URL= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb6b0a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +bin/ +dist/ +*.log +.DS_Store +/tmp/ + +box diff --git a/README.md b/README.md new file mode 100644 index 0000000..be41978 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Project Box + +Project Box is a Go + vanilla web app for managing user projects. + +Features: +- Workspace model mapping each app-user workspace to a host Unix account +- Create projects per authenticated user +- Auto-create Forgejo repository or onboard an existing repository URL +- Auto-create Caddy reverse-proxy route via Admin API (optional) +- Manage project env vars +- Auto-generate user-scoped systemd service per project +- PostgreSQL persistence with startup migration +- Authentik forward-auth header support + dev mode demo user +- HTTP server on TCP and optional Unix socket +- Request/access logs + +## Run + +1. Copy config: + ```bash + cp .env.example .env + ``` +2. Start PostgreSQL and create database `box`. +3. Start app: + ```bash + go run . + ``` +4. Open `http://localhost:8080`. + +## Environment Variables + +See `.env.example`. + +Important: +- `DEV_MODE=true` uses a demo user and bypasses forward-auth headers. +- `DEV_MODE=false` expects auth headers from your reverse proxy: + - `X-authentik-username` + - `X-authentik-email` +- `FORGEJO_BASE_URL` + `FORGEJO_TOKEN` enable repository creation. +- `FORGEJO_ORG` switches creation from personal repos to org repos. +- `UNIX_SOCKET_PATH` enables unix socket listener in addition to `LISTEN_ADDR`. +- `CADDY_ROUTE_ENABLED=true` enables route provisioning. +- `CADDY_ADMIN_URL` points to the Caddy Admin API (default `http://localhost:2019`). +- `CADDY_SERVER_ID` is the HTTP server object id under `apps.http.servers` (default `srv0`). +- `CADDY_DOMAIN_SUFFIX` controls generated host as `.` (if empty: `-.local`). +- `WEBHOOK_BASE_URL` sets absolute webhook URLs returned by API/UI (recommended behind reverse proxy). + +## Caddy API Behavior + +On project creation, when Caddy routing is enabled, the app appends a route to: +- `POST /config/apps/http/servers//routes` + +Route shape: +- `match.host = []` +- `handle[0].handler = reverse_proxy` +- `handle[0].upstreams[0].dial = 127.0.0.1:` + +## Workspaces and Unix Users + +- Create one or more workspaces per authenticated user. +- Each workspace stores a `unix_user` (must exist on the host). +- Project creation requires selecting a workspace. +- Service/env files are written under that Unix user's home: + - `~/.config/systemd/user/.service` + - `~/.config/project-manager///service.env` +- The app attempts to run user-systemd commands as that Unix user via: + - `sudo -n -u systemctl --user ...` + +## Existing Repos + +- In project creation, set `repo_url` (or fill \"Existing Repo URL\" in UI) to onboard an existing repo. +- If `repo_url` is provided, Forgejo repo creation is skipped. +- If `repo_url` is empty, the app tries to create a Forgejo repo when Forgejo env vars are configured. + +## Auto Deploy via Webhook + +- Each project gets a unique deploy webhook URL, exposed as `webhook_url` in project API responses and shown in UI. +- For repos hosted on the configured Forgejo instance, the app now auto-provisions the repository webhook via Forgejo API. +- If auto-provisioning cannot apply (for example external non-Forgejo repo), configure a webhook manually to call `webhook_url`. +- Deploy runs only when payload `ref` equals `refs/heads/main`. +- Deploy behavior: + 1. Clone repo to `////repo` if missing. + 2. Otherwise `fetch origin main` and `reset --hard origin/main`. + 3. Restart the project user service via `systemctl --user restart`. + +Notes: +- `WEBHOOK_BASE_URL` should be set to an externally reachable base URL so Forgejo can call webhook endpoints. + +## Runtime Status and Logs + +- Per-project runtime endpoints: + - `GET /api/projects/:id/status` + - `GET /api/projects/:id/logs?lines=200` +- Status is read via `systemctl --user show `. +- Logs are read via `journalctl --user -u `. +- Both commands are executed as the workspace Unix user via `sudo -n -u ...`. + +## Systemd + +For each project in workspace `` owned by unix user ``, the app writes: +- env file: `////.env` +- unit file: `/.config/systemd/user/projectmgr--.service` + +Then it attempts as that unix user: +- `systemctl --user daemon-reload` +- `systemctl --user enable --now ` + +The generated `.env` is synced from the project env-var configuration and always includes: +- `LISTEN_NETWORK=unix` +- `LISTEN_ADDRESS=////app.sock` + +If user systemd is not available in the runtime environment, project creation still succeeds and unit files are still generated. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..640fd16 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module box + +go 1.23.0 + +toolchain go1.24.5 + +require ( + github.com/jackc/pgx/v5 v5.7.6 + github.com/joho/godotenv v1.5.1 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..28f9c1b --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..62ea816 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,63 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" +) + +func loadConfig() (Config, error) { + cfg := Config{ + DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/box?sslmode=disable"), + ListenAddr: getenv("LISTEN_ADDR", ":8080"), + UnixSocketPath: getenv("UNIX_SOCKET_PATH", ""), + DevMode: getenvBool("DEV_MODE", true), + DemoUser: getenv("DEV_DEMO_USER", "demo"), + DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"), + AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"), + AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"), + ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"), + ForgejoToken: getenv("FORGEJO_TOKEN", ""), + ForgejoOrg: getenv("FORGEJO_ORG", ""), + ProjectRoot: getenv("PROJECTS_ROOT", filepath.Join(userHomeOrDot(), "projects")), + DefaultRunCommand: getenv("DEFAULT_RUN_COMMAND", "/usr/bin/env bash -lc 'sleep infinity'"), + CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"), + CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"), + CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""), + CaddyRouteEnabled: getenvBool("CADDY_ROUTE_ENABLED", false), + WebhookBaseURL: strings.TrimRight(getenv("WEBHOOK_BASE_URL", ""), "/"), + } + if cfg.DatabaseURL == "" { + return cfg, errors.New("DATABASE_URL is required") + } + return cfg, nil +} + +func userHomeOrDot() string { + h, err := os.UserHomeDir() + if err != nil { + return "." + } + return h +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getenvBool(key string, fallback bool) bool { + v := os.Getenv(key) + if v == "" { + return fallback + } + b, err := strconv.ParseBool(v) + if err != nil { + return fallback + } + return b +} diff --git a/internal/app/db.go b/internal/app/db.go new file mode 100644 index 0000000..2a057bf --- /dev/null +++ b/internal/app/db.go @@ -0,0 +1,77 @@ +package app + +import "context" + +func (a *App) migrate(ctx context.Context) error { + ddl := []string{ + `CREATE TABLE IF NOT EXISTS workspaces ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + unix_user TEXT NOT NULL, + UNIQUE(user_id, name), + UNIQUE(user_id, unix_user) + );`, + `CREATE TABLE IF NOT EXISTS projects ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + workspace_id BIGINT REFERENCES workspaces(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + repo_url TEXT NOT NULL DEFAULT '', + service_name TEXT NOT NULL, + route_host TEXT NOT NULL DEFAULT '', + target_port INT NOT NULL DEFAULT 8000, + deploy_token TEXT NOT NULL DEFAULT '', + provision_state TEXT NOT NULL DEFAULT 'provisioned', + provision_error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, slug) + );`, + `CREATE TABLE IF NOT EXISTS project_env_vars ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL, + UNIQUE(project_id, key) + );`, + } + for _, q := range ddl { + if _, err := a.db.ExecContext(ctx, q); err != nil { + return err + } + } + alter := []string{ + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS route_host TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS target_port INT NOT NULL DEFAULT 8000;`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS workspace_id BIGINT REFERENCES workspaces(id) ON DELETE RESTRICT;`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS deploy_token TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS provision_state TEXT NOT NULL DEFAULT 'provisioned';`, + `ALTER TABLE projects ADD COLUMN IF NOT EXISTS provision_error TEXT NOT NULL DEFAULT '';`, + } + for _, q := range alter { + if _, err := a.db.ExecContext(ctx, q); err != nil { + return err + } + } + rows, err := a.db.QueryContext(ctx, `SELECT id FROM projects WHERE deploy_token=''`) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return err + } + tok, err := newDeployToken() + if err != nil { + return err + } + if _, err := a.db.ExecContext(ctx, `UPDATE projects SET deploy_token=$1 WHERE id=$2`, tok, id); err != nil { + return err + } + } + return rows.Err() +} diff --git a/internal/app/deploy_service.go b/internal/app/deploy_service.go new file mode 100644 index 0000000..1dba0cf --- /dev/null +++ b/internal/app/deploy_service.go @@ -0,0 +1,93 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" +) + +func (a *App) handleDeployWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + path := strings.TrimPrefix(r.URL.Path, "/api/webhooks/deploy/") + parts := strings.Split(path, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + http.Error(w, "bad path", http.StatusBadRequest) + return + } + projectID, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + http.Error(w, "invalid project id", http.StatusBadRequest) + return + } + p, err := a.getProjectForWebhook(r.Context(), projectID, parts[1]) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var payload struct { + Ref string `json:"ref"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if payload.Ref != "refs/heads/main" { + writeJSON(w, http.StatusAccepted, map[string]string{"status": "ignored", "reason": "not main branch"}) + return + } + if err := a.deployProjectFromMain(r.Context(), p); err != nil { + log.Printf("deploy failed project_id=%d err=%v", p.ID, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deployed"}) +} + +func (a *App) getProjectForWebhook(ctx context.Context, projectID int64, token string) (Project, error) { + var p Project + err := a.db.QueryRowContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.service_name, p.route_host, p.target_port, p.workspace_id, w.name, w.unix_user, p.deploy_token, p.created_at + FROM projects p JOIN workspaces w ON w.id=p.workspace_id WHERE p.id=$1 AND p.deploy_token=$2`, projectID, token). + Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.CreatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Project{}, errors.New("project not found") + } + return Project{}, err + } + p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) + return p, nil +} + +func (a *App) deployProjectFromMain(_ context.Context, p Project) error { + if p.RepoURL == "" { + return errors.New("project has no repo_url to deploy from") + } + projectDir := filepath.Join(a.cfg.ProjectRoot, slugify(p.UserID), p.Workspace, p.Slug) + repoDir := filepath.Join(projectDir, "repo") + if _, err := os.Stat(repoDir); os.IsNotExist(err) { + if err := runCmd("git", "clone", "--branch", "main", "--single-branch", p.RepoURL, repoDir); err != nil { + return err + } + } else { + if err := runCmd("git", "-C", repoDir, "remote", "set-url", "origin", p.RepoURL); err != nil { + return err + } + if err := runCmd("git", "-C", repoDir, "fetch", "origin", "main", "--prune"); err != nil { + return err + } + if err := runCmd("git", "-C", repoDir, "reset", "--hard", "origin/main"); err != nil { + return err + } + } + return runCmd("sudo", "-n", "-u", p.UnixUser, "systemctl", "--user", "restart", p.ServiceName) +} diff --git a/internal/app/integrations.go b/internal/app/integrations.go new file mode 100644 index 0000000..8ddfe7c --- /dev/null +++ b/internal/app/integrations.go @@ -0,0 +1,192 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +func (a *App) createForgejoRepo(ctx context.Context, name, description string, private bool) (string, error) { + if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" { + return "", nil + } + payload := map[string]any{"name": name, "description": description, "private": private, "auto_init": true} + body, _ := json.Marshal(payload) + endpoint := a.cfg.ForgejoBaseURL + "/api/v1/user/repos" + if a.cfg.ForgejoOrg != "" { + endpoint = fmt.Sprintf("%s/api/v1/orgs/%s/repos", a.cfg.ForgejoBaseURL, a.cfg.ForgejoOrg) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body))) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "token "+a.cfg.ForgejoToken) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("forgejo request failed: %w", err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return "", fmt.Errorf("forgejo error: status=%d body=%s", resp.StatusCode, string(respBody)) + } + var parsed struct { + HTMLURL string `json:"html_url"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", nil + } + return parsed.HTMLURL, nil +} + +func (a *App) buildRouteHost(slug, username string) string { + if a.cfg.CaddyDomainSuffix == "" { + return fmt.Sprintf("%s-%s.local", slugify(username), slug) + } + return fmt.Sprintf("%s.%s", slug, strings.TrimPrefix(a.cfg.CaddyDomainSuffix, ".")) +} + +func (a *App) ensureCaddyRoute(ctx context.Context, p Project) error { + if !a.cfg.CaddyRouteEnabled { + return nil + } + route := map[string]any{ + "@id": "project-route-" + p.UserID + "-" + p.Slug, + "match": []any{map[string]any{"host": []string{p.RouteHost}}}, + "handle": []any{map[string]any{"handler": "reverse_proxy", "upstreams": []any{map[string]any{"dial": fmt.Sprintf("127.0.0.1:%d", p.TargetPort)}}}}, + } + body, _ := json.Marshal(route) + url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("caddy request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("caddy error: status=%d body=%s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (a *App) ensureForgejoDeployWebhook(ctx context.Context, p Project) error { + if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" || p.RepoURL == "" { + return nil + } + if a.cfg.WebhookBaseURL == "" { + return errors.New("WEBHOOK_BASE_URL is required for automatic Forgejo webhook provisioning") + } + + owner, repo, err := parseForgejoRepoOwnerRepo(a.cfg.ForgejoBaseURL, p.RepoURL) + if err != nil { + // Existing repo is not on configured Forgejo host; skip auto-provision. + return nil + } + + hooksURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", a.cfg.ForgejoBaseURL, owner, repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, hooksURL, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+a.cfg.ForgejoToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("forgejo hooks list failed: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return fmt.Errorf("forgejo hooks list error: status=%d body=%s", resp.StatusCode, string(body)) + } + var hooks []struct { + Config struct { + URL string `json:"url"` + } `json:"config"` + } + _ = json.Unmarshal(body, &hooks) + for _, h := range hooks { + if strings.TrimSpace(h.Config.URL) == strings.TrimSpace(p.WebhookURL) { + return nil + } + } + + payload := map[string]any{ + "type": "gitea", + "active": true, + "events": []string{"push"}, + "config": map[string]any{ + "url": p.WebhookURL, + "content_type": "json", + }, + } + bodyBytes, _ := json.Marshal(payload) + createReq, err := http.NewRequestWithContext(ctx, http.MethodPost, hooksURL, strings.NewReader(string(bodyBytes))) + if err != nil { + return err + } + createReq.Header.Set("Authorization", "token "+a.cfg.ForgejoToken) + createReq.Header.Set("Content-Type", "application/json") + createResp, err := http.DefaultClient.Do(createReq) + if err != nil { + return fmt.Errorf("forgejo hook create failed: %w", err) + } + defer createResp.Body.Close() + createBody, _ := io.ReadAll(createResp.Body) + if createResp.StatusCode >= 300 { + return fmt.Errorf("forgejo hook create error: status=%d body=%s", createResp.StatusCode, string(createBody)) + } + return nil +} + +func parseForgejoRepoOwnerRepo(forgejoBaseURL, repoURL string) (string, string, error) { + fBase, err := url.Parse(forgejoBaseURL) + if err != nil { + return "", "", err + } + normalized := strings.TrimSpace(repoURL) + if strings.HasPrefix(normalized, "git@") { + // git@host:owner/repo(.git) + at := strings.Index(normalized, "@") + colon := strings.Index(normalized, ":") + if at < 0 || colon < 0 || colon <= at+1 { + return "", "", errors.New("invalid ssh repo url") + } + host := normalized[at+1 : colon] + if !strings.EqualFold(host, fBase.Hostname()) { + return "", "", errors.New("repo host does not match configured forgejo host") + } + path := strings.TrimPrefix(normalized[colon+1:], "/") + path = strings.TrimSuffix(path, ".git") + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "", "", errors.New("repo path does not include owner/repo") + } + return parts[0], parts[1], nil + } + + u, err := url.Parse(normalized) + if err != nil { + return "", "", err + } + if !strings.EqualFold(u.Hostname(), fBase.Hostname()) { + return "", "", errors.New("repo host does not match configured forgejo host") + } + path := strings.TrimPrefix(u.Path, "/") + path = strings.TrimSuffix(path, ".git") + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "", "", errors.New("repo path does not include owner/repo") + } + return parts[0], parts[1], nil +} diff --git a/internal/app/middleware.go b/internal/app/middleware.go new file mode 100644 index 0000000..c99b5c3 --- /dev/null +++ b/internal/app/middleware.go @@ -0,0 +1,62 @@ +package app + +import ( + "context" + "log" + "net/http" + "time" +) + +type ctxKey string + +const userKey ctxKey = "user" + +func (a *App) withAuth(next func(http.ResponseWriter, *http.Request, User)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var u User + if a.cfg.DevMode { + u = User{Username: a.cfg.DemoUser, Email: a.cfg.DemoEmail} + } else { + u = User{Username: r.Header.Get(a.cfg.AuthHeaderUser), Email: r.Header.Get(a.cfg.AuthHeaderEmail)} + if u.Username == "" { + http.Error(w, "missing authenticated user header", http.StatusUnauthorized) + return + } + } + next(w, r.WithContext(context.WithValue(r.Context(), userKey, u)), u) + } +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + lrw := &logResponseWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(lrw, r) + log.Printf("method=%s path=%s status=%d bytes=%d dur_ms=%d remote=%s ua=%q", + r.Method, + r.URL.Path, + lrw.status, + lrw.bytes, + time.Since(start).Milliseconds(), + r.RemoteAddr, + r.UserAgent(), + ) + }) +} + +type logResponseWriter struct { + http.ResponseWriter + status int + bytes int +} + +func (l *logResponseWriter) WriteHeader(statusCode int) { + l.status = statusCode + l.ResponseWriter.WriteHeader(statusCode) +} + +func (l *logResponseWriter) Write(b []byte) (int, error) { + n, err := l.ResponseWriter.Write(b) + l.bytes += n + return n, err +} diff --git a/internal/app/project_service.go b/internal/app/project_service.go new file mode 100644 index 0000000..b4fd804 --- /dev/null +++ b/internal/app/project_service.go @@ -0,0 +1,182 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" +) + +func (a *App) listProjects(ctx context.Context, userID string) ([]Project, error) { + rows, err := a.db.QueryContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.service_name, p.route_host, p.target_port, p.workspace_id, w.name, w.unix_user, p.deploy_token, p.provision_state, p.provision_error, p.created_at + FROM projects p + JOIN workspaces w ON w.id = p.workspace_id + WHERE p.user_id=$1 ORDER BY p.created_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Project + for rows.Next() { + var p Project + if err := rows.Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &p.CreatedAt); err != nil { + return nil, err + } + p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) + out = append(out, p) + } + return out, rows.Err() +} + +func (a *App) createProject(ctx context.Context, user User, workspaceID int64, name, description string, private bool, existingRepoURL string, targetPort int) (Project, error) { + slug := slugify(name) + if slug == "" { + return Project{}, errors.New("project name must include letters or numbers") + } + workspace, err := a.getWorkspace(ctx, user.Username, workspaceID) + if err != nil { + return Project{}, err + } + + repoURL := existingRepoURL + serviceName := fmt.Sprintf("projectmgr-%s-%s.service", slugify(user.Username), slug) + routeHost := a.buildRouteHost(slug, user.Username) + deployToken, err := newDeployToken() + if err != nil { + return Project{}, err + } + + var p Project + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return Project{}, err + } + defer tx.Rollback() + + err = tx.QueryRowContext(ctx, `INSERT INTO projects (user_id, workspace_id, name, slug, description, repo_url, service_name, route_host, target_port, deploy_token, provision_state, provision_error) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'pending','') RETURNING id, user_id, name, slug, description, repo_url, service_name, route_host, target_port, workspace_id, deploy_token, provision_state, provision_error, created_at`, + user.Username, workspaceID, name, slug, description, repoURL, serviceName, routeHost, targetPort, deployToken, + ).Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &p.CreatedAt) + if err != nil { + if strings.Contains(err.Error(), "duplicate key") { + return Project{}, errors.New("project with this name already exists") + } + return Project{}, err + } + if err := tx.Commit(); err != nil { + return Project{}, err + } + + p.Workspace = workspace.Name + p.UnixUser = workspace.UnixUser + p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) + + if p.RepoURL == "" { + repoURL, err = a.createForgejoRepo(ctx, slug, description, private) + if err != nil { + _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) + p.ProvisionState = "failed" + p.ProvisionError = err.Error() + return p, err + } + p.RepoURL = repoURL + if _, err := a.db.ExecContext(ctx, `UPDATE projects SET repo_url=$1 WHERE id=$2`, repoURL, p.ID); err != nil { + _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) + p.ProvisionState = "failed" + p.ProvisionError = err.Error() + return p, err + } + } + if err := a.ensureForgejoDeployWebhook(ctx, p); err != nil { + _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) + p.ProvisionState = "failed" + p.ProvisionError = err.Error() + return p, err + } + + if err := a.renderSystemdForProject(ctx, p); err != nil { + _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) + p.ProvisionState = "failed" + p.ProvisionError = err.Error() + return p, err + } + if err := a.ensureCaddyRoute(ctx, p); err != nil { + _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) + p.ProvisionState = "failed" + p.ProvisionError = err.Error() + return p, err + } + if err := a.updateProvisioningState(ctx, p.ID, "provisioned", ""); err != nil { + p.ProvisionState = "failed" + p.ProvisionError = err.Error() + return p, err + } + p.ProvisionState = "provisioned" + p.ProvisionError = "" + return p, nil +} + +func (a *App) getProject(ctx context.Context, userID string, projectID int64) (Project, error) { + var p Project + err := a.db.QueryRowContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.service_name, p.route_host, p.target_port, p.workspace_id, w.name, w.unix_user, p.deploy_token, p.provision_state, p.provision_error, p.created_at + FROM projects p JOIN workspaces w ON w.id=p.workspace_id WHERE p.user_id=$1 AND p.id=$2`, userID, projectID). + Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.ServiceName, &p.RouteHost, &p.TargetPort, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.ProvisionState, &p.ProvisionError, &p.CreatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Project{}, errors.New("project not found") + } + return Project{}, err + } + p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) + return p, nil +} + +func (a *App) updateProvisioningState(ctx context.Context, projectID int64, state, errText string) error { + _, err := a.db.ExecContext(ctx, `UPDATE projects SET provision_state=$1, provision_error=$2 WHERE id=$3`, state, errText, projectID) + return err +} + +func (a *App) listEnvVars(ctx context.Context, userID string, projectID int64) ([]EnvVar, error) { + if _, err := a.getProject(ctx, userID, projectID); err != nil { + return nil, err + } + rows, err := a.db.QueryContext(ctx, `SELECT id, project_id, key, value FROM project_env_vars WHERE project_id=$1 ORDER BY key`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []EnvVar + for rows.Next() { + var v EnvVar + if err := rows.Scan(&v.ID, &v.ProjectID, &v.Key, &v.Value); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +func (a *App) upsertEnvVar(ctx context.Context, userID string, projectID int64, key, value string) error { + p, err := a.getProject(ctx, userID, projectID) + if err != nil { + return err + } + _, err = a.db.ExecContext(ctx, `INSERT INTO project_env_vars (project_id, key, value) VALUES ($1,$2,$3) + ON CONFLICT(project_id, key) DO UPDATE SET value = EXCLUDED.value`, projectID, key, value) + if err != nil { + return err + } + return a.renderSystemdForProject(ctx, p) +} + +func (a *App) deleteEnvVar(ctx context.Context, userID string, projectID int64, key string) error { + p, err := a.getProject(ctx, userID, projectID) + if err != nil { + return err + } + _, err = a.db.ExecContext(ctx, `DELETE FROM project_env_vars WHERE project_id=$1 AND key=$2`, projectID, key) + if err != nil { + return err + } + return a.renderSystemdForProject(ctx, p) +} diff --git a/internal/app/routes_handlers.go b/internal/app/routes_handlers.go new file mode 100644 index 0000000..5fc1b69 --- /dev/null +++ b/internal/app/routes_handlers.go @@ -0,0 +1,251 @@ +package app + +import ( + "encoding/json" + "net/http" + osuser "os/user" + "strconv" + "strings" +) + +func (a *App) routes() http.Handler { + mux := http.NewServeMux() + mux.Handle("/", http.FileServer(http.Dir("./web"))) + mux.HandleFunc("/api/me", a.withAuth(a.handleMe)) + mux.HandleFunc("/api/workspaces", a.withAuth(a.handleWorkspaces)) + mux.HandleFunc("/api/projects", a.withAuth(a.handleProjects)) + mux.HandleFunc("/api/projects/", a.withAuth(a.handleProjectSubroutes)) + mux.HandleFunc("/api/webhooks/deploy/", a.handleDeployWebhook) + return loggingMiddleware(mux) +} + +func (a *App) handleMe(w http.ResponseWriter, _ *http.Request, user User) { + writeJSON(w, http.StatusOK, user) +} + +func (a *App) handleWorkspaces(w http.ResponseWriter, r *http.Request, user User) { + switch r.Method { + case http.MethodGet: + items, err := a.listWorkspaces(r.Context(), user.Username) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req struct { + Name string `json:"name"` + UnixUser string `json:"unix_user"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + req.Name = strings.TrimSpace(req.Name) + req.UnixUser = strings.TrimSpace(req.UnixUser) + if req.Name == "" || req.UnixUser == "" { + http.Error(w, "name and unix_user are required", http.StatusBadRequest) + return + } + if _, err := osuser.Lookup(req.UnixUser); err != nil { + http.Error(w, "unix_user does not exist on host", http.StatusBadRequest) + return + } + item, err := a.createWorkspace(r.Context(), user.Username, req.Name, req.UnixUser) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusCreated, item) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (a *App) handleProjects(w http.ResponseWriter, r *http.Request, user User) { + switch r.Method { + case http.MethodGet: + projects, err := a.listProjects(r.Context(), user.Username) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, projects) + case http.MethodPost: + var req struct { + Name string `json:"name"` + Description string `json:"description"` + RepoPrivate bool `json:"repo_private"` + RepoURL string `json:"repo_url"` + TargetPort int `json:"target_port"` + WorkspaceID int64 `json:"workspace_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Name) == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + if req.WorkspaceID <= 0 { + http.Error(w, "workspace_id is required", http.StatusBadRequest) + return + } + req.RepoURL = strings.TrimSpace(req.RepoURL) + if req.RepoURL != "" && !isValidRepoURL(req.RepoURL) { + http.Error(w, "repo_url must be a valid repo URL (https://..., ssh://..., or git@...)", http.StatusBadRequest) + return + } + if req.TargetPort == 0 { + req.TargetPort = 8000 + } + if req.TargetPort < 1 || req.TargetPort > 65535 { + http.Error(w, "target_port must be 1-65535", http.StatusBadRequest) + return + } + project, err := a.createProject(r.Context(), user, req.WorkspaceID, req.Name, req.Description, req.RepoPrivate, req.RepoURL, req.TargetPort) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusCreated, project) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (a *App) handleProjectSubroutes(w http.ResponseWriter, r *http.Request, user User) { + path := strings.TrimPrefix(r.URL.Path, "/api/projects/") + parts := strings.Split(path, "/") + if len(parts) < 1 || parts[0] == "" { + http.Error(w, "bad path", http.StatusBadRequest) + return + } + projectID, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + http.Error(w, "invalid project id", http.StatusBadRequest) + return + } + if len(parts) == 1 { + http.Error(w, "unsupported endpoint", http.StatusNotFound) + return + } + switch parts[1] { + case "env": + a.handleProjectEnv(w, r, user, projectID) + case "service": + a.handleServiceRegenerate(w, r, user, projectID) + case "status": + a.handleProjectStatus(w, r, user, projectID) + case "logs": + a.handleProjectLogs(w, r, user, projectID) + default: + http.Error(w, "unsupported endpoint", http.StatusNotFound) + } +} + +func (a *App) handleProjectEnv(w http.ResponseWriter, r *http.Request, user User, projectID int64) { + switch r.Method { + case http.MethodGet: + envVars, err := a.listEnvVars(r.Context(), user.Username, projectID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, envVars) + case http.MethodPost: + var req struct { + Key string `json:"key"` + Value string `json:"value"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if err := validateEnvKey(req.Key); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := a.upsertEnvVar(r.Context(), user.Username, projectID, req.Key, req.Value); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + case http.MethodDelete: + key := r.URL.Query().Get("key") + if err := validateEnvKey(key); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := a.deleteEnvVar(r.Context(), user.Username, projectID, key); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (a *App) handleServiceRegenerate(w http.ResponseWriter, r *http.Request, user User, projectID int64) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + p, err := a.getProject(r.Context(), user.Username, projectID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := a.renderSystemdForProject(r.Context(), p); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "service regenerated"}) +} + +func (a *App) handleProjectStatus(w http.ResponseWriter, r *http.Request, user User, projectID int64) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + p, err := a.getProject(r.Context(), user.Username, projectID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + status, err := a.getServiceStatus(p) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (a *App) handleProjectLogs(w http.ResponseWriter, r *http.Request, user User, projectID int64) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + p, err := a.getProject(r.Context(), user.Username, projectID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + lines := 200 + if raw := r.URL.Query().Get("lines"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > 1000 { + http.Error(w, "lines must be 1-1000", http.StatusBadRequest) + return + } + lines = n + } + logs, err := a.getServiceLogs(p, lines) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeJSON(w, http.StatusOK, map[string]any{"lines": lines, "logs": logs}) +} diff --git a/internal/app/run.go b/internal/app/run.go new file mode 100644 index 0000000..63227e1 --- /dev/null +++ b/internal/app/run.go @@ -0,0 +1,56 @@ +package app + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/joho/godotenv" +) + +func Run() error { + _ = godotenv.Load() + cfg, err := loadConfig() + if err != nil { + return fmt.Errorf("config error: %w", err) + } + db, err := sql.Open("pgx", cfg.DatabaseURL) + if err != nil { + return fmt.Errorf("database open error: %w", err) + } + defer db.Close() + if err := db.Ping(); err != nil { + return fmt.Errorf("database ping error: %w", err) + } + app := &App{cfg: cfg, db: db} + if err := app.migrate(context.Background()); err != nil { + return fmt.Errorf("migration error: %w", err) + } + handler := app.routes() + servers, cleanup, err := startServers(cfg, handler) + if err != nil { + return fmt.Errorf("server start error: %w", err) + } + defer cleanup() + + log.Printf("server started: tcp=%q unix=%q dev_mode=%t", cfg.ListenAddr, cfg.UnixSocketPath, cfg.DevMode) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + <-sigCh + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for _, s := range servers { + if err := s.Shutdown(ctx); err != nil { + log.Printf("shutdown error: %v", err) + } + } + log.Printf("shutdown complete") + return nil +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..1f59c90 --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,56 @@ +package app + +import ( + "errors" + "log" + "net" + "net/http" + "os" + "path/filepath" +) + +func startServers(cfg Config, handler http.Handler) ([]*http.Server, func(), error) { + var servers []*http.Server + cleanup := func() {} + + if cfg.ListenAddr != "" { + srv := &http.Server{Addr: cfg.ListenAddr, Handler: handler} + servers = append(servers, srv) + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("tcp server error: %v", err) + } + }() + } + + if cfg.UnixSocketPath != "" { + if err := os.MkdirAll(filepath.Dir(cfg.UnixSocketPath), 0o755); err != nil { + return nil, cleanup, err + } + _ = os.Remove(cfg.UnixSocketPath) + ln, err := net.Listen("unix", cfg.UnixSocketPath) + if err != nil { + return nil, cleanup, err + } + if err := os.Chmod(cfg.UnixSocketPath, 0o666); err != nil { + return nil, cleanup, err + } + + srv := &http.Server{Handler: handler} + servers = append(servers, srv) + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("unix server error: %v", err) + } + }() + cleanup = func() { + _ = ln.Close() + _ = os.Remove(cfg.UnixSocketPath) + } + } + + if len(servers) == 0 { + return nil, cleanup, errors.New("no listeners configured, set LISTEN_ADDR and/or UNIX_SOCKET_PATH") + } + return servers, cleanup, nil +} diff --git a/internal/app/systemd_service.go b/internal/app/systemd_service.go new file mode 100644 index 0000000..f0fab29 --- /dev/null +++ b/internal/app/systemd_service.go @@ -0,0 +1,149 @@ +package app + +import ( + "context" + "fmt" + "os" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" +) + +func (a *App) renderSystemdForProject(ctx context.Context, p Project) error { + envVars, err := a.listEnvVars(ctx, p.UserID, p.ID) + if err != nil { + if err.Error() == "project not found" { + return err + } + envVars = []EnvVar{} + } + projectDir := filepath.Join(a.cfg.ProjectRoot, slugify(p.UserID), p.Workspace, p.Slug) + if err := os.MkdirAll(projectDir, 0o755); err != nil { + return err + } + dataDir := filepath.Join(projectDir, "data") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return err + } + socketPath := filepath.Join(projectDir, "app.sock") + envPath := filepath.Join(projectDir, ".env") + + values := map[string]string{} + for _, v := range envVars { + values[v.Key] = v.Value + } + values["LISTEN_NETWORK"] = "unix" + values["LISTEN_ADDRESS"] = socketPath + + var b strings.Builder + keys := make([]string, 0, len(values)) + for k := range values { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b.WriteString(k) + b.WriteString("=") + b.WriteString(escapeEnvValue(values[k])) + b.WriteString("\n") + } + if err := os.WriteFile(envPath, []byte(b.String()), 0o600); err != nil { + return err + } + + unit := fmt.Sprintf(`[Unit] +Description=Project %s +After=network.target + +[Service] +Type=simple +WorkingDirectory=%s +EnvironmentFile=%s +ExecStart=%s +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target +`, p.Name, dataDir, envPath, a.cfg.DefaultRunCommand) + + homeDir, err := homeForUnixUser(p.UnixUser) + if err != nil { + return err + } + serviceDir := filepath.Join(homeDir, ".config", "systemd", "user") + if err := os.MkdirAll(serviceDir, 0o755); err != nil { + return err + } + servicePath := filepath.Join(serviceDir, p.ServiceName) + if err := os.WriteFile(servicePath, []byte(unit), 0o644); err != nil { + return err + } + _ = runCmd("sudo", "-n", "-u", p.UnixUser, "systemctl", "--user", "daemon-reload") + _ = runCmd("sudo", "-n", "-u", p.UnixUser, "systemctl", "--user", "enable", "--now", p.ServiceName) + return nil +} + +func homeForUnixUser(username string) (string, error) { + u, err := user.Lookup(username) + if err != nil { + return "", fmt.Errorf("lookup unix user %q failed: %w", username, err) + } + if u.HomeDir == "" { + return "", fmt.Errorf("unix user %q has no home directory", username) + } + return u.HomeDir, nil +} + +type ServiceStatus struct { + Unit string `json:"unit"` + ActiveState string `json:"active_state"` + SubState string `json:"sub_state"` + MainPID int `json:"main_pid"` + ExecStatus int `json:"exec_status"` + Result string `json:"result"` + FragmentPath string `json:"fragment_path"` +} + +func (a *App) getServiceStatus(p Project) (ServiceStatus, error) { + out, err := runCmdOut( + "sudo", "-n", "-u", p.UnixUser, + "systemctl", "--user", "show", p.ServiceName, "--no-pager", + "--property=ActiveState,SubState,MainPID,ExecMainStatus,Result,FragmentPath", + ) + if err != nil { + return ServiceStatus{}, err + } + status := ServiceStatus{Unit: p.ServiceName} + for _, line := range strings.Split(out, "\n") { + if line == "" || !strings.Contains(line, "=") { + continue + } + parts := strings.SplitN(line, "=", 2) + k, v := parts[0], parts[1] + switch k { + case "ActiveState": + status.ActiveState = v + case "SubState": + status.SubState = v + case "MainPID": + status.MainPID, _ = strconv.Atoi(v) + case "ExecMainStatus": + status.ExecStatus, _ = strconv.Atoi(v) + case "Result": + status.Result = v + case "FragmentPath": + status.FragmentPath = v + } + } + return status, nil +} + +func (a *App) getServiceLogs(p Project, lines int) (string, error) { + return runCmdOut( + "sudo", "-n", "-u", p.UnixUser, + "journalctl", "--user", "-u", p.ServiceName, "--no-pager", "-n", strconv.Itoa(lines), "-o", "short-iso", + ) +} diff --git a/internal/app/types.go b/internal/app/types.go new file mode 100644 index 0000000..8f46238 --- /dev/null +++ b/internal/app/types.go @@ -0,0 +1,71 @@ +package app + +import ( + "database/sql" + "time" +) + +type Config struct { + DatabaseURL string + ListenAddr string + UnixSocketPath string + DevMode bool + DemoUser string + DemoEmail string + AuthHeaderUser string + AuthHeaderEmail string + ForgejoBaseURL string + ForgejoToken string + ForgejoOrg string + ProjectRoot string + DefaultRunCommand string + CaddyAdminURL string + CaddyServerID string + CaddyDomainSuffix string + CaddyRouteEnabled bool + WebhookBaseURL string +} + +type App struct { + cfg Config + db *sql.DB +} + +type User struct { + Username string `json:"username"` + Email string `json:"email"` +} + +type Project struct { + ID int64 `json:"id"` + UserID string `json:"-"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + RepoURL string `json:"repo_url"` + ServiceName string `json:"service_name"` + RouteHost string `json:"route_host"` + TargetPort int `json:"target_port"` + WorkspaceID int64 `json:"workspace_id"` + Workspace string `json:"workspace"` + UnixUser string `json:"unix_user"` + WebhookURL string `json:"webhook_url"` + DeployToken string `json:"-"` + ProvisionState string `json:"provision_state"` + ProvisionError string `json:"provision_error,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type EnvVar struct { + ID int64 `json:"id"` + ProjectID int64 `json:"project_id"` + Key string `json:"key"` + Value string `json:"value"` +} + +type Workspace struct { + ID int64 `json:"id"` + UserID string `json:"-"` + Name string `json:"name"` + UnixUser string `json:"unix_user"` +} diff --git a/internal/app/util.go b/internal/app/util.go new file mode 100644 index 0000000..922b5b7 --- /dev/null +++ b/internal/app/util.go @@ -0,0 +1,72 @@ +package app + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "os/exec" + "regexp" + "strconv" + "strings" +) + +func validateEnvKey(key string) error { + if key == "" { + return fmt.Errorf("key is required") + } + if !regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`).MatchString(key) { + return fmt.Errorf("invalid env key format") + } + return nil +} + +func isValidRepoURL(s string) bool { + return strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "ssh://") || strings.HasPrefix(s, "git@") +} + +func escapeEnvValue(v string) string { return strconv.Quote(v) } + +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func (a *App) deployWebhookURL(projectID int64, token string) string { + if token == "" { + return "" + } + path := fmt.Sprintf("/api/webhooks/deploy/%d/%s", projectID, token) + if a.cfg.WebhookBaseURL != "" { + return a.cfg.WebhookBaseURL + path + } + return path +} + +func newDeployToken() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return fmt.Sprintf("%x", buf), nil +} + +func runCmd(name string, args ...string) error { + _, err := runCmdOut(name, args...) + return err +} + +func runCmdOut(name string, args ...string) (string, error) { + out, err := exec.Command(name, args...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("command failed: %s %s: %w output=%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out))) + } + return string(out), nil +} diff --git a/internal/app/workspace_service.go b/internal/app/workspace_service.go new file mode 100644 index 0000000..f55c420 --- /dev/null +++ b/internal/app/workspace_service.go @@ -0,0 +1,51 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "strings" +) + +func (a *App) listWorkspaces(ctx context.Context, userID string) ([]Workspace, error) { + rows, err := a.db.QueryContext(ctx, `SELECT id, user_id, name, unix_user FROM workspaces WHERE user_id=$1 ORDER BY name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Workspace + for rows.Next() { + var w Workspace + if err := rows.Scan(&w.ID, &w.UserID, &w.Name, &w.UnixUser); err != nil { + return nil, err + } + out = append(out, w) + } + return out, rows.Err() +} + +func (a *App) createWorkspace(ctx context.Context, userID, name, unixUser string) (Workspace, error) { + var w Workspace + err := a.db.QueryRowContext(ctx, `INSERT INTO workspaces (user_id, name, unix_user) VALUES ($1,$2,$3) RETURNING id, user_id, name, unix_user`, userID, name, unixUser). + Scan(&w.ID, &w.UserID, &w.Name, &w.UnixUser) + if err != nil { + if strings.Contains(err.Error(), "duplicate key") { + return Workspace{}, errors.New("workspace name or unix_user already exists") + } + return Workspace{}, err + } + return w, nil +} + +func (a *App) getWorkspace(ctx context.Context, userID string, workspaceID int64) (Workspace, error) { + var w Workspace + err := a.db.QueryRowContext(ctx, `SELECT id, user_id, name, unix_user FROM workspaces WHERE id=$1 AND user_id=$2`, workspaceID, userID). + Scan(&w.ID, &w.UserID, &w.Name, &w.UnixUser) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Workspace{}, errors.New("workspace not found") + } + return Workspace{}, err + } + return w, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c6cc4fb --- /dev/null +++ b/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "log" + + "box/internal/app" +) + +func main() { + if err := app.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..50cc317 --- /dev/null +++ b/web/app.js @@ -0,0 +1,202 @@ +async function api(path, options = {}) { + const res = await fetch(path, { + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, + ...options + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +const state = { projects: [], workspaces: [] }; + +async function init() { + const me = await api('/api/me'); + document.getElementById('me').textContent = `${me.username} (${me.email || 'no email'})`; + + document.getElementById('workspaceForm').addEventListener('submit', createWorkspace); + document.getElementById('createForm').addEventListener('submit', createProject); + await refreshWorkspaces(); + await refreshProjects(); +} + +async function refreshWorkspaces() { + state.workspaces = await api('/api/workspaces'); + const select = document.getElementById('workspaceSelect'); + if (state.workspaces.length === 0) { + select.innerHTML = ''; + return; + } + select.innerHTML = state.workspaces.map((w) => ( + `` + )).join(''); +} + +async function refreshProjects() { + state.projects = await api('/api/projects'); + renderProjects(); +} + +function renderProjects() { + const root = document.getElementById('projects'); + if (state.projects.length === 0) { + root.innerHTML = '

No projects yet.

'; + return; + } + + root.innerHTML = ''; + state.projects.forEach((p) => { + const div = document.createElement('article'); + div.className = 'project'; + div.innerHTML = ` +

${escapeHtml(p.name)}

+

workspace: ${escapeHtml(p.workspace)} (${escapeHtml(p.unix_user)})

+

slug: ${escapeHtml(p.slug)} | service: ${escapeHtml(p.service_name)}

+

app target: 127.0.0.1:${escapeHtml(p.target_port)} | route host: ${escapeHtml(p.route_host || 'not set')}

+

deploy webhook: ${escapeHtml(p.webhook_url || 'n/a')}

+

${escapeHtml(p.description || '')}

+ ${p.repo_url ? `

Open Repository

` : '

No repository URL set.

'} +
+ + + + +
+
Loading env vars...
+
+ + +
+
status: loading...
+
logs: not loaded
+ `; + + div.querySelector('[data-add]').addEventListener('click', async () => { + const key = div.querySelector('[data-k]').value.trim(); + const value = div.querySelector('[data-v]').value; + await api(`/api/projects/${p.id}/env`, { + method: 'POST', + body: JSON.stringify({ key, value }) + }); + await loadEnvList(div, p.id); + }); + + div.querySelector('[data-regen]').addEventListener('click', async () => { + await api(`/api/projects/${p.id}/service`, { method: 'POST' }); + alert('Service regenerated and systemctl --user reload attempted.'); + }); + div.querySelector('[data-status]').addEventListener('click', async () => { + await loadStatus(div, p.id); + }); + div.querySelector('[data-logs]').addEventListener('click', async () => { + await loadLogs(div, p.id); + }); + + root.appendChild(div); + loadEnvList(div, p.id); + loadStatus(div, p.id); + }); +} + +async function loadEnvList(projectNode, projectID) { + const envs = await api(`/api/projects/${projectID}/env`); + const envRoot = projectNode.querySelector('[data-env]'); + if (envs.length === 0) { + envRoot.innerHTML = 'No env vars'; + return; + } + envRoot.innerHTML = envs.map((e) => { + return `
${escapeHtml(e.key)}=${escapeHtml(e.value)}
`; + }).join(''); + + envRoot.querySelectorAll('[data-del]').forEach((btn) => { + btn.addEventListener('click', async () => { + const key = btn.getAttribute('data-del'); + await api(`/api/projects/${projectID}/env?key=${encodeURIComponent(key)}`, { method: 'DELETE' }); + await loadEnvList(projectNode, projectID); + }); + }); +} + +async function loadStatus(projectNode, projectID) { + const out = projectNode.querySelector('[data-status-out]'); + out.textContent = 'status: loading...'; + try { + const s = await api(`/api/projects/${projectID}/status`); + out.textContent = `status: ${s.active_state}/${s.sub_state} pid=${s.main_pid} result=${s.result || 'n/a'} exit=${s.exec_status}`; + } catch (err) { + out.textContent = `status error: ${err.message}`; + } +} + +async function loadLogs(projectNode, projectID) { + const out = projectNode.querySelector('[data-logs-out]'); + out.textContent = 'loading logs...'; + try { + const res = await api(`/api/projects/${projectID}/logs?lines=200`); + out.textContent = res.logs || '(no logs)'; + } catch (err) { + out.textContent = `logs error: ${err.message}`; + } +} + +async function createProject(e) { + e.preventDefault(); + const form = e.target; + const payload = { + workspace_id: Number(form.workspace_id.value), + name: form.name.value, + description: form.description.value, + repo_url: form.repo_url.value.trim(), + repo_private: form.repo_private.checked, + target_port: Number(form.target_port.value || 8000) + }; + const msg = document.getElementById('createMsg'); + msg.textContent = 'Creating project...'; + try { + await api('/api/projects', { + method: 'POST', + body: JSON.stringify(payload) + }); + msg.textContent = 'Project created.'; + form.reset(); + form.repo_private.checked = true; + await refreshProjects(); + } catch (err) { + msg.textContent = `Error: ${err.message}`; + } +} + +async function createWorkspace(e) { + e.preventDefault(); + const form = e.target; + const payload = { + name: form.name.value.trim(), + unix_user: form.unix_user.value.trim() + }; + const msg = document.getElementById('workspaceMsg'); + msg.textContent = 'Creating workspace...'; + try { + await api('/api/workspaces', { + method: 'POST', + body: JSON.stringify(payload) + }); + msg.textContent = 'Workspace created.'; + form.reset(); + await refreshWorkspaces(); + } catch (err) { + msg.textContent = `Error: ${err.message}`; + } +} + +function escapeHtml(s) { + return String(s) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +init().catch((err) => { + document.getElementById('projects').innerHTML = `

Failed to load: ${escapeHtml(err.message)}

`; +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..362d778 --- /dev/null +++ b/web/index.html @@ -0,0 +1,51 @@ + + + + + + Project Box + + + + + + +
+
+

Project Box

+

Create projects, provision Forgejo repositories, and keep service env vars in one place.

+
+
+ +
+

Workspaces

+
+ + + +
+

+
+ +
+

Create Project

+
+ + + + + + + +
+

+
+ +
+

Your Projects

+
+
+
+ + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..bdcdd1f --- /dev/null +++ b/web/styles.css @@ -0,0 +1,97 @@ +:root { + --bg: radial-gradient(circle at 10% 20%, #223 0%, #10131d 35%, #080a10 100%); + --panel: rgba(255, 255, 255, 0.08); + --panel-border: rgba(255, 255, 255, 0.2); + --text: #ecf0ff; + --muted: #b8bfd8; + --accent: #5ee8b5; + --danger: #ff7f8f; +} + +* { box-sizing: border-box; } +body { + margin: 0; + min-height: 100vh; + font-family: Manrope, sans-serif; + background: var(--bg); + color: var(--text); +} + +.app { + width: min(980px, 92vw); + margin: 2rem auto; + display: grid; + gap: 1rem; +} + +.hero h1 { + font-family: "Space Grotesk", sans-serif; + font-size: clamp(2rem, 5vw, 3rem); + margin: 0; +} +.hero p { color: var(--muted); } + +.card { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 16px; + padding: 1rem; + backdrop-filter: blur(8px); +} + +.pill { + display: inline-block; + margin-top: .5rem; + padding: .35rem .7rem; + border-radius: 999px; + border: 1px solid var(--panel-border); + color: var(--muted); +} + +form { + display: grid; + gap: .8rem; +} + +label { display: grid; gap: .4rem; font-weight: 500; } +.inline { display: flex; align-items: center; gap: .5rem; } +input, textarea, button { + font: inherit; + border-radius: 10px; + border: 1px solid var(--panel-border); + background: rgba(0,0,0,.25); + color: var(--text); + padding: .65rem .75rem; +} +textarea { min-height: 80px; } +button { + background: linear-gradient(120deg, #4bcf9e, #63a8ff); + color: #07111f; + border: 0; + font-weight: 700; + cursor: pointer; +} + +.projects { display: grid; gap: .8rem; } +.project { + border: 1px solid var(--panel-border); + border-radius: 12px; + padding: .8rem; + background: rgba(255, 255, 255, 0.04); +} +.project h3 { margin: 0 0 .4rem; font-family: "Space Grotesk", sans-serif; } +.meta { color: var(--muted); font-size: .9rem; } +.env-row { + display: grid; + grid-template-columns: 1fr 1fr auto auto; + gap: .5rem; + margin-top: .6rem; +} +.small { padding: .45rem .55rem; font-size: .88rem; } +.muted { color: var(--muted); } +.link { color: var(--accent); text-decoration: none; } +.danger { background: var(--danger); color: #200; } + +@media (max-width: 720px) { + .env-row { grid-template-columns: 1fr; } +}