From 1705d71dd507d09c43bba5ed1eb919063c0e6f20 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 26 Jun 2026 11:00:06 +0200 Subject: [PATCH] =?UTF-8?q?v0.84.0:=20catalog-driven=20initial=5Fcredentia?= =?UTF-8?q?ls=20=E2=80=94=20read=20an=20app's=20auto-generated=20first-log?= =?UTF-8?q?in=20from=20a=20file=20and=20show=20it=20on=20the=20app=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 25 +++ controller/internal/stacks/initialcreds.go | 146 ++++++++++++++++++ .../internal/stacks/initialcreds_test.go | 73 +++++++++ controller/internal/stacks/manager.go | 6 + controller/internal/stacks/metadata.go | 22 +++ controller/internal/web/handlers.go | 11 ++ .../internal/web/templates/app_info.html | 58 +++++++ 7 files changed, 341 insertions(+) create mode 100644 controller/internal/stacks/initialcreds.go create mode 100644 controller/internal/stacks/initialcreds_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d1dd048..b1257b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ ## Changelog +### v0.84.0 — Show an app's auto-generated initial login on its page (catalog-driven) (2026-06-26) +- **Problem:** some apps generate a random first-login password into a file at first boot (Crafty → + `/crafty/app/config/default-creds.txt`) instead of taking it from a deploy field. Customers had to + read the container logs to find it — the static `app_info.default_creds` hint can't carry a + per-install secret. +- **General, catalog-driven mechanism (not Crafty-specific):** + - `.felhom.yml` gains an optional `initial_credentials` block: `{file, format: json|regex|plain, + container?, username_key/password_key (json), username_pattern/password_pattern (regex), note}`. + - `internal/stacks/metadata.go`: new `InitialCredentials` struct + `Metadata.InitialCreds` (deep-copied + in `deepCopyStack`). + - `internal/stacks/initialcreds.go`: `ReadInitialCredentials(stack)` reads the file **live** from the + running container (`docker exec cat ` — path passed as a single arg, no shell) and parses + it via the pure, unit-tested `parseInitialCreds` (json/regex/plain). Never persists the secret to + `app.yaml`; returns a non-Available result (card hidden) when the container is down / file missing / + parse fails. Container defaults to the stack's main container (`findProbeContainer`). + - `internal/web/handlers.go`: `appDetailHandler` populates `InitialCreds` for deployed apps with the + spec; `app_info.html` renders a "Kezdeti belépési adatok" card with username + masked password + (Megjelenítés/Másolás, value read from a hidden element — never inlined into JS), labelled clearly as + the **initial** password (stays valid only until the customer changes it in-app). + - Tests: `parseInitialCreds` json/regex/plain + error paths. +- **Security note:** this surfaces a live working credential on the app page — same exposure class as the + existing post-deploy password reveal and `default_creds` card. It relies on the dashboard being + auth-gated in production (the demo's public-unauth dashboard is a separate, pre-existing tracked issue). +- Paired with `app-catalog-felhom.eu` adding the `initial_credentials` block to crafty-controller. + ### v0.83.0 — Traefik scoped serversTransport for self-signed HTTPS backends (fixes crafty 502) (2026-06-26) - **Problem:** the crafty-controller healthcheck fix (catalog `68ce009`) un-withheld its Traefik route, exposing a pre-existing 502 — Traefik proxied **HTTP** to Crafty's **HTTPS-only** self-signed backend diff --git a/controller/internal/stacks/initialcreds.go b/controller/internal/stacks/initialcreds.go new file mode 100644 index 0000000..769ecf1 --- /dev/null +++ b/controller/internal/stacks/initialcreds.go @@ -0,0 +1,146 @@ +package stacks + +import ( + "encoding/json" + "fmt" + "os/exec" + "regexp" + "strings" +) + +// ExtractedCreds is the result of reading an app's auto-generated first-login credential from a file +// inside its container (driven by Metadata.InitialCreds). Available is false when the spec is present +// but the credential can't be read right now (container down, file gone) — the UI then hides the card +// rather than showing an error. +type ExtractedCreds struct { + Available bool `json:"available"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Note string `json:"note,omitempty"` +} + +// ReadInitialCredentials reads and parses the configured first-login credential file from the deployed +// stack's container. Returns (nil, nil) when the stack has no InitialCreds spec (nothing to do); a +// non-Available result when the spec exists but the file can't be read yet. The file path comes from +// the catalog (trusted) and is passed to `docker exec … cat` as a single arg (no shell), so a path +// with spaces/odd chars can't break out. +func (m *Manager) ReadInitialCredentials(stackName string) (*ExtractedCreds, error) { + stack, ok := m.GetStack(stackName) + if !ok { + return nil, fmt.Errorf("stack %q not found", stackName) + } + spec := stack.Meta.InitialCreds + if spec == nil || spec.File == "" { + return nil, nil // no spec — nothing to extract + } + + // Resolve which container to read from: explicit override, else the stack's main container. + container := spec.Container + if container == "" { + container = findProbeContainer(stack.Name, stack.Containers) + } + if container == "" { + if m.isDebug() { + m.logger.Printf("[DEBUG] [stacks] initial-creds %s: no running container to read from", stackName) + } + return &ExtractedCreds{Available: false}, nil + } + + out, err := exec.Command("docker", "exec", container, "cat", spec.File).Output() + if err != nil { + // File missing / container not exec-able yet — expected during early boot or after the + // customer deletes the file. Not an error worth surfacing; hide the card. + if m.isDebug() { + m.logger.Printf("[DEBUG] [stacks] initial-creds %s: cat %s in %s failed: %v", stackName, spec.File, container, err) + } + return &ExtractedCreds{Available: false}, nil + } + content := string(out) + if strings.TrimSpace(content) == "" { + return &ExtractedCreds{Available: false}, nil + } + + creds, perr := parseInitialCreds(spec, content) + if perr != nil { + m.logger.Printf("[WARN] [stacks] initial-creds %s: parse %s (%s) failed: %v", stackName, spec.File, spec.Format, perr) + return &ExtractedCreds{Available: false}, nil + } + if creds.Password == "" { + // A spec that yields no password is useless — log so a bad catalog spec is noticed. + m.logger.Printf("[WARN] [stacks] initial-creds %s: no password extracted from %s (format=%s)", stackName, spec.File, spec.Format) + return &ExtractedCreds{Available: false}, nil + } + creds.Note = spec.Note + creds.Available = true + if m.isDebug() { + m.logger.Printf("[DEBUG] [stacks] initial-creds %s: extracted username=%q password=", stackName, creds.Username, len(creds.Password)) + } + return creds, nil +} + +// parseInitialCreds is the pure parsing core (unit-testable without docker): given the spec and the +// raw file content, it pulls out username/password per the declared format. +func parseInitialCreds(spec *InitialCredentials, content string) (*ExtractedCreds, error) { + switch strings.ToLower(spec.Format) { + case "json": + var obj map[string]any + if err := json.Unmarshal([]byte(content), &obj); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + pwKey := spec.PasswordKey + if pwKey == "" { + pwKey = "password" + } + return &ExtractedCreds{ + Username: jsonStr(obj, spec.UsernameKey), + Password: jsonStr(obj, pwKey), + }, nil + + case "regex": + if spec.PasswordPattern == "" { + return nil, fmt.Errorf("regex format requires password_pattern") + } + pw, err := firstSubmatch(spec.PasswordPattern, content) + if err != nil { + return nil, err + } + user := "" + if spec.UsernamePattern != "" { + user, _ = firstSubmatch(spec.UsernamePattern, content) + } + return &ExtractedCreds{Username: user, Password: pw}, nil + + case "plain", "": + // Whole file (trimmed) is the password; no username. + return &ExtractedCreds{Password: strings.TrimSpace(content)}, nil + + default: + return nil, fmt.Errorf("unknown format %q (want json|regex|plain)", spec.Format) + } +} + +// jsonStr fetches a string value from a decoded JSON object; empty key or non-string → "". +func jsonStr(obj map[string]any, key string) string { + if key == "" { + return "" + } + if v, ok := obj[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// firstSubmatch compiles pattern and returns its first capture group against content. +func firstSubmatch(pattern, content string) (string, error) { + re, err := regexp.Compile(pattern) + if err != nil { + return "", fmt.Errorf("bad pattern %q: %w", pattern, err) + } + mm := re.FindStringSubmatch(content) + if len(mm) < 2 { + return "", nil // no match / no capture group — treated as "not found" + } + return strings.TrimSpace(mm[1]), nil +} diff --git a/controller/internal/stacks/initialcreds_test.go b/controller/internal/stacks/initialcreds_test.go new file mode 100644 index 0000000..8dde1f7 --- /dev/null +++ b/controller/internal/stacks/initialcreds_test.go @@ -0,0 +1,73 @@ +package stacks + +import "testing" + +func TestParseInitialCredsJSON(t *testing.T) { + spec := &InitialCredentials{Format: "json", UsernameKey: "username", PasswordKey: "password"} + c, err := parseInitialCreds(spec, `{"username":"admin","password":"s3cr3t!","info":"ignore me"}`) + if err != nil { + t.Fatal(err) + } + if c.Username != "admin" || c.Password != "s3cr3t!" { + t.Fatalf("json parse wrong: %+v", c) + } +} + +func TestParseInitialCredsJSONDefaultPasswordKey(t *testing.T) { + // password_key omitted → defaults to "password"; no username key → empty username. + spec := &InitialCredentials{Format: "json"} + c, err := parseInitialCreds(spec, `{"password":"pw123"}`) + if err != nil { + t.Fatal(err) + } + if c.Username != "" || c.Password != "pw123" { + t.Fatalf("default-key parse wrong: %+v", c) + } +} + +func TestParseInitialCredsJSONInvalid(t *testing.T) { + spec := &InitialCredentials{Format: "json", PasswordKey: "password"} + if _, err := parseInitialCreds(spec, `not json`); err == nil { + t.Fatal("expected error on invalid JSON") + } +} + +func TestParseInitialCredsRegex(t *testing.T) { + spec := &InitialCredentials{ + Format: "regex", + UsernamePattern: `(?m)^user:\s*(\S+)`, + PasswordPattern: `(?m)^pass:\s*(\S+)`, + } + c, err := parseInitialCreds(spec, "user: root\npass: hunter2\n") + if err != nil { + t.Fatal(err) + } + if c.Username != "root" || c.Password != "hunter2" { + t.Fatalf("regex parse wrong: %+v", c) + } +} + +func TestParseInitialCredsRegexNoPattern(t *testing.T) { + spec := &InitialCredentials{Format: "regex"} + if _, err := parseInitialCreds(spec, "anything"); err == nil { + t.Fatal("expected error when password_pattern missing") + } +} + +func TestParseInitialCredsPlain(t *testing.T) { + spec := &InitialCredentials{Format: "plain"} + c, err := parseInitialCreds(spec, " topsecret\n") + if err != nil { + t.Fatal(err) + } + if c.Password != "topsecret" || c.Username != "" { + t.Fatalf("plain parse wrong: %+v", c) + } +} + +func TestParseInitialCredsUnknownFormat(t *testing.T) { + spec := &InitialCredentials{Format: "toml"} + if _, err := parseInitialCreds(spec, "x = 1"); err == nil { + t.Fatal("expected error on unknown format") + } +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index b5303c4..85db45c 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -687,6 +687,12 @@ func deepCopyStack(s *Stack) Stack { cp.Meta.HealthCheck = &hcCopy } + // Deep-copy Meta.InitialCreds pointer + if s.Meta.InitialCreds != nil { + icCopy := *s.Meta.InitialCreds + cp.Meta.InitialCreds = &icCopy + } + return cp } diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index c58c1c9..d7a7e91 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -25,6 +25,28 @@ type Metadata struct { OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"` HealthCheck *HealthCheckConfig `yaml:"healthcheck,omitempty" json:"healthcheck,omitempty"` Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"` + // InitialCreds: for apps that auto-generate a first-login credential into a file inside the + // container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and + // surfaces it on the app page, so the customer never has to dig through logs. Optional. + InitialCreds *InitialCredentials `yaml:"initial_credentials,omitempty" json:"initial_credentials,omitempty"` +} + +// InitialCredentials tells the controller how to extract an app's auto-generated first-login +// credential from a file inside the running container. The file path is catalog-defined (trusted). +// Verification stays catalog-driven so any future self-seeding app can reuse the mechanism. +type InitialCredentials struct { + File string `yaml:"file" json:"file"` // path INSIDE the container + Format string `yaml:"format" json:"format"` // "json" | "regex" | "plain" + // Container overrides which container to read from; empty → the stack's main container. + Container string `yaml:"container,omitempty" json:"container,omitempty"` + // json format: which keys hold the username/password (password_key required; username optional). + UsernameKey string `yaml:"username_key,omitempty" json:"username_key,omitempty"` + PasswordKey string `yaml:"password_key,omitempty" json:"password_key,omitempty"` + // regex format: patterns whose first capture group is the value (password_pattern required). + UsernamePattern string `yaml:"username_pattern,omitempty" json:"username_pattern,omitempty"` + PasswordPattern string `yaml:"password_pattern,omitempty" json:"password_pattern,omitempty"` + // Note is shown alongside the credential (e.g. "initial password, change it after first login"). + Note string `yaml:"note,omitempty" json:"note,omitempty"` } // AppInfo holds detailed app information for the info page. diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index d732d8d..07991fb 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -477,6 +477,17 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s data["HasAppInfo"] = found.Meta.HasAppInfo() data["EffectiveSubdomain"] = effectiveSubdomain + // Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first + // boot). Read it live from the container so the customer doesn't have to dig through logs. Only + // for deployed apps that declare an initial_credentials spec; hidden when unreadable. + if found.Deployed && found.Meta.InitialCreds != nil { + if creds, err := s.stackMgr.ReadInitialCredentials(found.Name); err != nil { + s.logger.Printf("[WARN] [web] initial-creds for %s: %v", found.Name, err) + } else if creds != nil && creds.Available { + data["InitialCreds"] = creds + } + } + // Per-app migration (B1): offer to move this app's data to another connected drive (≠ current). if found.Deployed { current := "" diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 5df63e1..f42944a 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -140,6 +140,31 @@ function appMigrate(app,label){ {{end}} + {{if .InitialCreds}} +
+

Kezdeti belépési adatok

+ + {{if .InitialCreds.Username}} + + + + + {{end}} + + + + +
Felhasználónév{{.InitialCreds.Username}}
Jelszó + •••••••••••• + + + +
+ {{if .InitialCreds.Note}}

{{.InitialCreds.Note}}

{{end}} +

Az első bejelentkezés után azonnal változtasd meg! Ez a kezdeti, automatikusan generált jelszó — ha már megváltoztattad, hagyd figyelmen kívül.

+
+ {{end}} + {{if .AppInfo.DefaultCreds}}

Alapértelmezett belépés

@@ -157,5 +182,38 @@ function appMigrate(app,label){
{{end}} +{{if .InitialCreds}} + +{{end}} + {{template "layout_end" .}} {{end}}