diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index b12fba76..61d848cf 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -50,7 +50,14 @@ jobs: - name: Validate runtime Dockerfiles if: matrix.image_name == 'gonavi-web-server' shell: bash - run: bash tools/validate-web-server-dockerfile.test.sh + run: | + set -euo pipefail + bash tools/validate-web-server-dockerfile.test.sh + env_file="$(mktemp)" + trap 'rm -f "$env_file"' EXIT + printf 'GONAVI_HOST_DATA_ROOT=%s\nGONAVI_WEB_PASSWORD=123456\n' "${RUNNER_TEMP}/gonavi-data" >"$env_file" + rendered="$(docker compose --env-file "$env_file" -f docker-compose.web-server.yml config)" + grep -Eq "GONAVI_WEB_PASSWORD: ['\"]?123456['\"]?$" <<<"$rendered" - name: Prepare build variables id: prep @@ -111,17 +118,29 @@ jobs: shell: bash run: | set -euo pipefail - cid="$(docker run -d -p 34116:34116 -e GONAVI_WEB_ADDR=0.0.0.0:34116 codex-smoke/${{ matrix.image_name }}:local web-server)" - trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT + cookie_jar="$(mktemp)" + cid="" + trap 'rm -f "$cookie_jar"; if [[ -n "$cid" ]]; then docker rm -f "$cid" >/dev/null 2>&1 || true; fi' EXIT + cid="$(docker run -d -p 34116:34116 -e GONAVI_WEB_ADDR=0.0.0.0:34116 -e GONAVI_WEB_PASSWORD=123456 codex-smoke/${{ matrix.image_name }}:local web-server)" + ready=false for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do if curl --fail --silent http://127.0.0.1:34116/__gonavi/healthz >/dev/null; then - exit 0 + ready=true + break fi sleep 1 done - docker logs "$cid" - echo "Web Server image smoke test failed" >&2 - exit 1 + if [[ "$ready" != "true" ]]; then + docker logs "$cid" + echo "Web Server image smoke test failed" >&2 + exit 1 + fi + status="$(curl --fail --silent --show-error http://127.0.0.1:34116/__gonavi/auth/status)" + grep -Fq '"configured":true' <<<"$status" + login="$(curl --fail --silent --show-error -c "$cookie_jar" -H 'Content-Type: application/json' -d '{"password":"123456","code":""}' http://127.0.0.1:34116/__gonavi/auth/login)" + grep -Fq '"success":true' <<<"$login" + app_status="$(curl --silent --show-error -o /dev/null -w '%{http_code}' -b "$cookie_jar" http://127.0.0.1:34116/)" + [[ "$app_status" == "200" ]] - name: Smoke test build environment image if: matrix.image_name == 'gonavi-build-env' diff --git a/.gitignore b/.gitignore index bdc4acd6..80791186 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ frontend/wailsjs/tsconfig.json dist/ .DS_Store .gemini-clipboard +.env +docker.*.env GoNavi-Wails GoNavi-Wails.exe optional-driver-agent diff --git a/Dockerfile.web-server b/Dockerfile.web-server index 41c18b0e..02b9c261 100644 --- a/Dockerfile.web-server +++ b/Dockerfile.web-server @@ -57,6 +57,6 @@ EXPOSE 34116 USER gonavi -# 首次访问 /setup 初始化 Web 管理员密码;认证状态落在数据目录 web_auth.json +# 未通过环境变量配置密码时,首次访问 /setup 初始化;认证状态落在数据目录 web_auth.json ENTRYPOINT ["/usr/local/bin/gonavi"] CMD ["web-server"] diff --git a/README.md b/README.md index affc3639..7440a01f 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ go build . .\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116 ``` -The first browser visit is redirected to `/setup` to create the web admin password. Google Authenticator is optional but supported out of the box, together with `web_auth.json`, session cookies, recovery codes, and login rate limiting. +Without a configured password, the first browser visit is redirected to `/setup` to create the web admin password. Google Authenticator is optional but supported out of the box, together with `web_auth.json`, session cookies, recovery codes, and login rate limiting. Current scope already includes: @@ -216,13 +216,28 @@ Still in progress: ```bash cp docker.web-server.env.example docker.web-server.env # set GONAVI_HOST_DATA_ROOT to an absolute path +# optionally set GONAVI_WEB_PASSWORD to at least 6 characters docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d ``` -Open `http://127.0.0.1:34116` (or the mapped host port). Complete `/setup` on first visit. +You can alternatively put the same variables in the project-level `.env` file and omit `--env-file docker.web-server.env`. + +Open `http://127.0.0.1:34116` (or the mapped host port). When `GONAVI_WEB_PASSWORD` is set, a newly created container synchronizes it and opens the login page directly. Otherwise, complete `/setup` on first visit. Mount the GoNavi active data root at `/data`. Prefer including `connections.json`, `daily_secrets.json`, and optional `drivers/`. Web auth state is stored as `web_auth.json` in that directory. +`GONAVI_WEB_PASSWORD` takes precedence over the password hash already stored in `web_auth.json`, while preserving 2FA and session settings. Removing the variable leaves the last synchronized password active. Treat the env file as a secret because the plaintext value is visible to users who can inspect the container; restrict its file permissions. + +After changing the env password, recreate the container so Compose reloads the env file: + +```bash +docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d --force-recreate +``` + +Docker Desktop Restart and `docker restart` keep the old container environment and do not reload the env file. + +On a new deployment, an environment password starts password-only authentication. To enable 2FA, leave `GONAVI_WEB_PASSWORD` empty for the initial `/setup`, then set it to the same password before recreating the container. + The container listens on `0.0.0.0:34116` by default (`GONAVI_WEB_ADDR`). **Do not expose an unhardened Web entrypoint to the public internet**; use a reverse proxy and HTTPS for production. Default Compose pulls the GHCR image. For a local source build, add the override: diff --git a/README.zh-CN.md b/README.zh-CN.md index 5e070429..a1b7dcf5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -189,7 +189,7 @@ go build . .\GoNavi-Wails.exe web-server --addr 127.0.0.1:34116 ``` -首次访问会进入 `/setup` 完成 Web 管理员密码初始化;可选启用 Google Authenticator,服务端会落地 `web_auth.json`、Session Cookie、恢复码与登录失败限流。 +未配置密码时,首次访问会进入 `/setup` 完成 Web 管理员密码初始化;可选启用 Google Authenticator,服务端会落地 `web_auth.json`、Session Cookie、恢复码与登录失败限流。 当前阶段已具备: @@ -210,13 +210,28 @@ go build . ```bash cp docker.web-server.env.example docker.web-server.env # 编辑 GONAVI_HOST_DATA_ROOT 为绝对路径 +# 可选设置至少 6 位的 GONAVI_WEB_PASSWORD docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d ``` -浏览器打开 `http://127.0.0.1:34116`(或宿主机映射端口)。首次访问完成 `/setup`。 +也可以把相同变量写入项目目录下的标准 `.env` 文件,此时可省略 `--env-file docker.web-server.env`。 + +浏览器打开 `http://127.0.0.1:34116`(或宿主机映射端口)。设置 `GONAVI_WEB_PASSWORD` 后,新创建的容器会同步该密码并直接进入登录页;未设置时首次访问完成 `/setup`。 请把 GoNavi 活动数据目录挂载为 `/data`。建议至少包含 `connections.json`、`daily_secrets.json`;如需可选驱动代理应包含 `drivers/`。Web 认证状态写入同目录下的 `web_auth.json`。 +`GONAVI_WEB_PASSWORD` 会覆盖 `web_auth.json` 中已有的密码哈希,但保留 2FA 和会话策略;移除环境变量后继续使用最后一次同步的密码。env 文件包含明文密码,可被有容器检查权限的用户看到,请限制文件读取权限。 + +修改 env 密码后,需要重新创建容器,让 Compose 重新读取 env 文件: + +```bash +docker compose --env-file docker.web-server.env -f docker-compose.web-server.yml up -d --force-recreate +``` + +Docker Desktop 的 Restart 和 `docker restart` 会沿用旧容器环境,不会重新读取 env 文件。 + +新部署直接设置环境密码时会启用纯密码认证。如需启用 2FA,请先留空 `GONAVI_WEB_PASSWORD` 并通过 `/setup` 初始化,再将它设置为同一密码后重新创建容器。 + 容器内默认监听 `0.0.0.0:34116`(`GONAVI_WEB_ADDR`)。**不要把未加固的 Web 入口直接暴露到公网**;生产环境请配合反向代理与 HTTPS。 默认 Compose 拉取 GHCR 预构建镜像。本地源码构建叠加 override: diff --git a/docker-compose.web-server.yml b/docker-compose.web-server.yml index b50e02d0..b888b5bc 100644 --- a/docker-compose.web-server.yml +++ b/docker-compose.web-server.yml @@ -10,6 +10,7 @@ services: GONAVI_DATA_ROOT: /data GONAVI_LOG_DIR: /var/lib/gonavi/logs GONAVI_WEB_ADDR: 0.0.0.0:34116 + GONAVI_WEB_PASSWORD: ${GONAVI_WEB_PASSWORD:-} volumes: - type: bind source: ${GONAVI_HOST_DATA_ROOT} diff --git a/docker.web-server.env.example b/docker.web-server.env.example index 289bdf19..cb8633f1 100644 --- a/docker.web-server.env.example +++ b/docker.web-server.env.example @@ -1,7 +1,7 @@ # 宿主机 GoNavi 活动数据目录。 # 建议至少包含:connections.json、daily_secrets.json; # 如需可选驱动代理,一并挂载 drivers/。 -# Web 认证状态会写入 web_auth.json(首次访问 /setup 初始化)。 +# Web 认证状态会写入 web_auth.json;未设置环境密码时首次访问 /setup 初始化。 GONAVI_HOST_DATA_ROOT=/absolute/path/to/gonavi-data # 预构建镜像地址。稳定版通常使用 latest,跟随 dev 分支可改成 ghcr.io/syngnat/gonavi-web-server:dev-latest @@ -12,3 +12,8 @@ GONAVI_WEB_PULL_POLICY=missing # 宿主机暴露端口(映射到容器 34116) GONAVI_WEB_HTTP_PORT=34116 + +# Web 管理员密码(至少 6 位)。留空时首次访问 /setup 初始化;公网部署请使用更强密码。 +# 设置或修改后请执行 docker compose up -d --force-recreate;请限制本文件的读取权限。 +# 新部署如需启用 2FA,请先留空并通过 /setup 初始化,再设置为同一密码并重新创建容器。 +GONAVI_WEB_PASSWORD= diff --git a/frontend/src/components/WebAuthSettingsPanel.env-password.test.ts b/frontend/src/components/WebAuthSettingsPanel.env-password.test.ts new file mode 100644 index 00000000..043e6c6d --- /dev/null +++ b/frontend/src/components/WebAuthSettingsPanel.env-password.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const source = readFileSync(new URL('./WebAuthSettingsPanel.tsx', import.meta.url), 'utf8'); +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU']; + +describe('WebAuthSettingsPanel environment-managed password', () => { + it('disables password changes and explains how to update the password', () => { + expect(source).toContain('passwordManagedByEnvironment: boolean;'); + expect(source).toContain('summary?.passwordManagedByEnvironment === true'); + expect(source).toContain("t('app.settings.web_auth.password.managed_by_environment')"); + expect(source).toContain('disabled={passwordManagedByEnvironment}'); + }); + + it('keeps the six-character and environment-managed copy in every locale', () => { + for (const locale of locales) { + const catalog = JSON.parse( + readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'), + ) as Record; + expect(catalog['app.settings.web_auth.password.new_placeholder']).toContain('6'); + expect(catalog['app.settings.web_auth.password.managed_by_environment']).toContain('GONAVI_WEB_PASSWORD'); + expect(catalog['web_auth.error.password_managed_by_environment']).toBeTruthy(); + } + }); +}); diff --git a/frontend/src/components/WebAuthSettingsPanel.tsx b/frontend/src/components/WebAuthSettingsPanel.tsx index 79d45ed1..23b5ccc9 100644 --- a/frontend/src/components/WebAuthSettingsPanel.tsx +++ b/frontend/src/components/WebAuthSettingsPanel.tsx @@ -10,6 +10,7 @@ type WebAuthSettingsSummary = { sessionAbsoluteHours: number; sessionRememberDays: number; updatedAt?: string; + passwordManagedByEnvironment: boolean; }; type WebAuthSettingsPanelProps = { @@ -80,6 +81,7 @@ const WebAuthSettingsPanel: React.FC = ({ const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [passwordForm, setPasswordForm] = useState(() => createEmptyPasswordFormState()); + const passwordManagedByEnvironment = summary?.passwordManagedByEnvironment === true; const summaryItems = useMemo(() => { if (!summary) { @@ -245,9 +247,11 @@ const WebAuthSettingsPanel: React.FC = ({
{t('app.settings.web_auth.password.title')}
- {summary?.totpEnabled - ? t('app.settings.web_auth.password.description_with_code') - : t('app.settings.web_auth.password.description')} + {passwordManagedByEnvironment + ? t('app.settings.web_auth.password.managed_by_environment') + : summary?.totpEnabled + ? t('app.settings.web_auth.password.description_with_code') + : t('app.settings.web_auth.password.description')}
= ({ {t('app.settings.web_auth.password.current_label')} updatePasswordField('currentPassword', event.target.value)} @@ -271,6 +276,7 @@ const WebAuthSettingsPanel: React.FC = ({ {t('app.settings.web_auth.password.code_label')} = ({ {t('app.settings.web_auth.password.new_label')} updatePasswordField('newPassword', event.target.value)} @@ -291,6 +298,7 @@ const WebAuthSettingsPanel: React.FC = ({ {t('app.settings.web_auth.password.confirm_label')} updatePasswordField('confirmPassword', event.target.value)} @@ -298,7 +306,12 @@ const WebAuthSettingsPanel: React.FC = ({
-
diff --git a/internal/webserver/auth.go b/internal/webserver/auth.go index 70f075cf..524c672b 100644 --- a/internal/webserver/auth.go +++ b/internal/webserver/auth.go @@ -22,6 +22,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "GoNavi-Wails/internal/appdata" "github.com/skip2/go-qrcode" @@ -39,7 +40,8 @@ const ( webDefaultSessionIdleMinutes = 30 webDefaultSessionAbsoluteHours = 24 * 7 webDefaultSessionRememberDays = 7 - webMinPasswordLength = 10 + webMinPasswordLength = 6 + webAuthPasswordEnvName = "GONAVI_WEB_PASSWORD" webTOTPPeriodSeconds = 30 webTOTPDigits = 6 ) @@ -52,6 +54,7 @@ var ( errWebAuthInvalidSetup = errors.New("invalid setup token") errWebAuthInvalidCredentials = errors.New("invalid credentials") errWebAuthRateLimited = errors.New("too many login attempts") + errWebAuthPasswordManaged = errors.New("web auth password is managed by environment") ) type webAuthConfig struct { @@ -337,13 +340,14 @@ func (m *webSessionManager) DestroyAll() { } type webAuthManager struct { - mu sync.RWMutex - store *webAuthStore - config webAuthConfig - pending map[string]pendingSetup - sessions *webSessionManager - loginAttempts *loginAttemptTracker - now func() time.Time + mu sync.RWMutex + store *webAuthStore + config webAuthConfig + pending map[string]pendingSetup + sessions *webSessionManager + loginAttempts *loginAttemptTracker + now func() time.Time + passwordManagedByEnvironment bool } type webAuthStatus struct { @@ -356,29 +360,75 @@ type webAuthStatus struct { } type webAuthSettingsSummary struct { - Configured bool `json:"configured"` - TOTPEnabled bool `json:"totpEnabled"` - RecoveryCodesRemaining int `json:"recoveryCodesRemaining"` - SessionIdleMinutes int `json:"sessionIdleMinutes,omitempty"` - SessionAbsoluteHours int `json:"sessionAbsoluteHours,omitempty"` - SessionRememberDays int `json:"sessionRememberDays,omitempty"` - UpdatedAt string `json:"updatedAt,omitempty"` + Configured bool `json:"configured"` + TOTPEnabled bool `json:"totpEnabled"` + RecoveryCodesRemaining int `json:"recoveryCodesRemaining"` + SessionIdleMinutes int `json:"sessionIdleMinutes,omitempty"` + SessionAbsoluteHours int `json:"sessionAbsoluteHours,omitempty"` + SessionRememberDays int `json:"sessionRememberDays,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` + PasswordManagedByEnvironment bool `json:"passwordManagedByEnvironment"` } func newWebAuthManager(root string) (*webAuthManager, error) { + return newWebAuthManagerWithPassword(root, "") +} + +func newWebAuthManagerFromEnvironment(root string) (*webAuthManager, error) { + return newWebAuthManagerWithPassword(root, os.Getenv(webAuthPasswordEnvName)) +} + +func newWebAuthManagerWithPassword(root string, configuredPassword string) (*webAuthManager, error) { store := newWebAuthStore(root) cfg, err := store.Load() if err != nil { return nil, err } - return &webAuthManager{ + manager := &webAuthManager{ store: store, config: cfg, pending: make(map[string]pendingSetup), sessions: newWebSessionManager(), loginAttempts: newLoginAttemptTracker(), now: time.Now, - }, nil + } + if err := manager.applyEnvironmentPassword(configuredPassword); err != nil { + return nil, err + } + return manager, nil +} + +func (m *webAuthManager) applyEnvironmentPassword(password string) error { + if strings.TrimSpace(password) == "" { + return nil + } + normalizedPassword, err := normalizeWebAuthPassword(password) + if err != nil { + return fmt.Errorf("%s: %w", webAuthPasswordEnvName, err) + } + m.passwordManagedByEnvironment = true + + m.mu.Lock() + defer m.mu.Unlock() + cfg := cloneWebAuthConfig(m.config) + if cfg.IsConfigured() && verifyPassword(cfg.PasswordHash, normalizedPassword) { + return nil + } + passwordHash, err := hashPassword(normalizedPassword) + if err != nil { + return fmt.Errorf("%s: %w", webAuthPasswordEnvName, err) + } + if !cfg.IsConfigured() { + cfg = normalizeWebAuthConfig(webAuthConfig{Enabled: true}) + } + cfg.Enabled = true + cfg.PasswordHash = passwordHash + cfg.UpdatedAt = m.now().UTC().Format(time.RFC3339) + if err := m.store.Save(cfg); err != nil { + return fmt.Errorf("persist %s: %w", webAuthPasswordEnvName, err) + } + m.config = cfg + return nil } func (m *webAuthManager) currentConfig() webAuthConfig { @@ -395,15 +445,16 @@ func cloneWebAuthConfig(cfg webAuthConfig) webAuthConfig { return copied } -func buildWebAuthSettingsSummary(cfg webAuthConfig) webAuthSettingsSummary { +func buildWebAuthSettingsSummary(cfg webAuthConfig, passwordManagedByEnvironment bool) webAuthSettingsSummary { return webAuthSettingsSummary{ - Configured: cfg.IsConfigured(), - TOTPEnabled: cfg.TOTPEnabled, - RecoveryCodesRemaining: len(cfg.RecoveryCodeHashes), - SessionIdleMinutes: cfg.SessionIdleMinutes, - SessionAbsoluteHours: cfg.SessionAbsoluteHours, - SessionRememberDays: cfg.SessionRememberDays, - UpdatedAt: strings.TrimSpace(cfg.UpdatedAt), + Configured: cfg.IsConfigured(), + TOTPEnabled: cfg.TOTPEnabled, + RecoveryCodesRemaining: len(cfg.RecoveryCodeHashes), + SessionIdleMinutes: cfg.SessionIdleMinutes, + SessionAbsoluteHours: cfg.SessionAbsoluteHours, + SessionRememberDays: cfg.SessionRememberDays, + UpdatedAt: strings.TrimSpace(cfg.UpdatedAt), + PasswordManagedByEnvironment: passwordManagedByEnvironment, } } @@ -427,7 +478,7 @@ func (m *webAuthManager) Settings() (webAuthSettingsSummary, error) { if !cfg.IsConfigured() { return webAuthSettingsSummary{}, errWebAuthNotConfigured } - return buildWebAuthSettingsSummary(cfg), nil + return buildWebAuthSettingsSummary(cfg, m.passwordManagedByEnvironment), nil } func (m *webAuthManager) BeginSetup(host string) (pendingSetupResponse, error) { @@ -497,9 +548,9 @@ func (m *webAuthManager) CompleteSetup(token string, password string, code strin delete(m.pending, strings.TrimSpace(token)) return webAuthConfig{}, "", errWebAuthSetupExpired } - normalizedPassword := strings.TrimSpace(password) - if len(normalizedPassword) < webMinPasswordLength { - return webAuthConfig{}, "", fmt.Errorf("password must be at least %d characters", webMinPasswordLength) + normalizedPassword, err := normalizeWebAuthPassword(password) + if err != nil { + return webAuthConfig{}, "", err } if enableTOTP && !validateTOTPCode(setup.Secret, code, m.now()) { return webAuthConfig{}, "", fmt.Errorf("invalid google authenticator code") @@ -588,6 +639,9 @@ func (m *webAuthManager) Logout(sessionID string) { } func (m *webAuthManager) ChangePassword(currentPassword string, code string, nextPassword string) (webAuthConfig, string, bool, error) { + if m.passwordManagedByEnvironment { + return webAuthConfig{}, "", false, errWebAuthPasswordManaged + } now := m.now() m.mu.Lock() @@ -792,11 +846,22 @@ func generateTOTPCodeAt(secret string, now time.Time) (string, error) { return fmt.Sprintf("%06d", code), nil } -func hashPassword(password string) (string, error) { +func normalizeWebAuthPassword(password string) (string, error) { normalized := strings.TrimSpace(password) if normalized == "" { return "", fmt.Errorf("password is required") } + if utf8.RuneCountInString(normalized) < webMinPasswordLength { + return "", fmt.Errorf("password must be at least %d characters", webMinPasswordLength) + } + return normalized, nil +} + +func hashPassword(password string) (string, error) { + normalized, err := normalizeWebAuthPassword(password) + if err != nil { + return "", err + } salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return "", err diff --git a/internal/webserver/auth_pages.go b/internal/webserver/auth_pages.go index e6f29bc8..0987c69e 100644 --- a/internal/webserver/auth_pages.go +++ b/internal/webserver/auth_pages.go @@ -208,6 +208,8 @@ func (s *Server) handleAuthPasswordChange(w http.ResponseWriter, r *http.Request status = http.StatusPreconditionFailed case errors.Is(err, errWebAuthInvalidCredentials): status = http.StatusUnauthorized + case errors.Is(err, errWebAuthPasswordManaged): + status = http.StatusConflict } s.writeAuthJSONError(w, status, localizeWebAuthError(localizer, err), 0) return @@ -217,7 +219,7 @@ func (s *Server) handleAuthPasswordChange(w http.ResponseWriter, r *http.Request s.writeJSON(w, http.StatusOK, map[string]any{ "success": true, "usedRecoveryCode": usedRecoveryCode, - "settings": buildWebAuthSettingsSummary(cfg), + "settings": buildWebAuthSettingsSummary(cfg, s.auth.passwordManagedByEnvironment), }) } @@ -363,6 +365,8 @@ func localizeWebAuthError(localizer *i18n.Localizer, err error) string { return webAuthText(localizer, "web_auth.error.invalid_password_or_code", nil) case errors.Is(err, errWebAuthRateLimited): return webAuthText(localizer, "web_auth.error.too_many_login_attempts", nil) + case errors.Is(err, errWebAuthPasswordManaged): + return webAuthText(localizer, "web_auth.error.password_managed_by_environment", nil) } message := strings.TrimSpace(err.Error()) @@ -1241,7 +1245,7 @@ function validateStep(stepIndex) { showError(i18n.passwordRequired); return false; } - if (password.length < ` + fmt.Sprintf("%d", webMinPasswordLength) + `) { + if (Array.from(password).length < ` + fmt.Sprintf("%d", webMinPasswordLength) + `) { showError(i18n.passwordTooShort); return false; } diff --git a/internal/webserver/auth_pages_test.go b/internal/webserver/auth_pages_test.go index c6f9b551..09e565a1 100644 --- a/internal/webserver/auth_pages_test.go +++ b/internal/webserver/auth_pages_test.go @@ -59,6 +59,7 @@ func TestRenderAuthPageUsesLocalizedCopyAndSolidColors(t *testing.T) { "初始化 GoNavi Web", "发行方", "账户", + "至少 6 位", } { if !strings.Contains(page, fragment) { t.Fatalf("expected rendered page to contain %q", fragment) @@ -85,4 +86,10 @@ func TestRenderAuthPageUsesLocalizedCopyAndSolidColors(t *testing.T) { t.Fatalf("expected setup wizard page to contain %q", fragment) } } + if strings.Contains(page, "至少 10 位") { + t.Fatal("expected setup page to stop advertising the old ten-character minimum") + } + if !strings.Contains(page, "Array.from(password).length < 6") { + t.Fatal("expected setup page to count visible password characters consistently with the backend") + } } diff --git a/internal/webserver/auth_test.go b/internal/webserver/auth_test.go index 6d9d650d..d751b534 100644 --- a/internal/webserver/auth_test.go +++ b/internal/webserver/auth_test.go @@ -2,6 +2,7 @@ package webserver import ( "errors" + "os" "strings" "testing" "time" @@ -94,6 +95,167 @@ func TestGenerateQRCodeDataURL(t *testing.T) { } } +func TestWebAuthManagerEnforcesSixCharacterPasswords(t *testing.T) { + if _, err := normalizeWebAuthPassword("密码"); err == nil { + t.Fatal("expected two visible characters to remain below the six-character minimum") + } + if _, err := normalizeWebAuthPassword("密码设置安全"); err != nil { + t.Fatalf("expected six Unicode characters to satisfy the password minimum, got %v", err) + } + + manager, err := newWebAuthManager(t.TempDir()) + if err != nil { + t.Fatalf("newWebAuthManager failed: %v", err) + } + setup, err := manager.BeginSetup("127.0.0.1:34116") + if err != nil { + t.Fatalf("BeginSetup failed: %v", err) + } + + if _, _, err := manager.CompleteSetup(setup.SetupToken, "12345", "", false, 30, 24, 7); err == nil || !strings.Contains(err.Error(), "at least 6") { + t.Fatalf("expected five-character setup password to be rejected, got %v", err) + } + cfg, _, err := manager.CompleteSetup(setup.SetupToken, "123456", "", false, 30, 24, 7) + if err != nil { + t.Fatalf("expected six-character setup password to succeed, got %v", err) + } + if !verifyPassword(cfg.PasswordHash, "123456") { + t.Fatal("expected six-character setup password to be persisted") + } + + if _, _, _, err := manager.ChangePassword("123456", "", "65432"); err == nil || !strings.Contains(err.Error(), "at least 6") { + t.Fatalf("expected five-character replacement password to be rejected, got %v", err) + } + if !verifyPassword(manager.currentConfig().PasswordHash, "123456") { + t.Fatal("expected rejected password change to preserve the current password") + } + nextCfg, _, _, err := manager.ChangePassword("123456", "", "654321") + if err != nil { + t.Fatalf("expected six-character replacement password to succeed, got %v", err) + } + if !verifyPassword(nextCfg.PasswordHash, "654321") { + t.Fatal("expected six-character replacement password to be persisted") + } +} + +func TestNewWebAuthManagerFromEnvironmentInitializesPassword(t *testing.T) { + root := t.TempDir() + t.Setenv(webAuthPasswordEnvName, "123456") + + manager, err := newWebAuthManagerFromEnvironment(root) + if err != nil { + t.Fatalf("newWebAuthManagerFromEnvironment failed: %v", err) + } + if status := manager.Status(""); !status.Configured { + t.Fatalf("expected environment password to configure web auth, got %+v", status) + } + settings, err := manager.Settings() + if err != nil { + t.Fatalf("read environment-managed settings failed: %v", err) + } + if !settings.PasswordManagedByEnvironment { + t.Fatal("expected settings to report that the password is managed by environment") + } + if _, _, _, _, err := manager.Login("123456", "", "127.0.0.1"); err != nil { + t.Fatalf("expected login with environment password to succeed, got %v", err) + } + if _, _, _, err := manager.ChangePassword("123456", "", "654321"); !errors.Is(err, errWebAuthPasswordManaged) { + t.Fatalf("expected environment-managed password change to be rejected, got %v", err) + } + payload, err := os.ReadFile(manager.store.path) + if err != nil { + t.Fatalf("read persisted web auth config failed: %v", err) + } + if strings.Contains(string(payload), "123456") { + t.Fatal("environment password must not be persisted in plaintext") + } + + t.Setenv(webAuthPasswordEnvName, "") + restarted, err := newWebAuthManagerFromEnvironment(root) + if err != nil { + t.Fatalf("restart after clearing environment password failed: %v", err) + } + settings, err = restarted.Settings() + if err != nil { + t.Fatalf("read settings after clearing environment password failed: %v", err) + } + if settings.PasswordManagedByEnvironment { + t.Fatal("expected password management to return to the settings UI after clearing the environment variable") + } + if _, _, _, _, err := restarted.Login("123456", "", "127.0.0.1"); err != nil { + t.Fatalf("expected the last synchronized password to remain active, got %v", err) + } +} + +func TestNewWebAuthManagerFromEnvironmentOverridesPasswordAndPreservesSettings(t *testing.T) { + root := t.TempDir() + manager, err := newWebAuthManager(root) + if err != nil { + t.Fatalf("newWebAuthManager failed: %v", err) + } + now := time.Unix(1_720_000_321, 0).UTC() + manager.now = func() time.Time { return now } + setup, err := manager.BeginSetup("127.0.0.1:34116") + if err != nil { + t.Fatalf("BeginSetup failed: %v", err) + } + code, err := generateTOTPCodeAt(setup.Secret, now) + if err != nil { + t.Fatalf("generateTOTPCodeAt failed: %v", err) + } + original, _, err := manager.CompleteSetup(setup.SetupToken, "old-password", code, true, 45, 48, 14) + if err != nil { + t.Fatalf("CompleteSetup failed: %v", err) + } + + t.Setenv(webAuthPasswordEnvName, "654321") + restarted, err := newWebAuthManagerFromEnvironment(root) + if err != nil { + t.Fatalf("restart with environment password failed: %v", err) + } + updated := restarted.currentConfig() + if verifyPassword(updated.PasswordHash, "old-password") || !verifyPassword(updated.PasswordHash, "654321") { + t.Fatal("expected environment password to replace the persisted password") + } + if !updated.TOTPEnabled || updated.TOTPSecret != original.TOTPSecret || len(updated.RecoveryCodeHashes) != len(original.RecoveryCodeHashes) { + t.Fatal("expected environment password update to preserve TOTP settings") + } + if updated.SessionIdleMinutes != 45 || updated.SessionAbsoluteHours != 48 || updated.SessionRememberDays != 14 { + t.Fatalf("expected environment password update to preserve session settings, got %+v", updated) + } + settings, err := restarted.Settings() + if err != nil || !settings.PasswordManagedByEnvironment { + t.Fatalf("expected restarted settings to report environment management, settings=%+v err=%v", settings, err) + } + + before, err := os.ReadFile(restarted.store.path) + if err != nil { + t.Fatalf("read updated web auth config failed: %v", err) + } + if _, err := newWebAuthManagerFromEnvironment(root); err != nil { + t.Fatalf("restart with unchanged environment password failed: %v", err) + } + after, err := os.ReadFile(restarted.store.path) + if err != nil { + t.Fatalf("read unchanged web auth config failed: %v", err) + } + if string(before) != string(after) { + t.Fatal("unchanged environment password must not rewrite the persisted config") + } +} + +func TestNewWebAuthManagerFromEnvironmentRejectsShortPassword(t *testing.T) { + t.Setenv(webAuthPasswordEnvName, "12345") + + _, err := newWebAuthManagerFromEnvironment(t.TempDir()) + if err == nil || !strings.Contains(err.Error(), webAuthPasswordEnvName) || !strings.Contains(err.Error(), "at least 6") { + t.Fatalf("expected short environment password error, got %v", err) + } + if strings.Contains(err.Error(), "12345") { + t.Fatal("environment password error must not expose the password") + } +} + func TestWebAuthManagerChangePasswordRotatesSession(t *testing.T) { manager, err := newWebAuthManager(t.TempDir()) if err != nil { diff --git a/internal/webserver/server.go b/internal/webserver/server.go index c26c94f7..4e25672d 100644 --- a/internal/webserver/server.go +++ b/internal/webserver/server.go @@ -250,7 +250,7 @@ func New(ctx context.Context, assetFS fs.FS, options Options) (*Server, error) { appcore.InitializeLifecycle(app, lifecycleCtx) ai := aiservice.NewService() aiservice.InitializeLifecycle(ai, lifecycleCtx) - auth, err := newWebAuthManager("") + auth, err := newWebAuthManagerFromEnvironment("") if err != nil { return nil, fmt.Errorf("initialize web auth failed: %w", err) } diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index eada56f4..28335227 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -2683,8 +2683,9 @@ "app.settings.web_auth.password.current_placeholder": "Aktuelles Administratorpasswort eingeben", "app.settings.web_auth.password.description": "Beim Ändern des Passworts wird die aktuelle Web-Sitzung rotiert und andere aktive Sitzungen werden abgemeldet.", "app.settings.web_auth.password.description_with_code": "Beim Ändern des Passworts wird die aktuelle Web-Sitzung rotiert und andere aktive Sitzungen werden abgemeldet. Wenn 2FA aktiviert ist, geben Sie zusätzlich den aktuellen Code oder einen Wiederherstellungscode ein.", + "app.settings.web_auth.password.managed_by_environment": "Das Administratorpasswort wird über GONAVI_WEB_PASSWORD verwaltet. Erstellen Sie bei Container-Bereitstellungen den Container neu, andernfalls starten Sie den Webserver neu.", "app.settings.web_auth.password.new_label": "Neues Administratorpasswort", - "app.settings.web_auth.password.new_placeholder": "Mindestens 10 Zeichen", + "app.settings.web_auth.password.new_placeholder": "Mindestens 6 Zeichen", "app.settings.web_auth.password.save_failed": "Administratorpasswort konnte nicht aktualisiert werden", "app.settings.web_auth.password.save_success": "Administratorpasswort wurde aktualisiert", "app.settings.web_auth.password.submit": "Neues Passwort speichern", @@ -7942,6 +7943,7 @@ "web_auth.error.method_not_allowed": "Methode nicht erlaubt", "web_auth.error.password_confirmation_mismatch": "Die Passwortbestätigung stimmt nicht überein", "web_auth.error.password_min_length": "Das Passwort muss mindestens {{count}} Zeichen lang sein", + "web_auth.error.password_managed_by_environment": "Das Administratorpasswort wird über eine Umgebungsvariable verwaltet", "web_auth.error.password_required": "Admin-Passwort ist erforderlich", "web_auth.error.retry_after_seconds": ". Bitte in {{seconds}} Sekunden erneut versuchen", "web_auth.error.setup_info_expired": "Die Initialisierungsdaten sind abgelaufen. Laden Sie die Seite neu und versuchen Sie es erneut.", @@ -7980,7 +7982,7 @@ "web_auth.page.setup.next": "Weiter", "web_auth.page.setup.otpauth_label": "otpauth-URI", "web_auth.page.setup.password_label": "Admin-Passwort", - "web_auth.page.setup.password_placeholder": "Mindestens 10 Zeichen", + "web_auth.page.setup.password_placeholder": "Mindestens 6 Zeichen", "web_auth.page.setup.qr_alt": "Google-Authenticator-QR-Code", "web_auth.page.setup.recovery_intro": "Wiederherstellungscodes werden nur dieses eine Mal angezeigt. Bitte offline speichern:", "web_auth.page.setup.remember_label": "Anmeldung merken (Tage)", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 6b3a761f..c4a797d9 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -2683,8 +2683,9 @@ "app.settings.web_auth.password.current_placeholder": "Enter the current admin password", "app.settings.web_auth.password.description": "Changing the password rotates the current web session and signs out other active sessions.", "app.settings.web_auth.password.description_with_code": "Changing the password rotates the current web session and signs out other active sessions. When 2FA is enabled, enter the current verification code or a recovery code too.", + "app.settings.web_auth.password.managed_by_environment": "The admin password is managed by GONAVI_WEB_PASSWORD. Recreate the container for container deployments, or restart the Web server otherwise.", "app.settings.web_auth.password.new_label": "New admin password", - "app.settings.web_auth.password.new_placeholder": "At least 10 characters", + "app.settings.web_auth.password.new_placeholder": "At least 6 characters", "app.settings.web_auth.password.save_failed": "Failed to update the admin password", "app.settings.web_auth.password.save_success": "Admin password updated", "app.settings.web_auth.password.submit": "Save New Password", @@ -7942,6 +7943,7 @@ "web_auth.error.method_not_allowed": "Method not allowed", "web_auth.error.password_confirmation_mismatch": "Password confirmation does not match", "web_auth.error.password_min_length": "Password must be at least {{count}} characters", + "web_auth.error.password_managed_by_environment": "The admin password is managed by an environment variable", "web_auth.error.password_required": "Admin password is required", "web_auth.error.retry_after_seconds": ". Try again in {{seconds}} seconds", "web_auth.error.setup_info_expired": "Setup information expired. Refresh the page and try again.", @@ -7980,7 +7982,7 @@ "web_auth.page.setup.next": "Next", "web_auth.page.setup.otpauth_label": "otpauth URI", "web_auth.page.setup.password_label": "Admin password", - "web_auth.page.setup.password_placeholder": "At least 10 characters", + "web_auth.page.setup.password_placeholder": "At least 6 characters", "web_auth.page.setup.qr_alt": "Google Authenticator QR code", "web_auth.page.setup.recovery_intro": "Recovery codes are shown only once. Save them offline:", "web_auth.page.setup.remember_label": "Remember sign-in (days)", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index fcf278f7..b409dad1 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -2683,8 +2683,9 @@ "app.settings.web_auth.password.current_placeholder": "現在の管理者パスワードを入力", "app.settings.web_auth.password.description": "パスワードを変更すると現在の Web セッションが更新され、ほかのログイン中セッションは無効になります。", "app.settings.web_auth.password.description_with_code": "パスワードを変更すると現在の Web セッションが更新され、ほかのログイン中セッションは無効になります。2FA が有効な場合は、現在の認証コードまたはリカバリーコードも入力してください。", + "app.settings.web_auth.password.managed_by_environment": "管理者パスワードは GONAVI_WEB_PASSWORD で管理されています。コンテナ環境ではコンテナを再作成し、それ以外では Web サーバーを再起動してください。", "app.settings.web_auth.password.new_label": "新しい管理者パスワード", - "app.settings.web_auth.password.new_placeholder": "10 文字以上", + "app.settings.web_auth.password.new_placeholder": "6 文字以上", "app.settings.web_auth.password.save_failed": "管理者パスワードの更新に失敗しました", "app.settings.web_auth.password.save_success": "管理者パスワードを更新しました", "app.settings.web_auth.password.submit": "新しいパスワードを保存", @@ -7942,6 +7943,7 @@ "web_auth.error.method_not_allowed": "許可されていないメソッドです", "web_auth.error.password_confirmation_mismatch": "確認用パスワードが一致しません", "web_auth.error.password_min_length": "パスワードは {{count}} 文字以上必要です", + "web_auth.error.password_managed_by_environment": "管理者パスワードは環境変数で管理されています", "web_auth.error.password_required": "管理者パスワードを入力してください", "web_auth.error.retry_after_seconds": "。{{seconds}} 秒後に再試行してください", "web_auth.error.setup_info_expired": "初期化情報の有効期限が切れました。ページを再読み込みして再試行してください。", @@ -7980,7 +7982,7 @@ "web_auth.page.setup.next": "次へ", "web_auth.page.setup.otpauth_label": "otpauth URI", "web_auth.page.setup.password_label": "管理者パスワード", - "web_auth.page.setup.password_placeholder": "10 文字以上", + "web_auth.page.setup.password_placeholder": "6 文字以上", "web_auth.page.setup.qr_alt": "Google Authenticator QR コード", "web_auth.page.setup.recovery_intro": "リカバリーコードは今回のみ表示されます。オフラインで保存してください:", "web_auth.page.setup.remember_label": "ログインを記憶(日)", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 421d7d7b..95f93532 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -2683,8 +2683,9 @@ "app.settings.web_auth.password.current_placeholder": "Введите текущий пароль администратора", "app.settings.web_auth.password.description": "После смены пароля текущая веб-сессия будет обновлена, а остальные активные сессии будут завершены.", "app.settings.web_auth.password.description_with_code": "После смены пароля текущая веб-сессия будет обновлена, а остальные активные сессии будут завершены. Если включена 2FA, также введите текущий код или код восстановления.", + "app.settings.web_auth.password.managed_by_environment": "Пароль администратора управляется через GONAVI_WEB_PASSWORD. Для контейнерного развёртывания пересоздайте контейнер, иначе перезапустите веб-сервер.", "app.settings.web_auth.password.new_label": "Новый пароль администратора", - "app.settings.web_auth.password.new_placeholder": "Не менее 10 символов", + "app.settings.web_auth.password.new_placeholder": "Не менее 6 символов", "app.settings.web_auth.password.save_failed": "Не удалось обновить пароль администратора", "app.settings.web_auth.password.save_success": "Пароль администратора обновлен", "app.settings.web_auth.password.submit": "Сохранить новый пароль", @@ -7942,6 +7943,7 @@ "web_auth.error.method_not_allowed": "Метод не поддерживается", "web_auth.error.password_confirmation_mismatch": "Подтверждение пароля не совпадает", "web_auth.error.password_min_length": "Пароль должен содержать не менее {{count}} символов", + "web_auth.error.password_managed_by_environment": "Пароль администратора управляется переменной окружения", "web_auth.error.password_required": "Требуется пароль администратора", "web_auth.error.retry_after_seconds": ". Повторите попытку через {{seconds}} с", "web_auth.error.setup_info_expired": "Данные инициализации устарели. Обновите страницу и попробуйте снова.", @@ -7980,7 +7982,7 @@ "web_auth.page.setup.next": "Далее", "web_auth.page.setup.otpauth_label": "otpauth URI", "web_auth.page.setup.password_label": "Пароль администратора", - "web_auth.page.setup.password_placeholder": "Не менее 10 символов", + "web_auth.page.setup.password_placeholder": "Не менее 6 символов", "web_auth.page.setup.qr_alt": "QR-код Google Authenticator", "web_auth.page.setup.recovery_intro": "Коды восстановления показываются только один раз. Сохраните их офлайн:", "web_auth.page.setup.remember_label": "Запомнить вход (дни)", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 8564b4d6..ba228efa 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -2683,8 +2683,9 @@ "app.settings.web_auth.password.current_placeholder": "输入当前管理员密码", "app.settings.web_auth.password.description": "修改密码后会轮换当前 Web 会话,其他已登录会话会失效。", "app.settings.web_auth.password.description_with_code": "修改密码后会轮换当前 Web 会话,其他已登录会话会失效。已启用 2FA 时,还需要输入当前验证码或恢复码。", + "app.settings.web_auth.password.managed_by_environment": "管理员密码由 GONAVI_WEB_PASSWORD 管理。容器部署请重新创建容器,其他部署请重启 Web 服务。", "app.settings.web_auth.password.new_label": "新管理员密码", - "app.settings.web_auth.password.new_placeholder": "至少 10 位", + "app.settings.web_auth.password.new_placeholder": "至少 6 位", "app.settings.web_auth.password.save_failed": "修改管理员密码失败", "app.settings.web_auth.password.save_success": "管理员密码已更新", "app.settings.web_auth.password.submit": "保存新密码", @@ -7942,6 +7943,7 @@ "web_auth.error.method_not_allowed": "请求方法不允许", "web_auth.error.password_confirmation_mismatch": "两次输入的密码不一致", "web_auth.error.password_min_length": "密码长度至少为 {{count}} 位", + "web_auth.error.password_managed_by_environment": "管理员密码由环境变量管理", "web_auth.error.password_required": "管理员密码不能为空", "web_auth.error.retry_after_seconds": ",请在 {{seconds}} 秒后重试", "web_auth.error.setup_info_expired": "初始化信息已失效,请刷新页面后重试", @@ -7980,7 +7982,7 @@ "web_auth.page.setup.next": "下一步", "web_auth.page.setup.otpauth_label": "otpauth URI", "web_auth.page.setup.password_label": "管理员密码", - "web_auth.page.setup.password_placeholder": "至少 10 位", + "web_auth.page.setup.password_placeholder": "至少 6 位", "web_auth.page.setup.qr_alt": "Google Authenticator 二维码", "web_auth.page.setup.recovery_intro": "恢复码仅显示这一次,建议离线保存:", "web_auth.page.setup.remember_label": "记住登录(天)", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 5f472ca6..d75fa51e 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -2683,8 +2683,9 @@ "app.settings.web_auth.password.current_placeholder": "輸入目前管理員密碼", "app.settings.web_auth.password.description": "修改密碼後會輪換目前 Web 工作階段,其他已登入工作階段會失效。", "app.settings.web_auth.password.description_with_code": "修改密碼後會輪換目前 Web 工作階段,其他已登入工作階段會失效。啟用 2FA 時,還需要輸入目前驗證碼或恢復碼。", + "app.settings.web_auth.password.managed_by_environment": "管理員密碼由 GONAVI_WEB_PASSWORD 管理。容器部署請重新建立容器,其他部署請重新啟動 Web 服務。", "app.settings.web_auth.password.new_label": "新管理員密碼", - "app.settings.web_auth.password.new_placeholder": "至少 10 位", + "app.settings.web_auth.password.new_placeholder": "至少 6 位", "app.settings.web_auth.password.save_failed": "修改管理員密碼失敗", "app.settings.web_auth.password.save_success": "管理員密碼已更新", "app.settings.web_auth.password.submit": "儲存新密碼", @@ -7942,6 +7943,7 @@ "web_auth.error.method_not_allowed": "不允許的請求方法", "web_auth.error.password_confirmation_mismatch": "兩次輸入的密碼不一致", "web_auth.error.password_min_length": "密碼長度至少為 {{count}} 位", + "web_auth.error.password_managed_by_environment": "管理員密碼由環境變數管理", "web_auth.error.password_required": "管理員密碼不能為空", "web_auth.error.retry_after_seconds": ",請在 {{seconds}} 秒後重試", "web_auth.error.setup_info_expired": "初始化資訊已失效,請重新整理頁面後再試", @@ -7980,7 +7982,7 @@ "web_auth.page.setup.next": "下一步", "web_auth.page.setup.otpauth_label": "otpauth URI", "web_auth.page.setup.password_label": "管理員密碼", - "web_auth.page.setup.password_placeholder": "至少 10 位", + "web_auth.page.setup.password_placeholder": "至少 6 位", "web_auth.page.setup.qr_alt": "Google Authenticator QR Code", "web_auth.page.setup.recovery_intro": "復原碼只會顯示這一次,建議離線保存:", "web_auth.page.setup.remember_label": "記住登入(天)", diff --git a/tools/validate-web-server-dockerfile.sh b/tools/validate-web-server-dockerfile.sh index 927a9fcb..1513ee8e 100755 --- a/tools/validate-web-server-dockerfile.sh +++ b/tools/validate-web-server-dockerfile.sh @@ -5,6 +5,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" DOCKERFILE="${ROOT}/Dockerfile.web-server" MCP_DOCKERFILE="${ROOT}/Dockerfile.mcp-server" +COMPOSE_FILE="${ROOT}/docker-compose.web-server.yml" +ENV_EXAMPLE="${ROOT}/docker.web-server.env.example" if [[ ! -f "${DOCKERFILE}" ]]; then echo "missing Dockerfile.web-server" >&2 @@ -14,6 +16,10 @@ if [[ ! -f "${MCP_DOCKERFILE}" ]]; then echo "missing Dockerfile.mcp-server" >&2 exit 1 fi +if [[ ! -f "${COMPOSE_FILE}" || ! -f "${ENV_EXAMPLE}" ]]; then + echo "missing web-server Compose or env example" >&2 + exit 1 +fi fail() { echo "validate-web-server-dockerfile: $*" >&2 @@ -62,5 +68,9 @@ grep -Fq '../../../shared/i18n/' "${FRONTEND_CATALOG}" \ assert_target_driver_revisions_generated_before_build "${DOCKERFILE}" "Dockerfile.web-server" assert_target_driver_revisions_generated_before_build "${MCP_DOCKERFILE}" "Dockerfile.mcp-server" +grep -Fq 'GONAVI_WEB_PASSWORD: ${GONAVI_WEB_PASSWORD:-}' "${COMPOSE_FILE}" \ + || fail "docker-compose.web-server.yml must forward GONAVI_WEB_PASSWORD" +grep -Eq '^GONAVI_WEB_PASSWORD=$' "${ENV_EXAMPLE}" \ + || fail "docker.web-server.env.example must declare an empty GONAVI_WEB_PASSWORD" echo "validate-web-server-dockerfile: ok"