v0.280.0: the setup gate (decision 46); R-710 'I changed it' + absent-record window; R-709 password fields off the page; password:N:special generator
gates / gates (push) Successful in 25s
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -181,6 +181,18 @@ type AppConfig struct {
|
||||
RestoredLogins []string `yaml:"restored_logins,omitempty" json:"restored_logins,omitempty"`
|
||||
// AfterInstall (v0.279.0, decision 45) is what the template's one-time after_install command did.
|
||||
AfterInstall *AfterInstallRecord `yaml:"after_install,omitempty" json:"after_install,omitempty"`
|
||||
// SetupGate (v0.280.0, decision 46) is the app's setup gate: closed from a fresh install until the first
|
||||
// setup is done. A life record (carried across a restore). See setup_gate.go.
|
||||
SetupGate *SetupGateRecord `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"`
|
||||
// DefaultLogin (v0.280.0, R-710) is the household's own word that it changed the template's known default
|
||||
// login by hand. The page stops naming the default. See internal/web/known_login.go.
|
||||
DefaultLogin *DefaultLoginRecord `yaml:"default_login,omitempty" json:"default_login,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultLoginRecord is app.yaml's `default_login:`.
|
||||
type DefaultLoginRecord struct {
|
||||
ChangedAt string `yaml:"changed_at" json:"changed_at"`
|
||||
By string `yaml:"by" json:"by"`
|
||||
}
|
||||
|
||||
// InstalledImage is one compose service's observed image. See AppConfig.InstalledImages.
|
||||
@@ -408,6 +420,19 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// `09` §3 decision 46: a gated template is installed CLOSED, and the gate's traefik file is written BEFORE
|
||||
// the first start (spike F2). Cannot write it → the install is refused: never published open.
|
||||
var gate *SetupGateRecord
|
||||
if meta.SetupGate {
|
||||
g, err := m.prepareSetupGate(req.StackName, stack.ComposePath, env)
|
||||
if err != nil {
|
||||
clearDeploying()
|
||||
m.logger.Printf("[ERROR] [stacks] Deploy %s REFUSED: the setup gate could not be prepared: %v", req.StackName, err)
|
||||
return "", util.MsgError("err.stacks.setup_gate_failed", err.Error())
|
||||
}
|
||||
gate = g
|
||||
}
|
||||
|
||||
// Save app.yaml.
|
||||
// CTRL-T2-1: persist the env now, but mark the ON-DISK state Deployed:false
|
||||
// until `docker compose up -d` actually succeeds (done in runComposeDeploy).
|
||||
@@ -427,6 +452,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
// (isBootOrphan gates on Deployed first), and if the compose-up then fails, runComposeDeploy
|
||||
// reverts Deployed to false — so a failed deploy can never present as an app owed a restart.
|
||||
DesiredState: DesiredStateRunning,
|
||||
SetupGate: gate,
|
||||
}
|
||||
|
||||
diskCfg := *appCfg
|
||||
@@ -739,6 +765,9 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
|
||||
// so USERDATA_PATH must be injected here too (mirrors stackEnv), else the FIRST deploy resolves
|
||||
// ${USERDATA_PATH} to "" and binds a bogus root-owned dir at the container root.
|
||||
func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) {
|
||||
if m.composeExecFn != nil { // test seam (v0.280.0): a deploy test never reaches Docker
|
||||
return m.composeExecFn(dir, env, args...)
|
||||
}
|
||||
cmdEnv := os.Environ()
|
||||
for k, v := range env {
|
||||
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
|
||||
@@ -1116,11 +1145,22 @@ func generateValue(spec string) (string, error) {
|
||||
|
||||
switch parts[0] {
|
||||
case "password":
|
||||
// "password:N" letters and digits; "password:N:special" (v0.280.0) also carries one of
|
||||
// passwordSpecials and at least one lower, upper and digit — for an app whose own policy demands it
|
||||
// (calibre-web). The deploy page's generatePassword (deploy.html) makes the same shape.
|
||||
lenStr, form, _ := strings.Cut(parts[1], ":")
|
||||
length := 0
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &length); err != nil || length <= 0 {
|
||||
if _, err := fmt.Sscanf(lenStr, "%d", &length); err != nil || length <= 0 {
|
||||
return "", fmt.Errorf("invalid password length: %q", parts[1])
|
||||
}
|
||||
return randomAlphanumeric(length)
|
||||
switch form {
|
||||
case "":
|
||||
return randomAlphanumeric(length)
|
||||
case "special":
|
||||
return randomWithSpecial(length)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown password form %q (want special)", form)
|
||||
}
|
||||
case "hex":
|
||||
byteLen := 0
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &byteLen); err != nil || byteLen <= 0 {
|
||||
@@ -1286,6 +1326,35 @@ func containsStr(slice []string, s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// passwordSpecials are safe in a compose env value, a shell's double quotes, a URL form and a "user:password"
|
||||
// argument: no quote, no $, no backslash, no colon, no space.
|
||||
const passwordSpecials = "-_.!@#%+="
|
||||
|
||||
// randomWithSpecial: length >= 8, first character a letter or digit, at least one lower, upper, digit and special.
|
||||
func randomWithSpecial(length int) (string, error) {
|
||||
if length < 8 {
|
||||
return "", fmt.Errorf("a password with a special character needs at least 8 characters, not %d", length)
|
||||
}
|
||||
chars := alphanumChars + passwordSpecials
|
||||
for {
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[i] = chars[n.Int64()]
|
||||
}
|
||||
p := string(b)
|
||||
if strings.ContainsAny(p[:1], passwordSpecials) || !strings.ContainsAny(p, "abcdefghijklmnopqrstuvwxyz") ||
|
||||
!strings.ContainsAny(p, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") || !strings.ContainsAny(p, "0123456789") ||
|
||||
!strings.ContainsAny(p, passwordSpecials) {
|
||||
continue
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
func randomAlphanumeric(length int) (string, error) {
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
|
||||
Reference in New Issue
Block a user