v0.27.0 — user-configurable app subdomains
Users can now customize the subdomain for each app during deployment instead of using a fixed value. The deploy page shows an editable text input with the default pre-filled and the base domain as a suffix. New "subdomain" deploy field type with DNS-safe format validation, reserved name blocklist, and uniqueness check across deployed stacks. Locked after deploy — changing requires Remove + Redeploy. Backward compatible: InjectMissingFields() auto-fills SUBDOMAIN from .felhom.yml defaults for existing deployed apps on next sync/restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +16,70 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// reservedSubdomains lists subdomains reserved for system use.
|
||||
var reservedSubdomains = map[string]bool{
|
||||
"felhom": true, // controller dashboard
|
||||
"files": true, // filebrowser
|
||||
"traefik": true, // reverse proxy
|
||||
"api": true,
|
||||
"www": true,
|
||||
"mail": true,
|
||||
"smtp": true,
|
||||
"ftp": true,
|
||||
"admin": true,
|
||||
"portal": true,
|
||||
"ssh": true,
|
||||
"ns1": true,
|
||||
"ns2": true,
|
||||
"mx": true,
|
||||
"pop": true,
|
||||
"imap": true,
|
||||
}
|
||||
|
||||
var subdomainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
||||
|
||||
// validateSubdomain checks that a subdomain is DNS-safe.
|
||||
func validateSubdomain(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("az aldomain nem lehet üres")
|
||||
}
|
||||
if len(s) > 63 {
|
||||
return fmt.Errorf("az aldomain legfeljebb 63 karakter lehet")
|
||||
}
|
||||
if !subdomainRe.MatchString(s) {
|
||||
return fmt.Errorf("az aldomain csak kisbetűket, számokat és kötőjelet tartalmazhat, és nem kezdődhet/végződhet kötőjellel")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubdomainInUse checks if a subdomain is already used by any deployed stack
|
||||
// other than excludeStack.
|
||||
func (m *Manager) SubdomainInUse(subdomain, excludeStack string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for name, stack := range m.stacks {
|
||||
if name == excludeStack || !stack.Deployed {
|
||||
continue
|
||||
}
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
appCfg := LoadAppConfig(stackDir)
|
||||
if appCfg == nil {
|
||||
continue
|
||||
}
|
||||
// Check stored SUBDOMAIN first
|
||||
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd == subdomain {
|
||||
return true
|
||||
}
|
||||
// Backward compat: check metadata subdomain for apps without SUBDOMAIN in env
|
||||
if _, hasSub := appCfg.Env["SUBDOMAIN"]; !hasSub {
|
||||
if stack.Meta.Subdomain == subdomain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AppConfig holds the per-app deployment configuration.
|
||||
// Saved as app.yaml in each stack directory after first deployment.
|
||||
type AppConfig struct {
|
||||
@@ -113,6 +178,23 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
// Auto-fill from controller config
|
||||
value = m.cfg.Customer.Domain
|
||||
|
||||
case "subdomain":
|
||||
// User-editable with default from metadata
|
||||
if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
|
||||
value = strings.ToLower(strings.TrimSpace(userVal))
|
||||
} else if field.Default != "" {
|
||||
value = field.Default
|
||||
}
|
||||
if err := validateSubdomain(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if reservedSubdomains[value] {
|
||||
return "", fmt.Errorf("a(z) %q aldomain foglalt rendszer számára", value)
|
||||
}
|
||||
if m.SubdomainInUse(value, req.StackName) {
|
||||
return "", fmt.Errorf("a(z) %q aldomain már használatban van egy másik alkalmazásban", value)
|
||||
}
|
||||
|
||||
case "secret":
|
||||
// Use pre-generated value if provided by the deploy page (same value the user saw),
|
||||
// otherwise fall back to generating a fresh one.
|
||||
@@ -387,13 +469,8 @@ func (m *Manager) PreviewDeployValues(name string) (map[string]string, error) {
|
||||
for _, field := range meta.DeployFields {
|
||||
switch field.Type {
|
||||
case "domain":
|
||||
// Show the full URL the app will be reachable at (subdomain.base_domain).
|
||||
// This is informational only — DeployStack always stores the base domain.
|
||||
if meta.Subdomain != "" {
|
||||
result[field.EnvVar] = meta.Subdomain + "." + m.cfg.Customer.Domain
|
||||
} else {
|
||||
result[field.EnvVar] = m.cfg.Customer.Domain
|
||||
}
|
||||
// Show the base domain. The subdomain is now a separate user-editable field.
|
||||
result[field.EnvVar] = m.cfg.Customer.Domain
|
||||
case "secret":
|
||||
if field.Generate == "" {
|
||||
continue
|
||||
@@ -531,6 +608,22 @@ func (m *Manager) InjectMissingFields(stackNames []string) {
|
||||
}
|
||||
injected = append(injected, field.EnvVar)
|
||||
|
||||
case "subdomain":
|
||||
// Auto-fill from field default or metadata subdomain
|
||||
val := field.Default
|
||||
if val == "" {
|
||||
val = meta.Subdomain
|
||||
}
|
||||
if val == "" {
|
||||
m.logger.Printf("[WARN] Stack %s: new subdomain field %s has no default — skipping", name, field.EnvVar)
|
||||
continue
|
||||
}
|
||||
appCfg.Env[field.EnvVar] = val
|
||||
if field.LockedAfterDeploy && !containsStr(appCfg.LockedFields, field.EnvVar) {
|
||||
appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar)
|
||||
}
|
||||
injected = append(injected, field.EnvVar)
|
||||
|
||||
default:
|
||||
m.logger.Printf("[WARN] Stack %s: new field %s (type=%s) requires manual configuration", name, field.EnvVar, field.Type)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ type ResourceHints struct {
|
||||
type DeployField struct {
|
||||
EnvVar string `yaml:"env_var" json:"env_var"`
|
||||
Label string `yaml:"label" json:"label"`
|
||||
Type string `yaml:"type" json:"type"` // domain, secret, password, path, text, select, boolean
|
||||
Type string `yaml:"type" json:"type"` // domain, subdomain, secret, password, path, text, select, boolean
|
||||
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
|
||||
Default string `yaml:"default" json:"default"`
|
||||
Required bool `yaml:"required" json:"required"`
|
||||
@@ -112,9 +112,9 @@ func LoadMetadata(stackDir string) Metadata {
|
||||
meta.Category = "tools"
|
||||
}
|
||||
|
||||
// DOMAIN field is always auto-filled — mark it implicitly required
|
||||
// DOMAIN and SUBDOMAIN fields are always auto-filled/required — mark implicitly
|
||||
for i := range meta.DeployFields {
|
||||
if meta.DeployFields[i].Type == "domain" {
|
||||
if meta.DeployFields[i].Type == "domain" || meta.DeployFields[i].Type == "subdomain" {
|
||||
meta.DeployFields[i].Required = true
|
||||
meta.DeployFields[i].LockedAfterDeploy = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user