Data layer
GORM wrapper, model definitions, AES-GCM encryption, naming, config, plans, Redis cache, email.
Files: internal/database/*, internal/models/models.go, internal/config/config.go, internal/plans/plans.go, internal/cache/cache.go, internal/mail/mail.go.
internal/database/gorm_db.go
Thin wrapper around *gorm.DB with an in-memory TTL cache (sync.Map), optional AES-GCM encryption for sensitive fields, and all application-level data-access methods. Establishes the Postgres connection (max 25 open / 5 idle, 5-min lifetime), runs RunMigrations on startup (see migrate.go), exposes ErrConflict and ErrAccountExists.
Connect(dsn) (*GormDB, error)— Opens, pings, configures pool, runsRunMigrations(db). Side effect: DDL applied viagolang-migratefrom the embeddedmigrations/*.sqlset. Refuses to start on adirtyschema_migrationsrow.(db) EnableSecretEncryption(serverSecret) error— Derives AES-GCM key from secret.(db) DecryptJobPayload(stored) string— Public wrapper; returns plaintext or original.ValidEnvKey(k) bool—^[A-Z][A-Z0-9_]{0,63}$.(db) InvalidateUserCache(userID)/InvalidateAppCache(appID)— Cache eviction.(db) ListAppEnvVars(appID)— Decrypts each value.(db) SetAppEnvVar(appID, key, value)— Validates key, encrypts, upserts.(db) DeleteAppEnvVar(appID, key).(db) AppEnvMap(appID)— Decryptedmap[string]string.(db) Close() error.(db) FindUserByID(id).(db) UpsertGoogleUser(googleID, email, name, avatar)— ON CONFLICT ongoogle_id.(db) UpsertGitToken(userID, provider, username, plaintext)— Encrypts + upserts.(db) DeleteGitToken(userID, provider).(db) GetGitToken(userID, provider) (token, username, ok)— Decrypts.(db) ListGitTokens(userID) []LinkedProvider.(db) MarkWorkerOnline(workerID)/ListWorkers()/FindWorkerByID(id).(db) FindOnlineWorkerByAPIKey(rawKey)— HashesrawKeyfirst, queries onapi_key_hash. 30 s cache (keyed by hash). Despite the name, matches workers regardless ofstatusso anofflineworker can still reconnect (see wsproto).(db) ListAppsByWorker(workerID)— Apps currently scheduled on this worker.(db) ListRunningAppsForWorker(workerID)— Running apps on this worker withport > 0. Used by/v1/traefik/configto emit zone-subdomain routers.(db) ListVerifiedDomainsByAppIDs(appIDs []int) (map[int][]string, error)— Batch query returning verified domain hostnames grouped by app ID. Used by/v1/traefik/proxy-configto build custom-domain routes in a single DB round-trip.(db) SaveRuntimeLogs(lines []RuntimeLogLine)— Batch-inserts runtime log lines into theruntime_logstable for persistence across control-plane restarts.(db) LoadRuntimeLogsMulti(appIDs []int, limit int) ([]RuntimeLogLine, error)— Loads recent persisted log lines for multiple apps, ordered by timestamp. Used by thewsLogshandler to replay logs on fresh WebSocket connections.HashAPIKey(rawKey) string/HashAPIKeyBytes(rawKey) []byte— Public helpers exposing the same hash used at rest. Bytes form is the WSS HMAC signing key (recovered server-side fromapi_key_hash, derived independently on the worker side).(db) UpsertWorker(name, ip, rawAPIKey, zone, regionName, capCPU, capMem)— Storessha256(rawAPIKey)asapi_key_hash; raw key never persisted.(db) UpdateWorkerHeartbeat(workerID, status, res).(db) FindAppByUserIDAndName(userID, appName)/FindAppByID(appID)— Cache-first.(db) ListUserApps(userID)— 2-min cache.(db) MarkAppDeleted(appID)/HardDeleteApp(appID)— The hard variant cancels pending/assigned jobs in a tx.(db) MarkAppStopped(appID)/MarkAppViolated(appID, reason).(db) FindDeployment(id)/ListDeployments(appID, limit).(db) ListRecentUserDeployments(userID, limit)— Raw SQL join acrossappsfor non-deleted apps.(db) LatestBuildLogs(appID)— From the most recent deployment.(db) SetDeploymentBuildLogs(deploymentID, workerID, logs)— Verifies worker owns deployment, strips null bytes.(db) FindJobForWorker(jobID, workerID)/NextAssignedJob(workerID)— Decrypts payload.(db) UpdateJobStatus(jobID, workerID, status, message, progress)— Setsstarted_aton transition toprocessing.(db) FindActiveJobForApp(appID)— Most recent pending/assigned/processing.(db) CompleteJob(job, status, result)— Tx: marks job + updates app status + deployment fields.(db) CreateDeploymentWithSpec(...)— Tx: unique app name/subdomain, env vars, deployment, encrypted job payload.(db) SetAppWebhookID(appID, hookID)/UpdateAppSpec(appID, updates)/SetAppAutoDeploy(appID, enabled).(db) FindAppByUUID(uuid).(db) CreateRedeployment(appID, workerID int, payload, commitSHA, branch string)— Tx: deployment + restart job pinned, appdeploying. Commit metadata is persisted on the deployment row up-front so failure paths still carry the right git identity.(db) CreatePinnedJob(jobType, appID, workerID, payload, appStatus).(db) CreateDomain(d)/ListDomainsForUser(userID)/ListDomainsForApp(appID)/FindDomain(id, userID).(db) ListVerifiedDomainsForApp(appID) []string— Hostname-only slice; used bytraefikConfigto renderHost()rules.(db) MarkDomainVerified(id)/MarkDomainCheckFailed(id, reason)/DeleteDomain(id, userID).(db) ListWebhooks(userID)/ListWebhooksForEvent(event)/FindWebhook(id, userID)/FindWebhookByID(id).(db) CreateWebhook(w)/UpdateWebhook(id, userID, updates)/DeleteWebhook(id, userID)— The latter cascadeswebhook_deliveries.(db) RecordWebhookDelivery(d)— Inserts + updates parent counters.(db) ListWebhookDeliveries(webhookID, userID, limit).(db) WebhookStats(userID) (total, active, failed7d, lastAt, err).
Sessions + audit
(db) CreateSession(userID, tokenHash, ip, geo, ua, twoFactor bool) (sessionID, err)— Inserts asessionsrow, recording whether the issue passed through 2FA.(db) FindSessionByTokenHash(hash) (Session, err)— Looks up bysha256(token); honoursrevoked_at.(db) BumpSessionLastSeen(sessionID, ip, ua)— Debounced (caller throttles to once/min). Drives the rolling 30-day TTL — idle sessions expire fromlast_seen_at, notcreated_at.(db) ListSessionsForUser(userID) []Session— Newest first.(db) RevokeSession(id, userID)/(db) RevokeOtherSessions(userID, exceptID).(db) PruneRevokedSessions(maxAge) error— Hard-deletes revoked or 30+ day-old rows. Called every 6 hours frommain.go.(db) RecordAuditEvent(e)— Append-only insert intoaudit_events. Replaces the oldRecordLoginEventafter the table was renamed in migration0005.(db) ListAuditEvents(userID, limit) []AuditEvent.(db) HasAuditEventForIP(userID, ip) bool— Drives theaccount.login_new_devicewebhook (formerlyHasLoginEventForIP).
Audit kinds emitted by the web layer: login, logout, session_revoked, app_deploy, app_restart, app_start, app_stop, app_delete, env_set, env_delete, git_token_revoke, domain_create, domain_verify, domain_delete.
2FA / TOTP
(db) SetTOTPSecret(userID, encSecret)/(db) GetTOTPSecret(userID)— Stores and reads the AES-GCM-encrypted shared secret onusers.(db) EnableTOTP(userID)/(db) DisableTOTP(userID)— Fliptotp_enabled, stamptotp_verified_at, clear backup codes on disable.(db) ReplaceBackupCodes(userID, hashes []string)— Bulk-replace argon2 hashes inbackup_codes; called on first verify and re-generate.(db) ConsumeBackupCode(userID, plaintext) (ok bool, err)— Argon2-compares against unused rows; setsused_aton hit.(db) CreatePendingTOTP(userID, provider, ttl) (token string, err)— Inserts apending_totpsrow keyed by an opaque token (the value of thewh_2fa_pendingcookie).(db) ConsumePendingTOTP(token) (userID, provider, err)— Atomically deletes and returns the row; rejects expired entries.(db) PrunePendingTOTPs() error— Bulk-delete expired challenges. Called every 6 hours frommain.go.
Alerts
(db) EnsureAppAlertRules(appID, userID)— UPSERTs the default rule set if missing.(db) ListAppAlertRules(appID) []AppAlertRule.(db) UpsertAppAlertRule(rule).(db) ListEnabledAlertRules() []AppAlertRule— Used by the threshold poller.(db) ListAppsForRules() []App— Running apps with their owner.(db) CreateAlert(a)/(db) FindActiveAlertBySource(userID, srcType, srcID) (Alert, ok).(db) ResolveAlertsBySource(userID, srcType, srcID).(db) ListAlerts(userID, filter) ([]Alert, AlertStats)— Filter by status/kind/app, returns the headline counters used by the dashboard.(db) UpdateAlertStatus(id, userID, status).
Usage time-series
(db) UpsertUsageSample(s)— Inserts/updates by composite PK(app_id, bucket_start).(db) ListUsageSamples(scope, from, to) []UsageSample— Bounded by 90-day retention.(db) PruneUsageSamples(olderThan time.Time) int64— Daily pruner.
internal/database/migrate.go
Thin wrapper around golang-migrate that lets the binary ship its own SQL.
RunMigrations(db) error— Builds aniofssource frommigrations/*.sql(embedded viaembed.FS), wraps the live*sql.DBingolang-migrate/database/postgres, and callsUp(). No-op when current. Returns a structured error on adirtyschema_migrationsrow so the caller can fail fast.
Schema changes ship as new 000N_<slug>.up.sql / 000N_<slug>.down.sql pairs in internal/database/migrations/. There is no GORM AutoMigrate path anymore — model struct changes that aren't backed by a migration will pass go build but fail at runtime when GORM scans columns.
internal/database/models.go
GORM model definitions with explicit TableName() methods and BeforeCreate/BeforeUpdate hooks on User. See Architecture → Database schema for full column tables.
Models: User, Worker, App, Deployment, Job, AppEnvVar, GitProviderToken, Webhook, WebhookDelivery, Session, AuditEvent, AppAlertRule, Alert, UsageSample, BackupCode, PendingTOTP, Domain, RuntimeLogLine.
Notable column-level details: Worker.APIKeyHash (column api_key_hash, sha256 hex) replaces the former APIKey. Job no longer has MaxRetries / RetryCount (dropped in migration 0004).
- All
(<Model>) TableName() string— Explicit table name. (u *User) BeforeCreate(tx) error— StampsCreatedAt/UpdatedAtif zero.(u *User) BeforeUpdate(tx) error— SetsUpdatedAt = now.
internal/database/naming.go
ValidateAppNameInput(s) error— Length 7-32, charset[a-z0-9-], not in reserved blocklist.NewAppUUID() string— UUID v4 fromcrypto/rand.GenerateAppName() string—adjective-noun-NNNNfrom two 64-word lists viamath/rand/v2.
internal/database/secret.go
AES-256-GCM authenticated encryption for sensitive strings. Keys are derived from api_server.secret via HKDF-SHA256 with a per-purpose info label, so different uses (column AES, OAuth state, JWT signing) get independent keys.
deriveKey(masterSecret, purpose) []byte— HKDF-SHA256, 32-byte output.newSecretCipher(serverSecret) (*secretCipher, error)— Uses purposewisehosting-aes-v1.(s) Encrypt(plain) (stored, err)— Returnsv1:<base64url-no-pad>; empty in → empty out.(s) Decrypt(stored) (plain, err)— Pass-through if thev1:prefix is absent (supports zero-downtime rollout and key rotation).
Type and constructor are package-private; surfaced via GormDB helpers above.
internal/models/models.go
Pure Go structs used for inter-service messaging. No GORM, no DB.
DeployPayload— Build/run parameters.ViolationReport,AppStatusReport.ResourceLimits— CPU/memory/PIDs/disk/bandwidth.JobResult— Container ID, port, logs, commit metadata, error.HeartbeatRequest,WorkerResources,JobStatusUpdate.
internal/config/config.go
Loads YAML from $CONFIG_PATH (default config.yaml); typed accessor methods for every field. See Configuration for the full field catalogue.
Load() (*Config, error)— Reads file, then overlays any systemd-supplied credentials (see below), thenValidate.loadCredential(name) (string, bool)— Reads$CREDENTIALS_DIRECTORY/<name>populated by systemdLoadCredential=. The post-parse overlay wins over file values for:api_server_secret,database_password,google_oauth_client_secret,github_oauth_client_secret,gitlab_oauth_client_secret,bitbucket_oauth_client_secret,codeberg_oauth_client_secret,internal_api_token,redis_password,smtp_password. Letsconfig.yamlship without secrets.(c) Validate() error— Required:database.password,api_server.secret(≥16 chars),container.port_range_*,platform.domain.(c) DatabaseDSN()/APIServerAddr()/APIServerSecret()/APIServerTLS()/APIServerBaseURL().(c) JobPollInterval()(5 s) /JobBuildTimeout()(10 m) /JobContainerStartTimeout()(30 s) /APIRateWindow()(1 m) — All with parse fallbacks.- One accessor per field for WebApp URL, all 5 OAuth providers (Google + 4 git providers), platform, worker, container, security, logging, Redis, SMTP.
internal/plans/plans.go
Static plan registry; runtime overrides via YAML.
| Plan | MaxApps | CPU | Memory | Disk | Bandwidth |
|---|---|---|---|---|---|
free | 1 | 1 | 512 MiB | 1 GiB | 10mbit |
pro | 5 | 2 | 2 GiB | 10 GiB | 100mbit |
business | 0 (unlimited) | 4 | 8 GiB | 50 GiB | unlimited |
For(id) Plan— Returns plan by ID; falls back tofree.All() []Plan—[free, pro, business].Apply(overrides)— Mutates the global registry.
internal/cache/cache.go
Redis-backed JSON cache shared across the control plane for hot-path query results (user lookups, app lookups). Default TTL 5 minutes. The cache is optional — the platform works without Redis, just with higher DB load on repeated lookups.
Type
Cache— Wraps*redis.Clientwith a default TTL.Options—Addr,Password,DB,TTL.
Functions
New(opts Options) (*Cache, error)— Connects, PINGs with 5 s timeout, logs success.(c) Close() error— Closes the Redis connection.(c) Get(key, dst) bool— JSON-unmarshals intodst; returns false on miss or corruption (auto-deletes corrupted entries).(c) Set(key, value, ttl)— JSON-marshals and stores with TTL (falls back to default when ≤ 0).(c) Delete(key)— Single-key eviction.(c) DeletePrefix(prefix)— SCAN + DEL for prefix-based cache invalidation (e.g.user:123:*).(c) DeleteByPattern(pattern)— Glob-pattern variant; delegates toDeletefor literal keys.
internal/mail/mail.go
Transactional email via SMTP. Currently used for new-device login alerts — when recordAuditEvent detects a login from a previously-unseen IP, it sends an email with device/location details and a one-click session revoke link.
Types
Config—Host,Port,Account,Password.Mailer— SMTP config + HMAC secret for revoke tokens.NewDeviceInfo—UserEmail,SessionID,UserID,IP,City,Country,Device,Browser,OS,Time.
Functions
New(cfg Config, hmacSecret string) *Mailer— Constructor.(m) Enabled() bool— True when host, account, and password are all non-empty.(m) SignRevokeToken(userID, sessionID) string— HMAC-SHA256 signeduserID:sessionID:unix:signature. Encoded in the email's "Revoke This Session" link.VerifyRevokeToken(token, secret) (userID, sessionID, error)— Validates HMAC, rejects tokens older than 72 hours.(m) SendNewDeviceAlert(info NewDeviceInfo, baseURL)— Async (goroutine). Renders an HTML email with device info, IP geolocation, and a revoke button linking toGET /api/session/revoke-token?token=.... The revoke endpoint is unauthenticated — the HMAC-signed token is the sole proof of authorization.newDeviceHTML(...)— Inline-CSS HTML template matching the dashboard dark theme.