From 484de2e6981341d91114398dace44307b537c64d Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Thu, 27 Aug 2026 16:17:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix=F0=9F=94=92:=20stop=20writing=20the=20d?= =?UTF-8?q?atabase=20password=20into=20the=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup line printed the DSN whole: * => goadmin:@tcp(host:3306)/go-admin?... So every deployment wrote its own database credential into its own logs, where a log shipper, a support bundle or a screenshot of a terminal carries it onward. Found while reading deploy output, which is exactly how it leaks. The host and username stay - they are what makes the line worth printing - and only the password is replaced. Both DSN shapes this project accepts are covered, a sqlite path is left alone, and anything unparseable is withheld rather than echoed, since it may hold a credential too. --- common/database/dsn.go | 47 ++++++++++++++++++++++++++++ common/database/dsn_test.go | 58 +++++++++++++++++++++++++++++++++++ common/database/initialize.go | 2 +- 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 common/database/dsn.go create mode 100644 common/database/dsn_test.go diff --git a/common/database/dsn.go b/common/database/dsn.go new file mode 100644 index 00000000..c64ac016 --- /dev/null +++ b/common/database/dsn.go @@ -0,0 +1,47 @@ +package database + +import ( + "net/url" + "regexp" + "strings" +) + +// A DSN carries the database password, and the startup line used to print it +// whole. Anyone with the log file, a log shipper, or a screenshot of a terminal +// had the credential. +// +// Both shapes this project accepts are covered: +// +// mysql user:password@tcp(host:3306)/db?params +// postgres postgres://user:password@host:5432/db?params +var mysqlDSN = regexp.MustCompile(`^([^:/@]+):([^@]*)@`) + +// redactDSN returns a DSN safe to log: everything but the password. +func redactDSN(dsn string) string { + if dsn == "" { + return "" + } + + // URL form, used by postgres and sqlserver. + if strings.Contains(dsn, "://") { + u, err := url.Parse(dsn) + if err != nil { + // Unparseable and possibly holding a password: say nothing about it. + return "[dsn]" + } + if u.User != nil { + if _, hasPassword := u.User.Password(); hasPassword { + u.User = url.UserPassword(u.User.Username(), "***") + } + } + return u.String() + } + + // user:password@tcp(...) form, used by mysql. + if m := mysqlDSN.FindStringSubmatch(dsn); m != nil { + return m[1] + ":***@" + dsn[len(m[0]):] + } + + // No credential recognised - a sqlite path, for instance. + return dsn +} diff --git a/common/database/dsn_test.go b/common/database/dsn_test.go new file mode 100644 index 00000000..e3c395d6 --- /dev/null +++ b/common/database/dsn_test.go @@ -0,0 +1,58 @@ +package database + +import ( + "strings" + "testing" +) + +// The startup line printed the DSN whole, so every deployment wrote its +// database password into its own logs - readable by anyone with the log file, a +// log shipper, or a screenshot of the terminal. +func TestRedactDSNKeepsThePasswordOut(t *testing.T) { + const secret = "s3cr3t-do-not-log" + + cases := map[string]string{ + "mysql": "goadmin:" + secret + "@tcp(db.example.com:3306)/go-admin?charset=utf8mb4&parseTime=True", + "postgres": "postgres://goadmin:" + secret + "@db.example.com:5432/go-admin?sslmode=disable", + "sqlserver": "sqlserver://goadmin:" + secret + "@db.example.com:1433?database=go-admin", + "empty-ish pwd": "goadmin:@tcp(db.example.com:3306)/go-admin", + } + + for name, dsn := range cases { + t.Run(name, func(t *testing.T) { + got := redactDSN(dsn) + if strings.Contains(got, secret) { + t.Fatalf("password survived redaction: %s", got) + } + // Still has to be useful: the host is what makes the line worth logging. + if !strings.Contains(got, "db.example.com") { + t.Errorf("host was lost, the line no longer says anything: %s", got) + } + if !strings.Contains(got, "goadmin") { + t.Errorf("username was lost: %s", got) + } + }) + } +} + +// sqlite has no credential to hide, and its path is the useful part. +func TestRedactDSNLeavesAPathAlone(t *testing.T) { + const path = "./go-admin-db.db" + if got := redactDSN(path); got != path { + t.Errorf("redactDSN(%q) = %q, want it unchanged", path, got) + } +} + +// An unparseable string might still hold a password, so it is not echoed. +func TestRedactDSNSaysNothingAboutWhatItCannotParse(t *testing.T) { + got := redactDSN("://not a url at all:hunter2@") + if strings.Contains(got, "hunter2") { + t.Fatalf("password survived: %s", got) + } +} + +func TestRedactDSNHandlesEmpty(t *testing.T) { + if got := redactDSN(""); got != "" { + t.Errorf("redactDSN(\"\") = %q", got) + } +} diff --git a/common/database/initialize.go b/common/database/initialize.go index d08a1ec8..09fb5253 100644 --- a/common/database/initialize.go +++ b/common/database/initialize.go @@ -28,7 +28,7 @@ func setupSimpleDatabase(host string, c *toolsConfig.Database) { if global.Driver == "" { global.Driver = c.Driver } - log.Infof("%s => %s", host, pkg.Green(c.Source)) + log.Infof("%s => %s", host, pkg.Green(redactDSN(c.Source))) registers := make([]toolsDB.ResolverConfigure, len(c.Registers)) for i := range c.Registers { registers[i] = toolsDB.NewResolverConfigure( From 54ffaac9c567c6bf925e2ffad44c9348925085c4 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Thu, 27 Aug 2026 16:19:03 +0800 Subject: [PATCH 2/3] =?UTF-8?q?chore=F0=9F=94=A7:=20say=20which=20migratio?= =?UTF-8?q?n=20is=20running,=20not=20a=20column=20of=20ones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An applied migration printed its count - a bare '1' - so a database with seven of them wrote seven lines of '1' at every start, and a failure said only which error, never which migration. It now names each one as it applies, reports the total, and says so when there is nothing to do. --- cmd/migrate/migration/init.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/migrate/migration/init.go b/cmd/migrate/migration/init.go index 739e6b40..28f48e0d 100644 --- a/cmd/migrate/migration/init.go +++ b/cmd/migrate/migration/init.go @@ -43,20 +43,28 @@ func (e *Migration) Migrate() { } var err error var count int64 + applied := 0 for _, v := range versions { err = e.db.Table("sys_migration").Where("version = ?", v).Count(&count).Error if err != nil { log.Fatalln(err) } if count > 0 { - log.Println(count) + // Already applied. This used to print the bare count, so a mature + // database wrote a screen of "1" at every start. count = 0 continue } - err = (e.version[v])(e.db.Debug(), v) - if err != nil { - log.Fatalln(err) + log.Printf("applying migration %s", v) + if err = (e.version[v])(e.db.Debug(), v); err != nil { + log.Fatalf("migration %s failed: %v", v, err) } + applied++ + } + if applied == 0 { + log.Println("no migrations to apply") + } else { + log.Printf("applied %d migration(s)", applied) } } From f5273f5a587ad2a8b1a9347b87b02d13d39ff00b Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Thu, 27 Aug 2026 16:19:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?ci=F0=9F=94=A7:=20migrate=20before=20deploy?= =?UTF-8?q?ing,=20and=20roll=20back=20when=20the=20new=20version=20does=20?= =?UTF-8?q?not=20come=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #871. The deploy did docker rm -f then docker run. Nothing ran migrations, so new code met old tables, and nothing checked the result - a container that exits immediately left the site down with a green deploy. Now, in order: pull the image, run the migration with it, and only then touch what is running. A failed migration stops there, leaving old code with the old schema, which is at least self-consistent. The running container is renamed rather than removed, so it can be started again unchanged if the new one does not become healthy. Healthy means both an HTTP response and a database connection in the log: the captcha endpoint answers without touching the database, so it alone would call a container healthy that cannot reach MySQL. --- .github/workflows/build.yml | 56 ++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eb7e051f..0f3c0e8d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,10 +72,58 @@ jobs: # # 路径本身走 secret:它不是凭据,但本仓库公开,没有理由把服务器的 # 目录结构一并公布。DEMO_CONFIG_PATH 指向宿主机上那份配置。 + # + # 顺序是有意的:迁移先跑,跑不过就保持现有版本不动; + # 旧容器改名保留而不是删除,新容器不健康时能原样恢复。 + # 健康检查两条都要过——HTTP 活着不代表数据库通了。 script: | - test -f "${{ secrets.DEMO_CONFIG_PATH }}" || { echo "宿主机配置缺失,中止部署"; exit 1; } - sudo docker rm -f go-admin-api + set -u + CFG="${{ secrets.DEMO_CONFIG_PATH }}" + IMG="${{ env.IMAGE_NAME_TAG }}" + NAME=go-admin-api + PREV="$NAME-prev" + + test -f "$CFG" || { echo "宿主机配置缺失,中止部署"; exit 1; } + sudo docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }} + sudo docker pull "$IMG" || { echo "拉取镜像失败,中止部署"; exit 1; } + + # 迁移用新镜像跑。失败时线上仍是旧版本配旧 schema,是自洽的; + # 硬切过去才会得到代码与表对不上的服务。 + if ! sudo docker run --rm -v "$CFG":/config/settings.yml:ro "$IMG" \ + /main migrate -c /config/settings.yml; then + echo "迁移失败,保持现有版本"; exit 1 + fi + + if sudo docker ps -a --format '{{.Names}}' | grep -qx "$NAME"; then + sudo docker rm -f "$PREV" >/dev/null 2>&1 || true + sudo docker rename "$NAME" "$PREV" + sudo docker stop "$PREV" >/dev/null + fi + sudo docker run -d -p 8000:8000 \ - -v "${{ secrets.DEMO_CONFIG_PATH }}":/config/settings.yml:ro \ - --name go-admin-api ${{ env.IMAGE_NAME_TAG }} + -v "$CFG":/config/settings.yml:ro \ + --name "$NAME" "$IMG" + + ok=0 + for i in $(seq 1 20); do + sleep 3 + code=$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://127.0.0.1:8000/api/v1/captcha 2>/dev/null || true) + if [ "$code" = "200" ] && sudo docker logs "$NAME" 2>&1 | grep -q 'connect success'; then + ok=1; echo "健康检查通过(第 $i 次探测)"; break + fi + done + + if [ "$ok" = "1" ]; then + sudo docker rm -f "$PREV" >/dev/null 2>&1 || true + else + echo "健康检查失败,回滚到上一版本" + sudo docker logs --tail 40 "$NAME" 2>&1 || true + sudo docker rm -f "$NAME" >/dev/null 2>&1 || true + if sudo docker ps -a --format '{{.Names}}' | grep -qx "$PREV"; then + sudo docker rename "$PREV" "$NAME" + sudo docker start "$NAME" >/dev/null + echo "已恢复" + fi + exit 1 + fi