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

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
}