diff --git a/CHANGELOG.md b/CHANGELOG.md index e2184ef..1771e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ ## Changelog +### v0.27.0 — User-Configurable App Subdomains (2026-02-22) + +#### Added +- **User-configurable subdomains**: Users can now customize the subdomain (e.g., `wiki`, `cloud`, `my-notes`) for each app during deployment, instead of using a fixed value. The deploy page shows an editable text input with the default subdomain pre-filled and the base domain as a suffix (e.g., `[wiki] .demo-felhom.eu`). +- **New deploy field type `"subdomain"`** — `internal/stacks/metadata.go`, `deploy.go`: A new field type that is user-editable with a default value, validated, and locked after deployment. Changing the subdomain requires removing the app (clean install) and redeploying. +- **Subdomain validation** — `internal/stacks/deploy.go`: Three-layer validation: DNS-safe format (lowercase alphanumeric + hyphens, max 63 chars), reserved name blocklist (`felhom`, `files`, `traefik`, `api`, `www`, `mail`, `admin`, etc.), and uniqueness check across all deployed stacks. +- **Backward compatibility** — `internal/stacks/deploy.go`: `InjectMissingFields()` auto-fills `SUBDOMAIN` from the `.felhom.yml` default for existing deployed apps when templates are synced, so no manual intervention is needed. +- **`internal/web/handlers.go`** — `stacksHandler()` builds an effective subdomain lookup map (stored env → metadata fallback). `appDetailHandler()` passes `EffectiveSubdomain` to templates. +- **`internal/web/templates/deploy.html`** — New `.subdomain-input-group` widget with inline `.domain` suffix. Client-side validation enforces DNS-safe format with real-time lowercasing. +- **`internal/web/templates/stacks.html`**, **`app_info.html`** — Subdomain links now read from stored `app.yaml` env (via lookup map) instead of hardcoded metadata, showing the user's actual chosen subdomain. + +#### Changed +- **`internal/stacks/deploy.go`** — `PreviewDeployValues()` domain case simplified: shows just the base domain now (subdomain is a separate field). +- **`internal/web/handlers.go`** — Deploy page domain auto-field no longer prepends `meta.Subdomain + "."`. Passes `DeployedFieldValues` for rendering stored subdomain on settings page. + +#### App Catalog (app-catalog-felhom.eu) +- All 51 template `docker-compose.yml` files updated: hardcoded `{subdomain}.${DOMAIN}` replaced with `${SUBDOMAIN}.${DOMAIN}` in Traefik labels, app env vars (APP_URL, trusted domains, webhook URLs, etc.), and comments. +- All 51 `.felhom.yml` files updated: added `SUBDOMAIN` deploy field with `type: subdomain` and `default:` matching the existing `subdomain:` metadata value. + ### v0.26.2 — Show Full App URL on Deploy Page (2026-02-22) #### Fixed diff --git a/controller/README.md b/controller/README.md index 9469418..1aeaa13 100644 --- a/controller/README.md +++ b/controller/README.md @@ -130,7 +130,8 @@ The app catalog lives in a separate Git repository. The controller: 1. Customer sees app card with "Telepites" button 2. Deploy page pre-generates and **displays** all auto-values before the user clicks deploy: - - `domain` fields: shown as readonly text input with the customer's configured domain + - `domain` fields: shown as readonly text input with the customer's configured base domain + - `subdomain` fields: editable text input pre-filled with the default from `.felhom.yml`, shown with `.base-domain` suffix. Validated for DNS-safe format, reserved names, and uniqueness across deployed stacks. Locked after deploy — changing requires Remove + Redeploy - `secret` fields: pre-generated and shown as masked password inputs with a "Megjelenítés" reveal button — user can see/copy all DB passwords and keys before deploying - User-configurable inputs (admin password, language, storage path) remain editable - Section header prompts the user to note down any passwords they need @@ -180,6 +181,7 @@ When app templates are updated (e.g., a new `APP_KEY` secret is added to `.felho - For each deployed stack, compares `.felhom.yml` `deploy_fields` against `app.yaml` env vars - Missing `secret` fields: auto-generated using the field's generator spec (`password:N`, `hex:N`, `base64key:N`) - Missing `domain` fields: filled with the customer's configured domain +- Missing `subdomain` fields: filled with the field's default value or the `.felhom.yml` `subdomain:` metadata - Other field types (e.g., `text`, `select`): logged as warning for manual configuration - Locked fields are added to the locked list automatically diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 8a8e44f..947b91a 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -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) } diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index a02a128..854dfcc 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -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 } diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index ad82e75..a2c0d70 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -202,6 +202,21 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) { } data["StorageLabels"] = storageLabels + // Build effective subdomain lookup (stored env > metadata fallback) + subdomains := make(map[string]string) + for _, stack := range s.stackMgr.GetStacks() { + if stack.Deployed { + if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { + if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" { + subdomains[stack.Name] = sd + continue + } + } + } + subdomains[stack.Name] = stack.Meta.Subdomain + } + data["Subdomains"] = subdomains + s.executeTemplate(w, r, "stacks", data) } @@ -259,12 +274,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri if alreadyDeployed && appCfg != nil { for _, f := range meta.AutoGeneratedFields() { if val, ok := appCfg.Env[f.EnvVar]; ok { - // For domain fields show the full URL (subdomain.base_domain) as displayed on app cards. - if f.Type == "domain" && meta.Subdomain != "" { - autoFieldValues[f.EnvVar] = meta.Subdomain + "." + val - } else { - autoFieldValues[f.EnvVar] = val - } + autoFieldValues[f.EnvVar] = val } } } else if !alreadyDeployed { @@ -275,6 +285,10 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri } } data["AutoFieldValues"] = autoFieldValues + // For deployed apps, pass stored field values so subdomain and other user fields show current values + if alreadyDeployed && appCfg != nil { + data["DeployedFieldValues"] = appCfg.Env + } // Storage paths with free space info for deploy dropdown var deployPaths []DeployStoragePath for _, sp := range s.settings.GetSchedulableStoragePaths() { @@ -406,6 +420,12 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s } } + // Determine effective subdomain (stored env > metadata fallback) + effectiveSubdomain := found.Meta.Subdomain + if sd, ok := currentValues["SUBDOMAIN"]; ok && sd != "" { + effectiveSubdomain = sd + } + data := s.baseData("stacks", found.Meta.DisplayName) data["Stack"] = found data["Meta"] = found.Meta @@ -414,6 +434,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s data["CurrentValues"] = currentValues data["HasAppInfo"] = found.Meta.HasAppInfo() data["HasOptionalConfig"] = found.Meta.HasOptionalConfig() + data["EffectiveSubdomain"] = effectiveSubdomain s.executeTemplate(w, r, "app_info", data) } diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 1ad0607..cee54b3 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -10,7 +10,7 @@ {{if .Stack.Deployed}} {{stateLabel .Stack.State}} {{if .Stack.Orphaned}}Elavult{{end}} - Megnyitás ↗ + {{if .EffectiveSubdomain}}Megnyitás ↗{{end}} Napló {{if .Stack.Orphaned}} diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index 4cd7d8e..dc6a8c7 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -270,7 +270,22 @@ {{if .LockedAfterDeploy}}telepítés után nem módosítható{{end}} - {{if eq .Type "select"}} + {{if eq .Type "subdomain"}} +
+ + .{{$.Domain}} +
+ {{if not $.AlreadyDeployed}} + Az aldomain telepítés után nem módosítható. Megváltoztatáshoz az alkalmazás eltávolítása és újratelepítése szükséges (minden adat törlődik). + {{end}} + {{else if eq .Type "select"}}