WiseHosting
Reference

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, runs RunMigrations(db). Side effect: DDL applied via golang-migrate from the embedded migrations/*.sql set. Refuses to start on a dirty schema_migrations row.
  • (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) — Decrypted map[string]string.
  • (db) Close() error.
  • (db) FindUserByID(id).
  • (db) UpsertGoogleUser(googleID, email, name, avatar) — ON CONFLICT on google_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) — Hashes rawKey first, queries on api_key_hash. 30 s cache (keyed by hash). Despite the name, matches workers regardless of status so an offline worker can still reconnect (see wsproto).
  • (db) ListAppsByWorker(workerID) — Apps currently scheduled on this worker.
  • (db) ListRunningAppsForWorker(workerID) — Running apps on this worker with port > 0. Used by /v1/traefik/config to 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-config to build custom-domain routes in a single DB round-trip.
  • (db) SaveRuntimeLogs(lines []RuntimeLogLine) — Batch-inserts runtime log lines into the runtime_logs table 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 the wsLogs handler 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 from api_key_hash, derived independently on the worker side).
  • (db) UpsertWorker(name, ip, rawAPIKey, zone, regionName, capCPU, capMem) — Stores sha256(rawAPIKey) as api_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 across apps for 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) — Sets started_at on transition to processing.
  • (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, app deploying. 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 by traefikConfig to render Host() 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 cascades webhook_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 a sessions row, recording whether the issue passed through 2FA.
  • (db) FindSessionByTokenHash(hash) (Session, err) — Looks up by sha256(token); honours revoked_at.
  • (db) BumpSessionLastSeen(sessionID, ip, ua) — Debounced (caller throttles to once/min). Drives the rolling 30-day TTL — idle sessions expire from last_seen_at, not created_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 from main.go.
  • (db) RecordAuditEvent(e) — Append-only insert into audit_events. Replaces the old RecordLoginEvent after the table was renamed in migration 0005.
  • (db) ListAuditEvents(userID, limit) []AuditEvent.
  • (db) HasAuditEventForIP(userID, ip) bool — Drives the account.login_new_device webhook (formerly HasLoginEventForIP).

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 on users.
  • (db) EnableTOTP(userID) / (db) DisableTOTP(userID) — Flip totp_enabled, stamp totp_verified_at, clear backup codes on disable.
  • (db) ReplaceBackupCodes(userID, hashes []string) — Bulk-replace argon2 hashes in backup_codes; called on first verify and re-generate.
  • (db) ConsumeBackupCode(userID, plaintext) (ok bool, err) — Argon2-compares against unused rows; sets used_at on hit.
  • (db) CreatePendingTOTP(userID, provider, ttl) (token string, err) — Inserts a pending_totps row keyed by an opaque token (the value of the wh_2fa_pending cookie).
  • (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 from main.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 an iofs source from migrations/*.sql (embedded via embed.FS), wraps the live *sql.DB in golang-migrate/database/postgres, and calls Up(). No-op when current. Returns a structured error on a dirty schema_migrations row 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 — Stamps CreatedAt/UpdatedAt if zero.
  • (u *User) BeforeUpdate(tx) error — Sets UpdatedAt = now.

internal/database/naming.go

  • ValidateAppNameInput(s) error — Length 7-32, charset [a-z0-9-], not in reserved blocklist.
  • NewAppUUID() string — UUID v4 from crypto/rand.
  • GenerateAppName() stringadjective-noun-NNNN from two 64-word lists via math/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 purpose wisehosting-aes-v1.
  • (s) Encrypt(plain) (stored, err) — Returns v1:<base64url-no-pad>; empty in → empty out.
  • (s) Decrypt(stored) (plain, err) — Pass-through if the v1: 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), then Validate.
  • loadCredential(name) (string, bool) — Reads $CREDENTIALS_DIRECTORY/<name> populated by systemd LoadCredential=. 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. Lets config.yaml ship 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.

PlanMaxAppsCPUMemoryDiskBandwidth
free11512 MiB1 GiB10mbit
pro522 GiB10 GiB100mbit
business0 (unlimited)48 GiB50 GiBunlimited
  • For(id) Plan — Returns plan by ID; falls back to free.
  • 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.Client with a default TTL.
  • OptionsAddr, 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 into dst; 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 to Delete for 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

  • ConfigHost, Port, Account, Password.
  • Mailer — SMTP config + HMAC secret for revoke tokens.
  • NewDeviceInfoUserEmail, 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 signed userID: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 to GET /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.

On this page