This commit is contained in:
pavel 2026-05-14 19:21:15 +02:00
commit b38c39030b
22 changed files with 1925 additions and 0 deletions

18
.env.example Normal file
View file

@ -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=

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.env
bin/
dist/
*.log
.DS_Store
/tmp/
box

112
README.md Normal file
View file

@ -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 `<slug>.<suffix>` (if empty: `<user>-<slug>.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/<CADDY_SERVER_ID>/routes`
Route shape:
- `match.host = [<project host>]`
- `handle[0].handler = reverse_proxy`
- `handle[0].upstreams[0].dial = 127.0.0.1:<target_port>`
## 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>.service`
- `~/.config/project-manager/<app-user>/<project>/service.env`
- The app attempts to run user-systemd commands as that Unix user via:
- `sudo -n -u <unix_user> 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 `<PROJECTS_ROOT>/<app-user>/<workspace>/<project>/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 <service>`.
- Logs are read via `journalctl --user -u <service>`.
- Both commands are executed as the workspace Unix user via `sudo -n -u <unix_user> ...`.
## Systemd
For each project in workspace `<ws>` owned by unix user `<unix_user>`, the app writes:
- env file: `<PROJECTS_ROOT>/<app-user>/<workspace>/<project>/.env`
- unit file: `<home-of-unix-user>/.config/systemd/user/projectmgr-<app-user>-<project>.service`
Then it attempts as that unix user:
- `systemctl --user daemon-reload`
- `systemctl --user enable --now <service>`
The generated `.env` is synced from the project env-var configuration and always includes:
- `LISTEN_NETWORK=unix`
- `LISTEN_ADDRESS=<PROJECTS_ROOT>/<app-user>/<workspace>/<project>/app.sock`
If user systemd is not available in the runtime environment, project creation still succeeds and unit files are still generated.

19
go.mod Normal file
View file

@ -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
)

30
go.sum Normal file
View file

@ -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=

63
internal/app/config.go Normal file
View file

@ -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
}

77
internal/app/db.go Normal file
View file

@ -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()
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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})
}

56
internal/app/run.go Normal file
View file

@ -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
}

56
internal/app/server.go Normal file
View file

@ -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
}

View file

@ -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",
)
}

71
internal/app/types.go Normal file
View file

@ -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"`
}

72
internal/app/util.go Normal file
View file

@ -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
}

View file

@ -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
}

13
main.go Normal file
View file

@ -0,0 +1,13 @@
package main
import (
"log"
"box/internal/app"
)
func main() {
if err := app.Run(); err != nil {
log.Fatal(err)
}
}

202
web/app.js Normal file
View file

@ -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 = '<option value="">Create a workspace first</option>';
return;
}
select.innerHTML = state.workspaces.map((w) => (
`<option value="${w.id}">${escapeHtml(w.name)} (${escapeHtml(w.unix_user)})</option>`
)).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 = '<p class="muted">No projects yet.</p>';
return;
}
root.innerHTML = '';
state.projects.forEach((p) => {
const div = document.createElement('article');
div.className = 'project';
div.innerHTML = `
<h3>${escapeHtml(p.name)}</h3>
<p class="meta">workspace: ${escapeHtml(p.workspace)} (${escapeHtml(p.unix_user)})</p>
<p class="meta">slug: ${escapeHtml(p.slug)} | service: ${escapeHtml(p.service_name)}</p>
<p class="meta">app target: 127.0.0.1:${escapeHtml(p.target_port)} | route host: ${escapeHtml(p.route_host || 'not set')}</p>
<p class="meta">deploy webhook: <code>${escapeHtml(p.webhook_url || 'n/a')}</code></p>
<p>${escapeHtml(p.description || '')}</p>
${p.repo_url ? `<p><a class="link" href="${p.repo_url}" target="_blank" rel="noreferrer">Open Repository</a></p>` : '<p class="muted">No repository URL set.</p>'}
<div class="env-row">
<input class="small" placeholder="ENV_KEY" data-k />
<input class="small" placeholder="value" data-v />
<button class="small" data-add>Add/Update Env</button>
<button class="small" data-regen>Regenerate Service</button>
</div>
<div class="env-list muted" data-env>Loading env vars...</div>
<div class="env-row">
<button class="small" data-status>Refresh Status</button>
<button class="small" data-logs>Load Logs</button>
</div>
<div class="meta" data-status-out>status: loading...</div>
<pre class="meta" data-logs-out style="white-space: pre-wrap; max-height: 220px; overflow: auto;">logs: not loaded</pre>
`;
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 `<div><code>${escapeHtml(e.key)}</code>=<code>${escapeHtml(e.value)}</code> <button class="small danger" data-del="${escapeHtml(e.key)}">Delete</button></div>`;
}).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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
init().catch((err) => {
document.getElementById('projects').innerHTML = `<p class="muted">Failed to load: ${escapeHtml(err.message)}</p>`;
});

51
web/index.html Normal file
View file

@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Project Box</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Manrope:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<main class="app">
<header class="hero">
<h1>Project Box</h1>
<p>Create projects, provision Forgejo repositories, and keep service env vars in one place.</p>
<div id="me" class="pill"></div>
</header>
<section class="card create-card">
<h2>Workspaces</h2>
<form id="workspaceForm">
<label>Workspace Name<input name="name" required placeholder="client-a" /></label>
<label>Unix User<input name="unix_user" required placeholder="pavel" /></label>
<button type="submit">Create Workspace</button>
</form>
<p id="workspaceMsg" class="muted"></p>
</section>
<section class="card create-card">
<h2>Create Project</h2>
<form id="createForm">
<label>Workspace<select name="workspace_id" id="workspaceSelect" required></select></label>
<label>Name<input name="name" required placeholder="analytics-api" /></label>
<label>Description<textarea name="description" placeholder="optional"></textarea></label>
<label>Existing Repo URL (optional)<input name="repo_url" placeholder="https://forgejo.example.com/org/repo" /></label>
<label>Target Port<input name="target_port" type="number" min="1" max="65535" value="8000" required /></label>
<label class="inline"><input type="checkbox" name="repo_private" checked />Private Forgejo repo</label>
<button type="submit">Create Project</button>
</form>
<p id="createMsg" class="muted"></p>
</section>
<section class="card">
<h2>Your Projects</h2>
<div id="projects" class="projects"></div>
</section>
</main>
<script src="/app.js" defer></script>
</body>
</html>

97
web/styles.css Normal file
View file

@ -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; }
}