Integrations
Git providers, outbound webhooks, framework presets.
Files: internal/gitproviders/*, internal/webhooks/*, internal/frameworks/frameworks.go.
internal/gitproviders/provider.go
Shared abstractions for the git-provider subsystem.
NewRegistry(ps...)— Indexes by ID and by hostname.(*Registry) Get(id) (Provider, bool).(*Registry) ForRepoURL(repoURL) (Provider, bool)— Hostname lookup.(*Registry) Configured() []Provider— Only providers whoseConfigured()returns true.(*Registry) All() []Provider.
Types
Profile—Login,Email,Name,AvatarURL.Repo—Name,FullName,HTMLURL,Private,Fork,Archived,DefaultBranch,Language,Provider,OwnerLogin,OwnerAvatar.Provider(interface) —ID,Label,Configured,Hosts,AuthURL,SignInScopes,LinkScopes,ExchangeCode,FetchProfile,ListRepos,RegisterWebhook,DeleteWebhook,VerifyWebhook,AuthenticatedCloneURL.
internal/gitproviders/github.go
GitHub { ClientID, ClientSecret }. Hosts github.com. OAuth + REST API v3 + HMAC-SHA256 (X-Hub-Signature-256).
ID()returns"github".Label()returns"GitHub".Configured()— both creds set.Hosts()returns[github.com].SignInScopes()returnsread:user user:email.LinkScopes()returnsrepo read:user user:email.AuthURL(redirect, state, scopes)—allow_signup=true.ExchangeCode(ctx, code, redirect)— POST/login/oauth/access_token.FetchProfile(ctx, token)— GET/user+/user/emails; primary verified email.ListRepos(ctx, token, query, page)— GET/user/repos(50/page); filters archived; client-side substring search.RegisterWebhook(...)— POST/repos/{o}/{r}/hooks(push, HMAC).DeleteWebhook(...)— DELETE/repos/{o}/{r}/hooks/{id}.VerifyWebhook(headers, secret, body)—X-Hub-Signature-256HMAC; returnsX-GitHub-Event.AuthenticatedCloneURL(repoURL, token)—x-access-tokenuser.
Org-scoped fetch + revoke
ListReposScoped(ctx, token, query, org, page) ([]Repo, error)— Whenorgis empty hitsGET /user/repos?per_page=100; otherwise switches toGET /orgs/{org}/repos?per_page=100. Used by the deploy dialog's org picker so private org repos surface only when the user explicitly scopes to that org.ListReposnow delegates here withorg="".ListOrgs(ctx, token) ([]Org, error)—GET /user/orgs?per_page=100; returns{Login, AvatarURL, Description}. Orgs that have OAuth-app access restrictions enabled without approving WiseHosting are missing from this list, by design — the SPA shows a "request access" link in that case.RevokeGrant(ctx, token) error—DELETE https://api.github.com/applications/{client_id}/grantwith HTTP Basic auth (client_id:client_secret). Called from the disconnect path so the next OAuth flow re-runs the consent screen — without this, GitHub silently re-authorizes the same scopes and the user can never grant new permissions through the dashboard's Re-auth button. Local token deletion proceeds even if the revoke call fails.
internal/gitproviders/gitlab.go
GitLab { ClientID, ClientSecret }. Hosts gitlab.com. Webhooks use shared-token header (X-Gitlab-Token) rather than HMAC.
- Identity / scopes / auth as above (sign-in:
read_user openid email profile; link:api read_repository write_repository). AuthURL—gitlab.com/oauth/authorize?response_type=code.ExchangeCode— POST/oauth/tokengrant_type=authorization_code.FetchProfile— GET/api/v4/user; errorsno_verified_primary_emailif blank.ListRepos— GET/api/v4/projectsmembership=trueorderedlast_activity_at; server-side search; archived filter.RegisterWebhook— POST/api/v4/projects/{id}/hooks(push_events=true, SSL-verified token).DeleteWebhook— DELETE/api/v4/projects/{id}/hooks/{hookID}.VerifyWebhook— Token equality; normalises"Push Hook"to"push".AuthenticatedCloneURL—oauth2user.
internal/gitproviders/bitbucket.go
Bitbucket { ClientID, ClientSecret }. Hosts bitbucket.org. Basic-auth token exchange. UUID-typed hook IDs are converted to int64 via HMAC hash.
- Sign-in:
account email. Link:account email repository repository:admin webhook. ExchangeCode— POST/site/oauth2/access_token(Basic auth).FetchProfile— GET/2.0/user+/user/emails; primary confirmed.ListRepos— GET/2.0/repositories?role=membersorted-updated_on; server-sidename~"q"filter.RegisterWebhook— POST/2.0/repositories/{o}/{r}/hooks(repo:push); HMAC-derived int64 hook ID.DeleteWebhook— currently a no-op (returns nil).VerifyWebhook—X-Hub-Signaturesha256=...HMAC; normalises"repo:push"to"push".AuthenticatedCloneURL—x-token-authuser.
internal/gitproviders/codeberg.go
Codeberg { ClientID, ClientSecret }. Hosts codeberg.org. Forgejo/Gitea-compatible API. Auth header is Authorization: token ... (not Bearer).
- Sign-in:
read:user. Link:read:user write:repository. ExchangeCode— POST JSON to/login/oauth/access_token.FetchProfile— GET/api/v1/user;no_verified_primary_emailwhen blank.ListRepos— GET/api/v1/user/repos; client-side substring filter; archived filter.RegisterWebhook— POST JSON/api/v1/repos/{o}/{r}/hooks(type:gitea,push).DeleteWebhook— DELETE/api/v1/repos/{o}/{r}/hooks/{id}.VerifyWebhook—X-Gitea-SignatureHMAC-SHA256; returnsX-Gitea-Event.AuthenticatedCloneURL—oauth2user.
internal/webhooks/dispatcher.go
Async outbound webhook dispatcher: 256-event buffered queue, configurable worker pool, retry back-off [0s, 1s, 5s, 30s], wildcard subscriptions (*, deployment.*). Each subscriber's URL determines the delivery format:
https://...— HMAC-SHA256-signed JSON body, full event payload.- Shoutrrr scheme (
discord://,slack://,ntfy://, …) — Compact{emoji} {title} — {app}text viaformatShoutrrrMessage.
The HTTPS path uses httpx.NewWebhookClient (see internal/httpx/tls.go), which resolves DNS up-front and rejects private/loopback/reserved IPs before connecting — basic SSRF guard so user-configured webhook URLs can't be aimed at metadata endpoints or internal services.
New(db) *Dispatcher— 256 buffer, 10 s HTTP client (httpx.NewWebhookClient).(*Dispatcher) Start(workers)— Spawns at least 2 goroutines.(*Dispatcher) Stop()— Cancels ctx.(*Dispatcher) Publish(e Event)— Validates againstAllEvents(); non-blocking enqueue (drops on full).(*Dispatcher) TestDeliver(h Webhook) WebhookDelivery— Single-shot synchronous send used by the Send test button. Branches HTTPS vs Shoutrrr the same way as the queue path, no retries, returns the resulting delivery row so the caller can persist it and update the webhook'slast_*counters.(*Dispatcher) sendHTTPS(h, ev)/(*Dispatcher) sendShoutrrr(h, ev)— Internal helpers; both delivery paths split out soTestDelivercan reuse them without re-implementing signing or formatting.isShoutrrrURL(url) bool— Branches the delivery path.
internal/httpx/tls.go
Hardened HTTP client constructors shared by every outbound caller in the control plane.
NewSecureClient(timeout) *http.Client— TLS 1.3 minimum, sane handshake/idle timeouts. Used by git-provider OAuth + REST calls.NewWebhookClient(timeout) *http.Client— Same TLS posture plus aDialContextthat resolves the target to IPs and refuses to dial any address inside the loopback / link-local / private (RFC 1918) / unique-local / multicast ranges. Returns the opaque error "webhook URL resolves to a private or reserved IP address" so attackers can't probe internal topology by URL.NewSecureTLSConfig() *tls.Config— TLS 1.3 minimum config used standalone (e.g. by the WSS hub).isPrivateIP(ip) bool— Internal helper backing the SSRF guard.
Types
Publisher—Publish(e Event)interface.Event—Name,UserID(JSON-omitted),AppID*,AppUUID,AppName,Subdomain,Data map,Timestamp.Dispatcher— DB, secure HTTP client, queue, ctx.
internal/webhooks/events.go
Single source of truth for event names.
AllEvents() []string.IsValidEvent(e) bool.
| Constant | Value |
|---|---|
EventDeploymentSucceeded | deployment.succeeded |
EventDeploymentFailed | deployment.failed |
EventAppCreated | app.created |
EventAppRestarted | app.restarted |
EventAppStopped | app.stopped |
EventAppDeleted | app.deleted |
EventAppViolation | app.violation |
EventAppOffline | app.offline |
EventAppCPU | app.cpu |
EventAppMemory | app.memory |
EventAppNetwork | app.network |
EventAppDisk | app.disk |
EventAppCrashloop | app.crashloop |
EventAccountLoginNew | account.login_new_device |
EventTest | test |
internal/frameworks/frameworks.go
Static registry of deployment framework presets + Dockerfile generation.
All() []Preset.Get(key) (Preset, bool).Default() Preset—dockerfile.MergeWithOverrides(p, install, build, start)— Non-blank overrides only.GenerateDockerfile(p) string— Empty for thedockerfilepreset (user-supplied).
Type
Preset { Key, Label, Description, Runtime, Icon, BaseImage, InstallCommand, BuildCommand, StartCommand, UsesDockerfile }.
| Key | Base image | Notes |
|---|---|---|
dockerfile | (user-supplied) | Default; no auto-detection |
nodejs | node:20-alpine | npm start, no build step |
nextjs | node:20-alpine | Install + npm run build |
vite | node:20-alpine to nginx | Multi-stage; output from dist/ |
go | golang:1.23-alpine to distroless | Static binary (CGO_ENABLED=0) |
python | python:3.12-slim | pip install -r requirements.txt |
static | nginx (inline) | Copies public/ |