Merge pull request #880 from go-admin-team/fix/dsn-in-logs

ci🔧: migrate before deploying, roll back on failure — and stop logging the database password
This commit is contained in:
wenjianzhang
2026-08-27 17:31:52 +08:00
committed by GitHub
5 changed files with 170 additions and 9 deletions
+52 -4
View File
@@ -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
+12 -4
View File
@@ -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)
}
}
+47
View File
@@ -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
}
+58
View File
@@ -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)
}
}
+1 -1
View File
@@ -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(