mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat: owner accounts and in-app platform settings
- First registered account becomes admin (users.is_admin, backfilled to earliest account); better-auth mirror path does the same - Public registration closes after bootstrap; admin can reopen it via GET/PUT /api/v1/settings (admin-gated) or SIGNUP_ENABLED env - In-app settings override env vars; secrets stored in app_settings, token values never returned by the API - Saving a Cloudflare Tunnel token pulls and runs a managed cloudflared sidecar on the backend's networks; clearing removes it; startup restores it from saved settings - group_add DOCKER_GID grants the non-root backend user Docker socket access; install.sh probes the in-container socket gid - Settings page gains an owner-only Platform section (registration toggle, tunnel token, link to Cloudflare dashboard); sign-up page stays reachable while registration is open - OpenAPI spec + generated types updated; manual user creation now requires admin Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
c3959d13e2
commit
c16b32f56a
18 files changed
+802
-34
No files matched your search
+12
-1
@@ -120,10 +120,21 @@ TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/
|
|||||||
# Development convenience: true in local dev, false in production
|
# Development convenience: true in local dev, false in production
|
||||||
TRAEFIK_API_INSECURE=true
|
TRAEFIK_API_INSECURE=true
|
||||||
|
|
||||||
# Optional: Cloudflare Tunnel (alternative to domain)
|
# Optional: Cloudflare Tunnel (alternative to domain). Can also be set in-app
|
||||||
|
# under Settings > Platform (in-app value overrides this).
|
||||||
# Get token from: https://dash.cloudflare.com/argotunnel
|
# Get token from: https://dash.cloudflare.com/argotunnel
|
||||||
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
|
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
|
||||||
|
|
||||||
|
# Optional: reopen public registration after the owner account exists.
|
||||||
|
# Manageable in-app under Settings > Platform (in-app value overrides this).
|
||||||
|
# SIGNUP_ENABLED=false
|
||||||
|
|
||||||
|
# Docker socket gid AS SEEN INSIDE containers - lets the backend manage
|
||||||
|
# containers (deployments, cloudflared sidecar). install.sh detects it with a
|
||||||
|
# throwaway container. Modern Docker maps the socket to root: use 0.
|
||||||
|
# Older daemons expose the host docker gid: stat -c %g /var/run/docker.sock
|
||||||
|
DOCKER_GID=0
|
||||||
|
|
||||||
# Optional: Custom Docker Registry
|
# Optional: Custom Docker Registry
|
||||||
# DOCKER_REGISTRY=your-registry.com
|
# DOCKER_REGISTRY=your-registry.com
|
||||||
# DOCKER_USERNAME=your_username
|
# DOCKER_USERNAME=your_username
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ Installs into `./containr`, generates secrets, pulls the published `:latest`
|
|||||||
images, and starts the stack — UI at `http://localhost:3000`, API at
|
images, and starts the stack — UI at `http://localhost:3000`, API at
|
||||||
`http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`.
|
`http://localhost:8082`. Non-interactive; re-runs never overwrite `.env`.
|
||||||
On first visit you create the owner account (email/password); registration
|
On first visit you create the owner account (email/password); registration
|
||||||
then closes automatically.
|
then closes automatically. The owner can reopen it and configure a Cloudflare
|
||||||
|
Tunnel token under **Settings → Platform** — in-app values override env vars.
|
||||||
|
|
||||||
Override with env vars:
|
Override with env vars:
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ services:
|
|||||||
- ../../.env.prod
|
- ../../.env.prod
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_GID:-0}"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type User struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AvatarURL string `json:"avatar_url,omitempty"`
|
AvatarURL string `json:"avatar_url,omitempty"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +64,10 @@ func handleLogin(c *gin.Context) {
|
|||||||
var user User
|
var user User
|
||||||
var hashedPassword string
|
var hashedPassword string
|
||||||
err := db.QueryRow(`
|
err := db.QueryRow(`
|
||||||
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
|
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = $1
|
WHERE email = $1
|
||||||
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
||||||
@@ -128,10 +129,11 @@ func handleAuthBootstrap(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"has_users": count > 0,
|
"has_users": count > 0,
|
||||||
"user_count": count,
|
"user_count": count,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"providers": providers,
|
"providers": providers,
|
||||||
|
"signup_enabled": signupEnabled(db),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,24 +284,26 @@ func handleRegister(c *gin.Context) {
|
|||||||
db := c.MustGet("db").(*database.DB)
|
db := c.MustGet("db").(*database.DB)
|
||||||
jwtSecret := c.MustGet("jwt_secret").(string)
|
jwtSecret := c.MustGet("jwt_secret").(string)
|
||||||
|
|
||||||
count, err := countLocalUsers(db)
|
total, err := countLocalUsers(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
// Closed once the first account exists unless the owner reopens registration.
|
||||||
|
if total > 0 && !signupEnabled(db) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count)
|
var existing int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if count > 0 {
|
if existing > 0 {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -311,13 +315,13 @@ func handleRegister(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user
|
// First account owns the platform.
|
||||||
var user User
|
var user User
|
||||||
err = db.QueryRow(`
|
err = db.QueryRow(`
|
||||||
INSERT INTO users (email, password_hash, name)
|
INSERT INTO users (email, password_hash, name, is_admin)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||||
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
`, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||||
@@ -338,13 +342,16 @@ func handleRegister(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleCreateUser(c *gin.Context) {
|
func handleCreateUser(c *gin.Context) {
|
||||||
|
db, ok := requireAdmin(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req ManualUserCreateRequest
|
var req ManualUserCreateRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
db := c.MustGet("db").(*database.DB)
|
|
||||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
if req.Email == "" || req.Name == "" {
|
if req.Email == "" || req.Name == "" {
|
||||||
@@ -477,10 +484,10 @@ func handleGetProfile(c *gin.Context) {
|
|||||||
|
|
||||||
var user User
|
var user User
|
||||||
err := db.QueryRow(`
|
err := db.QueryRow(`
|
||||||
SELECT id, email, name, COALESCE(avatar_url, ''), created_at
|
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *d
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return count > 0
|
// Registration is closed once the first account exists unless the owner
|
||||||
|
// reopens it in Settings (signup_enabled).
|
||||||
|
return count > 0 && !signupEnabled(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
dockerClient = client
|
dockerClient = client
|
||||||
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||||
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
||||||
|
// Restore managed sidecars (e.g. cloudflared) from saved settings.
|
||||||
|
syncManagedContainers(dockerClient, db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize build handler
|
// Initialize build handler
|
||||||
@@ -184,6 +186,10 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
protected.PUT("/user/profile", handleUpdateProfile)
|
protected.PUT("/user/profile", handleUpdateProfile)
|
||||||
protected.POST("/users", handleCreateUser)
|
protected.POST("/users", handleCreateUser)
|
||||||
|
|
||||||
|
// Platform settings (admin-gated inside handlers)
|
||||||
|
protected.GET("/settings", handleGetSettings)
|
||||||
|
protected.PUT("/settings", handleUpdateSettings)
|
||||||
|
|
||||||
// Project routes
|
// Project routes
|
||||||
protected.GET("/projects", handleGetProjects)
|
protected.GET("/projects", handleGetProjects)
|
||||||
protected.POST("/projects", handleCreateProject)
|
protected.POST("/projects", handleCreateProject)
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"containr/internal/database"
|
||||||
|
"containr/internal/docker"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/network"
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
|
||||||
|
cloudflaredContainerName = "containr-cloudflared"
|
||||||
|
cloudflaredImage = "cloudflare/cloudflared:latest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
var value string
|
||||||
|
err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, key).Scan(&value)
|
||||||
|
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 {
|
||||||
|
_, err := db.Exec(`
|
||||||
|
INSERT INTO app_settings (key, value, is_secret, updated_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value, is_secret = EXCLUDED.is_secret, updated_at = NOW()
|
||||||
|
`, key, value, secret)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteSetting(db *database.DB, key string) error {
|
||||||
|
_, err := db.Exec(`DELETE FROM app_settings WHERE key = $1`, key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
var isAdmin bool
|
||||||
|
if err := db.QueryRow(`SELECT is_admin FROM users WHERE id = $1`, userID).Scan(&isAdmin); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
var value string
|
||||||
|
if err := db.QueryRow(`SELECT value FROM app_settings WHERE key = $1`, settingCloudflareTunnelToken).Scan(&value); 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 reader, err := client.PullImage(ctx, cloudflaredImage, registry.AuthConfig{}); err == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, reader)
|
||||||
|
_ = reader.Close()
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -248,11 +248,18 @@ func ensureLocalUserRecord(c *gin.Context, user betterAuthSessionUser) (string,
|
|||||||
return "", hashErr
|
return "", hashErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The first mirrored account owns the platform.
|
||||||
|
var userCount int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
isFirstUser := userCount == 0
|
||||||
|
|
||||||
if err := db.QueryRow(`
|
if err := db.QueryRow(`
|
||||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
|
||||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, email, string(hashedPassword), name, avatarURL).Scan(&localUserID); err != nil {
|
`, email, string(hashedPassword), name, avatarURL, isFirstUser).Scan(&localUserID); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- In-app platform settings. Values here override environment variables;
|
||||||
|
-- secrets (e.g. Cloudflare tunnel token) are flagged and masked in API reads.
|
||||||
|
CREATE TABLE IF NOT EXISTS public.app_settings (
|
||||||
|
key character varying(255) PRIMARY KEY,
|
||||||
|
value text NOT NULL DEFAULT '',
|
||||||
|
is_secret boolean NOT NULL DEFAULT false,
|
||||||
|
updated_at timestamp with time zone NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.users ADD COLUMN IF NOT EXISTS is_admin boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- The first registered user owns the platform. Backfill existing installs:
|
||||||
|
-- the earliest account becomes admin when no admin is set.
|
||||||
|
UPDATE public.users
|
||||||
|
SET is_admin = true
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM public.users ORDER BY created_at ASC, id ASC LIMIT 1
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM public.users WHERE is_admin);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE public.users DROP COLUMN IF EXISTS is_admin;
|
||||||
|
DROP TABLE IF EXISTS public.app_settings;
|
||||||
@@ -279,7 +279,8 @@ export function SignUpPage() {
|
|||||||
return <Navigate to={redirectPath} replace />;
|
return <Navigate to={redirectPath} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bootstrapQuery.data?.mode === 'login') {
|
// Registration stays available when the owner reopened it in Settings.
|
||||||
|
if (bootstrapQuery.data?.mode === 'login' && !bootstrapQuery.data.signupEnabled) {
|
||||||
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +303,12 @@ export function SignUpPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout>
|
<AuthLayout>
|
||||||
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
|
<AuthCard
|
||||||
|
title="Create Account"
|
||||||
|
subtitle={bootstrapQuery.data?.mode === 'register'
|
||||||
|
? 'Create first platform owner account. Registration closes after bootstrap.'
|
||||||
|
: 'Create a new account.'}
|
||||||
|
>
|
||||||
{error ? <AuthErrorNotice message={error} /> : null}
|
{error ? <AuthErrorNotice message={error} /> : null}
|
||||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
|
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
|
||||||
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
|
{bootstrapQuery.isError ? <AuthErrorNotice message="Cannot reach the Containr API - check that the backend container is running." /> : null}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
getGitHubAppInstallUrl,
|
getGitHubAppInstallUrl,
|
||||||
connectGitHubApp,
|
connectGitHubApp,
|
||||||
updateCurrentUserProfile,
|
updateCurrentUserProfile,
|
||||||
|
getPlatformSettings,
|
||||||
|
updatePlatformSettings,
|
||||||
getHAStatus,
|
getHAStatus,
|
||||||
setHAEnabled,
|
setHAEnabled,
|
||||||
triggerFailover,
|
triggerFailover,
|
||||||
@@ -92,6 +94,8 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Zap,
|
Zap,
|
||||||
HeartPulse,
|
HeartPulse,
|
||||||
|
Cloud,
|
||||||
|
ExternalLink,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||||
@@ -1077,6 +1081,144 @@ function GitProvidersSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlatformSettingsSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const settingsQuery = useQuery({
|
||||||
|
queryKey: ['platform-settings'],
|
||||||
|
queryFn: getPlatformSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [signupDraft, setSignupDraft] = useState<boolean | null>(null);
|
||||||
|
const [tokenDraft, setTokenDraft] = useState('');
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const signupEnabled = signupDraft ?? settingsQuery.data?.signupEnabled ?? false;
|
||||||
|
const tunnel = settingsQuery.data?.cloudflareTunnel;
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: updatePlatformSettings,
|
||||||
|
onSuccess: (settings) => {
|
||||||
|
queryClient.setQueryData(['platform-settings'], settings);
|
||||||
|
setSignupDraft(null);
|
||||||
|
setTokenDraft('');
|
||||||
|
setFeedback('Settings saved.');
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save settings');
|
||||||
|
setFeedback(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveSignup = (enabled: boolean) => {
|
||||||
|
setSignupDraft(enabled);
|
||||||
|
saveMutation.mutate({ signupEnabled: enabled });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveToken = () => {
|
||||||
|
if (!tokenDraft.trim()) return;
|
||||||
|
saveMutation.mutate({ cloudflareTunnelToken: tokenDraft.trim() });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearToken = () => {
|
||||||
|
saveMutation.mutate({ cloudflareTunnelToken: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<Cloud size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Platform</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">Owner only - values set here override environment variables</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settingsQuery.isLoading ? (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<Loader2 size={18} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-start gap-3 p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={signupEnabled}
|
||||||
|
onChange={(e) => saveSignup(e.target.checked)}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="mt-0.5 accent-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">Open registration</span>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||||
|
Let anyone with the URL create an account. Off by default after the owner account is created.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Cloudflare Tunnel</span>
|
||||||
|
<span className="text-xs text-[var(--text-tertiary)]">
|
||||||
|
{tunnel?.tokenSet
|
||||||
|
? `token saved (${tunnel.source}) - ${tunnel.container}`
|
||||||
|
: tunnel?.container === 'unavailable'
|
||||||
|
? 'Docker socket unavailable'
|
||||||
|
: 'not configured'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||||
|
Expose this instance through a Cloudflare Tunnel. Create a tunnel in the dashboard,
|
||||||
|
copy the token, paste it here - the cloudflared sidecar is managed for you.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={tokenDraft}
|
||||||
|
onChange={(e) => setTokenDraft(e.target.value)}
|
||||||
|
className="flex-1 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||||
|
placeholder={tunnel?.tokenSet ? 'Token saved - paste a new one to rotate' : 'eyJhIjo... tunnel token'}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={saveToken}
|
||||||
|
disabled={saveMutation.isPending || !tokenDraft.trim()}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{tunnel?.tokenSet && tunnel.source === 'app' ? (
|
||||||
|
<button
|
||||||
|
onClick={clearToken}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://one.dash.cloudflare.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 mt-3 text-xs text-[var(--accent-primary)] hover:underline"
|
||||||
|
>
|
||||||
|
Open Cloudflare Zero Trust dashboard - Networks → Tunnels → Create
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{feedback ? <p className="text-xs text-[var(--success)]">{feedback}</p> : null}
|
||||||
|
{error ? <p className="text-xs text-[var(--error)]">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1238,6 +1380,9 @@ export function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Platform Settings - owner only */}
|
||||||
|
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||||
|
|
||||||
{/* Git Providers Section */}
|
{/* Git Providers Section */}
|
||||||
<GitProvidersSection />
|
<GitProvidersSection />
|
||||||
|
|
||||||
|
|||||||
@@ -2153,7 +2153,7 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Bootstrap status
|
* Bootstrap status
|
||||||
* @description Reports whether the instance already has a registered user. Drives the first-run setup gate.
|
* @description Reports whether the instance already has a registered user, which auth providers are configured, and whether public registration is open. Drives the first-run setup gate.
|
||||||
*/
|
*/
|
||||||
get: {
|
get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -2171,9 +2171,18 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
/** @description True when no user exists yet */
|
/** @description True when at least one account exists */
|
||||||
needs_setup?: boolean;
|
has_users?: boolean;
|
||||||
user_count?: number;
|
user_count?: number;
|
||||||
|
/**
|
||||||
|
* @description register when the instance has no users, login otherwise
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
mode?: "register" | "login";
|
||||||
|
/** @description OAuth providers with real credentials configured (e.g. github, google). Empty on self-hosted installs. */
|
||||||
|
providers?: string[];
|
||||||
|
/** @description True when the owner re-opened public registration in Settings */
|
||||||
|
signup_enabled?: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2187,6 +2196,91 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/settings": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Read platform settings
|
||||||
|
* @description Admin only. Returns in-app settings that override environment variables.
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Current platform settings */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlatformSettings"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Admin access required */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Update platform settings
|
||||||
|
* @description Admin only. Provided keys are applied immediately; the Cloudflare tunnel token starts/stops the managed cloudflared sidecar.
|
||||||
|
*/
|
||||||
|
put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @description Allow new public account registration after bootstrap */
|
||||||
|
signup_enabled?: boolean;
|
||||||
|
/** @description Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container. */
|
||||||
|
cloudflare_tunnel_token?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Updated platform settings */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlatformSettings"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Admin access required */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/users": {
|
"/users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -6687,6 +6781,21 @@ export interface components {
|
|||||||
/** @description User display name */
|
/** @description User display name */
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
PlatformSettings: {
|
||||||
|
/** @description Whether public account registration is open after bootstrap */
|
||||||
|
signup_enabled?: boolean;
|
||||||
|
cloudflare_tunnel?: {
|
||||||
|
/** @description Whether a tunnel token is configured (value is never returned) */
|
||||||
|
token_set?: boolean;
|
||||||
|
/**
|
||||||
|
* @description Where the active token comes from - in-app setting, env fallback, or unset
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
source?: "app" | "env" | "none";
|
||||||
|
/** @description cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable */
|
||||||
|
container?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
User: {
|
User: {
|
||||||
/** @description User ID */
|
/** @description User ID */
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -6699,6 +6808,8 @@ export interface components {
|
|||||||
name?: string;
|
name?: string;
|
||||||
/** @description Optional avatar image URL */
|
/** @description Optional avatar image URL */
|
||||||
avatar_url?: string;
|
avatar_url?: string;
|
||||||
|
/** @description True for the platform owner (first registered account) */
|
||||||
|
is_admin?: boolean;
|
||||||
/**
|
/**
|
||||||
* Format: date-time
|
* Format: date-time
|
||||||
* @description Account creation timestamp
|
* @description Account creation timestamp
|
||||||
|
|||||||
@@ -18,10 +18,20 @@ export type UserProfile = {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
isAdmin: boolean;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlatformSettings = {
|
||||||
|
signupEnabled: boolean;
|
||||||
|
cloudflareTunnel: {
|
||||||
|
tokenSet: boolean;
|
||||||
|
source: 'app' | 'env' | 'none';
|
||||||
|
container: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type ServiceEntity = {
|
export type ServiceEntity = {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -442,11 +452,46 @@ function normalizeUserProfile(profile: RawUserProfile): UserProfile | null {
|
|||||||
email: profile.email,
|
email: profile.email,
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
avatarUrl: profile.avatar_url ?? undefined,
|
avatarUrl: profile.avatar_url ?? undefined,
|
||||||
|
isAdmin: profile.is_admin ?? false,
|
||||||
createdAt: profile.created_at ?? undefined,
|
createdAt: profile.created_at ?? undefined,
|
||||||
updatedAt: profile.updated_at ?? undefined,
|
updatedAt: profile.updated_at ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePlatformSettings(raw: components['schemas']['PlatformSettings']): PlatformSettings {
|
||||||
|
return {
|
||||||
|
signupEnabled: raw.signup_enabled ?? false,
|
||||||
|
cloudflareTunnel: {
|
||||||
|
tokenSet: raw.cloudflare_tunnel?.token_set ?? false,
|
||||||
|
source: raw.cloudflare_tunnel?.source ?? 'none',
|
||||||
|
container: raw.cloudflare_tunnel?.container ?? 'missing',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlatformSettings(): Promise<PlatformSettings> {
|
||||||
|
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings');
|
||||||
|
return normalizePlatformSettings(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlatformSettings(input: {
|
||||||
|
signupEnabled?: boolean;
|
||||||
|
cloudflareTunnelToken?: string;
|
||||||
|
}): Promise<PlatformSettings> {
|
||||||
|
const body: Record<string, unknown> = {};
|
||||||
|
if (input.signupEnabled !== undefined) {
|
||||||
|
body.signup_enabled = input.signupEnabled;
|
||||||
|
}
|
||||||
|
if (input.cloudflareTunnelToken !== undefined) {
|
||||||
|
body.cloudflare_tunnel_token = input.cloudflareTunnelToken;
|
||||||
|
}
|
||||||
|
const raw = await requestJson<components['schemas']['PlatformSettings']>('/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return normalizePlatformSettings(raw);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeService(service: RawService): ServiceEntity | null {
|
function normalizeService(service: RawService): ServiceEntity | null {
|
||||||
if (!service.id || !service.name || !service.project_id) {
|
if (!service.id || !service.name || !service.project_id) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type AuthBootstrap = {
|
|||||||
userCount: number;
|
userCount: number;
|
||||||
mode: 'register' | 'login';
|
mode: 'register' | 'login';
|
||||||
providers: string[];
|
providers: string[];
|
||||||
|
signupEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AuthError extends Error {
|
export class AuthError extends Error {
|
||||||
@@ -67,6 +68,7 @@ export async function getAuthBootstrap(): Promise<AuthBootstrap> {
|
|||||||
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
|
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
|
||||||
mode: payload?.mode === 'register' ? 'register' : 'login',
|
mode: payload?.mode === 'register' ? 'register' : 'login',
|
||||||
providers,
|
providers,
|
||||||
|
signupEnabled: Boolean(payload?.signup_enabled),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ services:
|
|||||||
BITBUCKET_API_URL: ${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0}
|
BITBUCKET_API_URL: ${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0}
|
||||||
BITBUCKET_BASE_URL: ${BITBUCKET_BASE_URL:-https://bitbucket.org}
|
BITBUCKET_BASE_URL: ${BITBUCKET_BASE_URL:-https://bitbucket.org}
|
||||||
GITEA_BASE_URL: ${GITEA_BASE_URL:-https://gitea.example.com}
|
GITEA_BASE_URL: ${GITEA_BASE_URL:-https://gitea.example.com}
|
||||||
|
volumes:
|
||||||
|
# Required for service deployments and managed sidecars (e.g. cloudflared)
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
# Grants the non-root backend user access to the Docker socket.
|
||||||
|
# install.sh writes the host's docker gid into DOCKER_GID.
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_GID:-0}"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
+85
-3
@@ -1432,7 +1432,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Authentication
|
- Authentication
|
||||||
summary: Bootstrap status
|
summary: Bootstrap status
|
||||||
description: Reports whether the instance already has a registered user. Drives the first-run setup gate.
|
description: Reports whether the instance already has a registered user, which auth providers are configured, and whether public registration is open. Drives the first-run setup gate.
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Bootstrap state
|
description: Bootstrap state
|
||||||
@@ -1441,11 +1441,70 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
needs_setup:
|
has_users:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: True when no user exists yet
|
description: True when at least one account exists
|
||||||
user_count:
|
user_count:
|
||||||
type: integer
|
type: integer
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [register, login]
|
||||||
|
description: register when the instance has no users, login otherwise
|
||||||
|
providers:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: OAuth providers with real credentials configured (e.g. github, google). Empty on self-hosted installs.
|
||||||
|
signup_enabled:
|
||||||
|
type: boolean
|
||||||
|
description: True when the owner re-opened public registration in Settings
|
||||||
|
|
||||||
|
/settings:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Read platform settings
|
||||||
|
description: Admin only. Returns in-app settings that override environment variables.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Current platform settings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlatformSettings'
|
||||||
|
'403':
|
||||||
|
description: Admin access required
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Settings
|
||||||
|
summary: Update platform settings
|
||||||
|
description: Admin only. Provided keys are applied immediately; the Cloudflare tunnel token starts/stops the managed cloudflared sidecar.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
signup_enabled:
|
||||||
|
type: boolean
|
||||||
|
description: Allow new public account registration after bootstrap
|
||||||
|
cloudflare_tunnel_token:
|
||||||
|
type: string
|
||||||
|
description: Cloudflare Tunnel token. Empty string clears the stored token and removes the cloudflared container.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated platform settings
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlatformSettings'
|
||||||
|
'403':
|
||||||
|
description: Admin access required
|
||||||
|
|
||||||
# User Management
|
# User Management
|
||||||
/users:
|
/users:
|
||||||
@@ -4111,6 +4170,26 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: User display name
|
description: User display name
|
||||||
|
|
||||||
|
PlatformSettings:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
signup_enabled:
|
||||||
|
type: boolean
|
||||||
|
description: Whether public account registration is open after bootstrap
|
||||||
|
cloudflare_tunnel:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token_set:
|
||||||
|
type: boolean
|
||||||
|
description: Whether a tunnel token is configured (value is never returned)
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
enum: [app, env, none]
|
||||||
|
description: Where the active token comes from - in-app setting, env fallback, or unset
|
||||||
|
container:
|
||||||
|
type: string
|
||||||
|
description: cloudflared container state - running/exited/missing, or unavailable when Docker is unreachable
|
||||||
|
|
||||||
User:
|
User:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -4127,6 +4206,9 @@ components:
|
|||||||
avatar_url:
|
avatar_url:
|
||||||
type: string
|
type: string
|
||||||
description: Optional avatar image URL
|
description: Optional avatar image URL
|
||||||
|
is_admin:
|
||||||
|
type: boolean
|
||||||
|
description: True for the platform owner (first registered account)
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ services:
|
|||||||
- BITBUCKET_API_URL=${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0}
|
- BITBUCKET_API_URL=${BITBUCKET_API_URL:-https://api.bitbucket.org/2.0}
|
||||||
- BITBUCKET_BASE_URL=${BITBUCKET_BASE_URL:-https://bitbucket.org}
|
- BITBUCKET_BASE_URL=${BITBUCKET_BASE_URL:-https://bitbucket.org}
|
||||||
- GITEA_BASE_URL=${GITEA_BASE_URL:-https://gitea.example.com}
|
- GITEA_BASE_URL=${GITEA_BASE_URL:-https://gitea.example.com}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
group_add:
|
||||||
|
- "${DOCKER_GID:-0}"
|
||||||
networks:
|
networks:
|
||||||
- containr-network
|
- containr-network
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
+10
@@ -82,6 +82,16 @@ else
|
|||||||
sed -i.bak "s|your_secure_postgres_password|$PG_PASS|g" .env
|
sed -i.bak "s|your_secure_postgres_password|$PG_PASS|g" .env
|
||||||
sed -i.bak "s|your_secure_redis_password|$REDIS_PASS|g" .env
|
sed -i.bak "s|your_secure_redis_password|$REDIS_PASS|g" .env
|
||||||
|
|
||||||
|
# Docker socket gid as seen INSIDE containers (modern Docker maps it to
|
||||||
|
# root). Probe a throwaway container; fall back to the host gid, then 0.
|
||||||
|
if [[ -z "${DOCKER_GID:-}" ]]; then
|
||||||
|
DOCKER_GID="$(docker run --rm -v /var/run/docker.sock:/var/run/docker.sock alpine:3 stat -c %g /var/run/docker.sock 2>/dev/null \
|
||||||
|
|| stat -c %g /var/run/docker.sock 2>/dev/null \
|
||||||
|
|| stat -f %g /var/run/docker.sock 2>/dev/null \
|
||||||
|
|| echo 0)"
|
||||||
|
fi
|
||||||
|
setvar DOCKER_GID "$DOCKER_GID"
|
||||||
|
|
||||||
# Persist any overrides passed via the environment.
|
# Persist any overrides passed via the environment.
|
||||||
for v in HTTP_PORT API_PORT POSTGRES_PORT REDIS_PORT CONTAINR_VERSION; do
|
for v in HTTP_PORT API_PORT POSTGRES_PORT REDIS_PORT CONTAINR_VERSION; do
|
||||||
[[ -n "${!v:-}" ]] && setvar "$v" "${!v}"
|
[[ -n "${!v:-}" ]] && setvar "$v" "${!v}"
|
||||||
|
|||||||
Reference in new issue
Block a user