v0.84.0: catalog-driven initial_credentials — read an app's auto-generated first-login from a file and show it on the app page
This commit is contained in:
@@ -1,5 +1,30 @@
|
|||||||
## Changelog
|
## 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 <c> cat <file>` — 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)
|
### 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,
|
- **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
|
exposing a pre-existing 502 — Traefik proxied **HTTP** to Crafty's **HTTPS-only** self-signed backend
|
||||||
|
|||||||
@@ -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=<redacted len=%d>", 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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -687,6 +687,12 @@ func deepCopyStack(s *Stack) Stack {
|
|||||||
cp.Meta.HealthCheck = &hcCopy
|
cp.Meta.HealthCheck = &hcCopy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deep-copy Meta.InitialCreds pointer
|
||||||
|
if s.Meta.InitialCreds != nil {
|
||||||
|
icCopy := *s.Meta.InitialCreds
|
||||||
|
cp.Meta.InitialCreds = &icCopy
|
||||||
|
}
|
||||||
|
|
||||||
return cp
|
return cp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,28 @@ type Metadata struct {
|
|||||||
OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"`
|
OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"`
|
||||||
HealthCheck *HealthCheckConfig `yaml:"healthcheck,omitempty" json:"healthcheck,omitempty"`
|
HealthCheck *HealthCheckConfig `yaml:"healthcheck,omitempty" json:"healthcheck,omitempty"`
|
||||||
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,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.
|
// AppInfo holds detailed app information for the info page.
|
||||||
|
|||||||
@@ -477,6 +477,17 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
|||||||
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
||||||
data["EffectiveSubdomain"] = effectiveSubdomain
|
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).
|
// Per-app migration (B1): offer to move this app's data to another connected drive (≠ current).
|
||||||
if found.Deployed {
|
if found.Deployed {
|
||||||
current := ""
|
current := ""
|
||||||
|
|||||||
@@ -140,6 +140,31 @@ function appMigrate(app,label){
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .InitialCreds}}
|
||||||
|
<div class="app-info-card">
|
||||||
|
<h3>Kezdeti belépési adatok</h3>
|
||||||
|
<table class="initcred-table">
|
||||||
|
{{if .InitialCreds.Username}}
|
||||||
|
<tr>
|
||||||
|
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-muted);white-space:nowrap">Felhasználónév</td>
|
||||||
|
<td><code class="initcred-user" style="user-select:all">{{.InitialCreds.Username}}</code></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
<tr>
|
||||||
|
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-muted);white-space:nowrap;vertical-align:middle">Jelszó</td>
|
||||||
|
<td style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
|
||||||
|
<code id="initcred-pw">••••••••••••</code>
|
||||||
|
<span id="initcred-pw-val" hidden>{{.InitialCreds.Password}}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" onclick="icRevealPw(this)">Megjelenítés</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline" onclick="icCopyPw(this)">Másolás</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{{if .InitialCreds.Note}}<p class="app-info-creds-warn">{{.InitialCreds.Note}}</p>{{end}}
|
||||||
|
<p class="app-info-creds-warn">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.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .AppInfo.DefaultCreds}}
|
{{if .AppInfo.DefaultCreds}}
|
||||||
<div class="app-info-card">
|
<div class="app-info-card">
|
||||||
<h3>Alapértelmezett belépés</h3>
|
<h3>Alapértelmezett belépés</h3>
|
||||||
@@ -157,5 +182,38 @@ function appMigrate(app,label){
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .InitialCreds}}
|
||||||
|
<script>
|
||||||
|
// Initial-credential password reveal/copy. The value lives in a hidden element (HTML-escaped by the
|
||||||
|
// template) so it's never inlined into a JS string literal.
|
||||||
|
function icPwVal() {
|
||||||
|
var el = document.getElementById('initcred-pw-val');
|
||||||
|
return el ? el.textContent : '';
|
||||||
|
}
|
||||||
|
function icRevealPw(btn) {
|
||||||
|
var code = document.getElementById('initcred-pw');
|
||||||
|
if (!code) return;
|
||||||
|
if (code.dataset.shown === '1') {
|
||||||
|
code.textContent = '••••••••••••';
|
||||||
|
code.dataset.shown = '0';
|
||||||
|
btn.textContent = 'Megjelenítés';
|
||||||
|
} else {
|
||||||
|
code.textContent = icPwVal();
|
||||||
|
code.dataset.shown = '1';
|
||||||
|
btn.textContent = 'Elrejtés';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function icCopyPw(btn) {
|
||||||
|
var val = icPwVal();
|
||||||
|
if (!val) return;
|
||||||
|
navigator.clipboard.writeText(val).then(function () {
|
||||||
|
var orig = btn.textContent;
|
||||||
|
btn.textContent = 'Másolva ✓';
|
||||||
|
setTimeout(function () { btn.textContent = orig; }, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{template "layout_end" .}}
|
{{template "layout_end" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user