mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
* refactor(db): port operations, settings, audit, activity, and misc service metadata to sqlc Covers the cross-cutting surface: operations board reads, app settings, notification producers, activity/audit inserts, builds upsert/cancel, git clone credential resolution, invites, environments, registry auth, project/service variables, HA resolver, boot sweeps, and admin user ops. Dynamic filtered lists and Better Auth table probes stay raw by design. Authored By: TDvorak <[email protected]> * refactor(db): port apwhy gateway handlers and proxy data plane to sqlc Final tranche — every static call site now uses generated queries. Raw SQL remains only where the statement is assembled at runtime (filtered lists, sparse updates, admin count closure) or probes Better Auth's auth_users table, which lives outside the local schema. Authored By: TDvorak <[email protected]>
367 lines
11 KiB
Go
367 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"containr/internal/database"
|
|
"containr/internal/database/sqlcdb"
|
|
"containr/internal/docker"
|
|
|
|
"github.com/docker/docker/api/types/network"
|
|
"github.com/docker/docker/api/types/registry"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Platform setting keys. Values stored in app_settings take precedence over
|
|
// environment variables; env remains the fallback for optional overrides.
|
|
const (
|
|
settingSignupEnabled = "signup_enabled"
|
|
settingCloudflareTunnelToken = "cloudflare_tunnel_token"
|
|
|
|
// White-label branding keys — unset fields fall back to Containr
|
|
// defaults. Self-hosters rebrand their instance via these.
|
|
settingBrandProductName = "branding.product_name"
|
|
settingBrandLogoURL = "branding.logo_url"
|
|
settingBrandFaviconURL = "branding.favicon_url"
|
|
settingBrandAccent = "branding.accent_color"
|
|
settingBrandDocsURL = "branding.docs_url"
|
|
settingBrandSupportURL = "branding.support_url"
|
|
|
|
cloudflaredContainerName = "containr-cloudflared"
|
|
cloudflaredImage = "cloudflare/cloudflared:latest"
|
|
)
|
|
|
|
// brandingConfig is the public white-label payload. Fields are always
|
|
// populated — defaults keep the instance Containr-branded.
|
|
type brandingConfig struct {
|
|
ProductName string `json:"product_name"`
|
|
LogoURL string `json:"logo_url"`
|
|
FaviconURL string `json:"favicon_url"`
|
|
AccentColor string `json:"accent_color"`
|
|
DocsURL string `json:"docs_url"`
|
|
SupportURL string `json:"support_url"`
|
|
}
|
|
|
|
func resolveBranding(db *database.DB) brandingConfig {
|
|
return brandingConfig{
|
|
ProductName: settingValue(db, settingBrandProductName, "BRAND_NAME", "Containr"),
|
|
LogoURL: settingValue(db, settingBrandLogoURL, "BRAND_LOGO_URL", ""),
|
|
FaviconURL: settingValue(db, settingBrandFaviconURL, "BRAND_FAVICON_URL", ""),
|
|
AccentColor: settingValue(db, settingBrandAccent, "BRAND_ACCENT_COLOR", ""),
|
|
DocsURL: settingValue(db, settingBrandDocsURL, "BRAND_DOCS_URL", ""),
|
|
SupportURL: settingValue(db, settingBrandSupportURL, "BRAND_SUPPORT_URL", ""),
|
|
}
|
|
}
|
|
|
|
// GET /branding — public so the login page and shell can brand themselves
|
|
// before a session exists.
|
|
func handleGetBranding(c *gin.Context) {
|
|
db, _ := c.Get("db")
|
|
database, _ := db.(*database.DB)
|
|
c.JSON(http.StatusOK, resolveBranding(database))
|
|
}
|
|
|
|
// settingValue resolves a setting: app_settings row first, env fallback,
|
|
// then the caller's default.
|
|
func settingValue(db *database.DB, key, envKey, fallback string) string {
|
|
if db != nil && db.DB != nil {
|
|
value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), key)
|
|
if err == nil {
|
|
return value
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
log.Printf("Failed to read setting %s: %v", key, err)
|
|
}
|
|
}
|
|
if envKey != "" {
|
|
if value := strings.TrimSpace(os.Getenv(envKey)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func setSetting(db *database.DB, key, value string, secret bool) error {
|
|
return sqlcdb.New(db.DB).UpsertAppSetting(context.Background(), sqlcdb.UpsertAppSettingParams{
|
|
Key: key,
|
|
Value: value,
|
|
IsSecret: secret,
|
|
})
|
|
}
|
|
|
|
func deleteSetting(db *database.DB, key string) error {
|
|
return sqlcdb.New(db.DB).DeleteAppSetting(context.Background(), key)
|
|
}
|
|
|
|
// signupEnabled controls whether public registration stays open after the
|
|
// first account exists. Defaults to closed; the platform owner can reopen it
|
|
// in Settings or via SIGNUP_ENABLED=true.
|
|
func signupEnabled(db *database.DB) bool {
|
|
return strings.EqualFold(settingValue(db, settingSignupEnabled, "SIGNUP_ENABLED", "false"), "true")
|
|
}
|
|
|
|
func isAdminUser(db *database.DB, userID string) bool {
|
|
parsed, err := uuid.Parse(userID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
isAdmin, err := sqlcdb.New(db.DB).GetUserIsAdmin(context.Background(), parsed)
|
|
return err == nil && isAdmin
|
|
}
|
|
|
|
func requireAdmin(c *gin.Context) (*database.DB, bool) {
|
|
userID, ok := requireAuthenticatedUserID(c)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
db, _ := c.Get("db")
|
|
database, _ := db.(*database.DB)
|
|
if database == nil || database.DB == nil || !isAdminUser(database, userID) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
|
return nil, false
|
|
}
|
|
return database, true
|
|
}
|
|
|
|
type cloudflareTunnelStatus struct {
|
|
TokenSet bool `json:"token_set"`
|
|
Source string `json:"source"` // "app" | "env" | "none"
|
|
Container string `json:"container"`
|
|
}
|
|
|
|
func handleGetSettings(c *gin.Context) {
|
|
db, ok := requireAdmin(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
dockerClient, _ := c.Get("docker_client")
|
|
client, _ := dockerClient.(*docker.Client)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"signup_enabled": signupEnabled(db),
|
|
"cloudflare_tunnel": cloudflareTunnelState(c.Request.Context(), db, client),
|
|
"branding": resolveBranding(db),
|
|
})
|
|
}
|
|
|
|
func handleUpdateSettings(c *gin.Context) {
|
|
db, ok := requireAdmin(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
SignupEnabled *bool `json:"signup_enabled"`
|
|
CloudflareTunnelToken *string `json:"cloudflare_tunnel_token"`
|
|
Branding *struct {
|
|
ProductName *string `json:"product_name"`
|
|
LogoURL *string `json:"logo_url"`
|
|
FaviconURL *string `json:"favicon_url"`
|
|
AccentColor *string `json:"accent_color"`
|
|
DocsURL *string `json:"docs_url"`
|
|
SupportURL *string `json:"support_url"`
|
|
} `json:"branding"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.SignupEnabled != nil {
|
|
value := "false"
|
|
if *req.SignupEnabled {
|
|
value = "true"
|
|
}
|
|
if err := setSetting(db, settingSignupEnabled, value, false); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update signup setting"})
|
|
return
|
|
}
|
|
}
|
|
|
|
if req.CloudflareTunnelToken != nil {
|
|
token := strings.TrimSpace(*req.CloudflareTunnelToken)
|
|
if token == "" {
|
|
if err := deleteSetting(db, settingCloudflareTunnelToken); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear tunnel token"})
|
|
return
|
|
}
|
|
} else if err := setSetting(db, settingCloudflareTunnelToken, token, true); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save tunnel token"})
|
|
return
|
|
}
|
|
|
|
if dockerClient, exists := c.Get("docker_client"); exists {
|
|
if client, ok := dockerClient.(*docker.Client); ok && client != nil {
|
|
go applyCloudflaredTunnel(client, token)
|
|
}
|
|
}
|
|
}
|
|
|
|
if req.Branding != nil {
|
|
fields := []struct {
|
|
key string
|
|
value *string
|
|
}{
|
|
{settingBrandProductName, req.Branding.ProductName},
|
|
{settingBrandLogoURL, req.Branding.LogoURL},
|
|
{settingBrandFaviconURL, req.Branding.FaviconURL},
|
|
{settingBrandAccent, req.Branding.AccentColor},
|
|
{settingBrandDocsURL, req.Branding.DocsURL},
|
|
{settingBrandSupportURL, req.Branding.SupportURL},
|
|
}
|
|
for _, f := range fields {
|
|
if f.value == nil {
|
|
continue
|
|
}
|
|
v := strings.TrimSpace(*f.value)
|
|
var err error
|
|
if v == "" {
|
|
err = deleteSetting(db, f.key)
|
|
} else {
|
|
err = setSetting(db, f.key, v, false)
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save branding"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
handleGetSettings(c)
|
|
}
|
|
|
|
// resolvedTunnelToken prefers the in-app value and falls back to the env.
|
|
func resolvedTunnelToken(db *database.DB) (string, string) {
|
|
if db != nil && db.DB != nil {
|
|
if value, err := sqlcdb.New(db.DB).GetAppSetting(context.Background(), settingCloudflareTunnelToken); err == nil && strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value), "app"
|
|
}
|
|
}
|
|
if value := strings.TrimSpace(os.Getenv("CLOUDFLARED_TOKEN")); value != "" && value != "your_cloudflare_tunnel_token_here" {
|
|
return value, "env"
|
|
}
|
|
return "", "none"
|
|
}
|
|
|
|
func cloudflareTunnelState(ctx context.Context, db *database.DB, client *docker.Client) cloudflareTunnelStatus {
|
|
token, source := resolvedTunnelToken(db)
|
|
state := cloudflareTunnelStatus{TokenSet: token != "", Source: source, Container: "missing"}
|
|
|
|
if client == nil {
|
|
state.Container = "unavailable"
|
|
return state
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
container, err := findCloudflaredContainer(ctx, client)
|
|
if err != nil || container == "" {
|
|
return state
|
|
}
|
|
if inspect, err := client.GetContainer(ctx, container); err == nil && inspect.State != nil {
|
|
state.Container = inspect.State.Status
|
|
}
|
|
return state
|
|
}
|
|
|
|
// syncManagedContainers reconciles containers the platform manages from
|
|
// settings - currently only the optional Cloudflare Tunnel sidecar.
|
|
func syncManagedContainers(client *docker.Client, db *database.DB) {
|
|
token, _ := resolvedTunnelToken(db)
|
|
if token == "" {
|
|
return
|
|
}
|
|
go applyCloudflaredTunnel(client, token)
|
|
}
|
|
|
|
func findCloudflaredContainer(ctx context.Context, client *docker.Client) (string, error) {
|
|
containers, err := client.ListContainers(ctx, true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, item := range containers {
|
|
for _, name := range item.Names {
|
|
if strings.TrimPrefix(name, "/") == cloudflaredContainerName {
|
|
return item.ID, nil
|
|
}
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// applyCloudflaredTunnel ensures a cloudflared container mirrors the stored
|
|
// token: created/recreated when set, removed when cleared. It joins the same
|
|
// networks as the backend so tunnel targets can use compose service names.
|
|
func applyCloudflaredTunnel(client *docker.Client, token string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
|
|
if existing, err := findCloudflaredContainer(ctx, client); err == nil && existing != "" {
|
|
if err := client.RemoveContainer(ctx, existing, true); err != nil {
|
|
log.Printf("Failed to remove existing cloudflared container: %v", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
if token == "" {
|
|
return
|
|
}
|
|
|
|
if err := client.PullImageWait(ctx, cloudflaredImage, registry.AuthConfig{}); err != nil {
|
|
log.Printf("Failed to pull %s: %v", cloudflaredImage, err)
|
|
}
|
|
|
|
networks, err := backendNetworks(ctx, client)
|
|
if err != nil {
|
|
log.Printf("Failed to resolve backend networks for cloudflared: %v", err)
|
|
return
|
|
}
|
|
|
|
id, err := client.CreateContainer(ctx, docker.ContainerConfig{
|
|
Name: cloudflaredContainerName,
|
|
Image: cloudflaredImage,
|
|
Cmd: []string{"tunnel", "--no-autoupdate", "run", "--token", token},
|
|
RestartPolicy: "unless-stopped",
|
|
Labels: map[string]string{"containr.managed": "cloudflared"},
|
|
Networks: networks,
|
|
})
|
|
if err != nil {
|
|
log.Printf("Failed to create cloudflared container: %v", err)
|
|
return
|
|
}
|
|
if err := client.StartContainer(ctx, id); err != nil {
|
|
log.Printf("Failed to start cloudflared container: %v", err)
|
|
}
|
|
}
|
|
|
|
// backendNetworks returns the networks this backend container sits on, so the
|
|
// managed cloudflared container can reach the frontend/backend by name.
|
|
func backendNetworks(ctx context.Context, client *docker.Client) (map[string]*network.EndpointSettings, error) {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
self, err := client.GetContainer(ctx, hostname)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
networks := map[string]*network.EndpointSettings{}
|
|
for name := range self.NetworkSettings.Networks {
|
|
networks[name] = &network.EndpointSettings{}
|
|
}
|
|
if len(networks) == 0 {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
return networks, nil
|
|
}
|