From fab31ba4c71ab93ea68ee17ec00a93ba3b488568 Mon Sep 17 00:00:00 2001 From: ynui Date: Wed, 4 Nov 2026 21:55:04 +0200 Subject: [PATCH] ci: gate Release on tests + E2E, chain Pages after Release - ci.yml reusable via workflow_call; Release runs CI then Docker-based Postgres/MySQL E2E before building - Pages deploys after a successful tag-pushed Release, not on every web-path main push (workflow_run); dry-runs/dispatches don't trigger it - config show: token prefix hint instead of bare 'configured' - GitHub login re-issues the latest active token instead of creating a new one - fix: mysql:// DSNs silently fell back to sqlite file path (url.Parse rejects tcp(host:port)); split stacked DDL for MySQL; drop unsupported IF NOT EXISTS on CREATE INDEX; backtick reserved 'key' column --- .github/workflows/ci.yml | 1 + .github/workflows/pages.yml | 10 +++++----- .github/workflows/release.yml | 23 +++++++++++++++++++++++ cmd/inlet/config.go | 9 +++++++-- internal/store/dialect.go | 24 ++++++++++++++++++++++-- internal/store/dialect_test.go | 26 ++++++++++++++++++++++++++ internal/store/migrate.go | 34 +++++++++++++++++++++++++++++----- internal/store/queries.go | 8 ++++++++ internal/store/store.go | 19 +++++++++++++++++++ internal/store/store_test.go | 27 +++++++++++++++++++++++++++ 10 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 internal/store/dialect_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe5479e..04fba2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ on: pull_request: push: branches: [main] + workflow_call: permissions: contents: read diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 6089b73..0ececc5 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,10 +1,9 @@ name: Pages on: - push: - branches: [main] - paths: - - internal/server/web/** - - .github/workflows/pages.yml + # Deploy after a successful tag-pushed Release; manual runs via workflow_dispatch. + workflow_run: + workflows: ["Release"] + types: [completed] workflow_dispatch: permissions: @@ -18,6 +17,7 @@ concurrency: jobs: deploy: + if: "github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')" runs-on: ubuntu-latest environment: name: github-pages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 465d250..06118ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,30 @@ env: COMMIT: ${{ github.sha }} jobs: + test: + uses: ./.github/workflows/ci.yml + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + - name: Start Postgres and MySQL + run: docker compose -f docker-compose.test.yml up -d --wait + - name: E2E store tests + run: | + INLET_TEST_DSN_SQLITE="file::memory:?cache=shared" \ + INLET_TEST_DSN_POSTGRES='postgres://inlet:inlet@localhost:5432/inlet?sslmode=disable' \ + INLET_TEST_DSN_MYSQL='mysql://inlet:inlet@tcp(localhost:3306)/inlet' \ + go test ./internal/store -run TestE2E -v + - name: Stop Postgres and MySQL + if: always() + run: docker compose -f docker-compose.test.yml down + build: + needs: [test, e2e] runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/cmd/inlet/config.go b/cmd/inlet/config.go index 0558d5b..0654588 100644 --- a/cmd/inlet/config.go +++ b/cmd/inlet/config.go @@ -56,7 +56,11 @@ func cmdConfigShow(args []string) { } token := "not configured" if cc.Token != "" { - token = "configured (value hidden)" + hint := cc.Token + if len(hint) > 12 { + hint = hint[:12] + } + token = fmt.Sprintf("configured (%s…, value hidden)", hint) } inspectPort := cc.InspectPort if inspectPort == 0 { @@ -145,7 +149,8 @@ const configShowUsage = `inlet config show - show current client settings Usage: inlet config show -The token value is hidden. +The token value is hidden; a 12-character prefix hint is shown so you can +match the token against 'inlet token list'. ` const configSetServerUsage = `inlet config set-server - set the control server URL diff --git a/internal/store/dialect.go b/internal/store/dialect.go index cb04d30..bce4fd8 100644 --- a/internal/store/dialect.go +++ b/internal/store/dialect.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "regexp" "strings" ) @@ -26,6 +27,17 @@ func detectDialect(dsn string) (Dialect, string, error) { return newSQLiteDialect(), "data/inlet.db", nil } + // Prefix checks first: url.Parse rejects typical MySQL DSNs + // (mysql://user:pass@tcp(host:port)/db), which used to silently fall + // back to a sqlite file path. + lower := strings.ToLower(dsn) + if strings.HasPrefix(lower, "mysql://") { + return newMySQLDialect(), dsn[len("mysql://"):], nil + } + if strings.HasPrefix(lower, "postgres://") || strings.HasPrefix(lower, "postgresql://") { + return newPostgresDialect(), dsn, nil + } + u, err := url.Parse(dsn) if err == nil && u.Scheme != "" { switch strings.ToLower(u.Scheme) { @@ -65,12 +77,17 @@ func detectDialect(dsn string) (Dialect, string, error) { case "postgres", "postgresql": return newPostgresDialect(), dsn, nil case "mysql": - return newMySQLDialect(), dsn, nil + return newMySQLDialect(), dsn[strings.Index(dsn, "://")+len("://"):], nil default: return nil, "", fmt.Errorf("unsupported database scheme: %s", u.Scheme) } } + // Unparseable DSN that looks like a URI is almost certainly a misconfigured + // external DSN; fail loudly instead of creating a sqlite file from it. + if strings.Contains(dsn, "://") { + return nil, "", fmt.Errorf("could not parse database DSN: %v", err) + } // No scheme - treat as SQLite file path (backward compatible) if dsn == ":memory:" { return newSQLiteDialect(), dsn, nil @@ -187,6 +204,8 @@ func (d *postgresDialect) AddColumnIfMissing(db *sql.DB, table, columnDef string type mysqlDialect struct{} +var mysqlKeyColumn = regexp.MustCompile(`\bkey\b`) + func newMySQLDialect() Dialect { return &mysqlDialect{} } func (d *mysqlDialect) Name() string { return "mysql" } @@ -195,7 +214,8 @@ func (d *mysqlDialect) Placeholder(n int) string { return "?" } func (d *mysqlDialect) RewriteQuery(query string) string { - return query // MySQL uses ? + // `key` is reserved in MySQL settings; backtick the column (uppercase KEY keywords untouched). + return mysqlKeyColumn.ReplaceAllString(query, "`key`") } func (d *mysqlDialect) UpsertSettings() string { return `INSERT INTO settings (key, value) VALUES (?, ?) diff --git a/internal/store/dialect_test.go b/internal/store/dialect_test.go new file mode 100644 index 0000000..bc41d7b --- /dev/null +++ b/internal/store/dialect_test.go @@ -0,0 +1,26 @@ +package store + +import "testing" + +func TestDetectDialectDSNs(t *testing.T) { + d, resolved, err := detectDialect("mysql://inlet:inlet@tcp(localhost:3306)/inlet") + if err != nil { + t.Fatal(err) + } + if d.Name() != "mysql" || resolved != "inlet:inlet@tcp(localhost:3306)/inlet" { + t.Fatalf("mysql: %s %q", d.Name(), resolved) + } + d, resolved, err = detectDialect("postgres://inlet:inlet@localhost:5432/inlet?sslmode=disable") + if err != nil { + t.Fatal(err) + } + if d.Name() != "postgres" || resolved != "postgres://inlet:inlet@localhost:5432/inlet?sslmode=disable" { + t.Fatalf("postgres: %s %q", d.Name(), resolved) + } + if _, _, err := detectDialect("bogus://unparseable%%"); err == nil { + t.Fatal("unparseable URI-style DSN should fail, not silently become a sqlite path") + } + if d, _, err := detectDialect("data/inlet.db"); err != nil || d.Name() != "sqlite" { + t.Fatalf("plain path: %v", err) + } +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go index ed9047e..283b8a9 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -2,6 +2,10 @@ package store import ( "database/sql" + "errors" + "strings" + + "github.com/go-sql-driver/mysql" ) func migrate(db *sql.DB, d Dialect) error { @@ -63,8 +67,8 @@ CREATE TABLE IF NOT EXISTS settings ( revoked_at BIGINT NOT NULL DEFAULT 0, UNIQUE KEY token_hash_unique (token_hash) ); -CREATE INDEX IF NOT EXISTS tokens_last_used ON tokens(last_used_at); -CREATE INDEX IF NOT EXISTS tokens_ip ON tokens(signup_ip); +CREATE INDEX tokens_last_used ON tokens(last_used_at); +CREATE INDEX tokens_ip ON tokens(signup_ip); CREATE TABLE IF NOT EXISTS accounts ( id VARCHAR(191) PRIMARY KEY, @@ -93,8 +97,20 @@ CREATE TABLE IF NOT EXISTS settings ( } } - if _, err := db.Exec(q); err != nil { - return err + // Some drivers (MySQL) can't run stacked statements in one Exec; run them one by one. + for _, stmt := range strings.Split(q, ";") { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := db.Exec(stmt); err != nil { + // MySQL has no CREATE INDEX IF NOT EXISTS; ignore duplicate index name. + var me *mysql.MySQLError + if errors.As(err, &me) && me.Number == 1061 { + continue + } + return err + } } // MySQL uses different column name for settings key? Let us handle @@ -106,7 +122,15 @@ CREATE TABLE IF NOT EXISTS settings ( // Simple approach - if key_name doesn't exist, try to rename; easier to just recreate logic not critical for now } - if _, err := db.Exec(QCreateIndexTokensAccount); err != nil { + // MySQL has no CREATE INDEX IF NOT EXISTS; tolerate the duplicate-name error. + if d.Name() == "mysql" { + if _, err := db.Exec(`CREATE INDEX tokens_account ON tokens(account_id)`); err != nil { + var me *mysql.MySQLError + if !(errors.As(err, &me) && me.Number == 1061) { + return err + } + } + } else if _, err := db.Exec(QCreateIndexTokensAccount); err != nil { return err } diff --git a/internal/store/queries.go b/internal/store/queries.go index 5934401..e0e4351 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -58,6 +58,14 @@ CREATE TABLE IF NOT EXISTS settings ( (id, token_hash, token_prefix, label, tunnel_id, source, signup_ip, account_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + QLatestActiveTokenForAccount = ` +SELECT t.id, t.token_prefix, t.label, t.tunnel_id, t.source, t.signup_ip, + t.account_id, COALESCE(a.login, ''), t.created_at, t.last_used_at, t.revoked_at +FROM tokens t LEFT JOIN accounts a ON a.id = t.account_id +WHERE t.account_id = ? AND t.revoked_at = 0 +ORDER BY t.created_at DESC, t.id DESC LIMIT 1` + QReissueToken = `UPDATE tokens SET token_hash = ?, token_prefix = ?, label = ? WHERE id = ?` + QFindTokenHashExists = `SELECT 1 FROM tokens WHERE token_hash = ?` QUpdateAdminTokenLabel = `UPDATE tokens SET label = ? WHERE token_hash = ? AND source = 'admin' AND label = 'environment'` diff --git a/internal/store/store.go b/internal/store/store.go index 251d1dd..06165eb 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -142,6 +142,25 @@ func (s *Store) IssueGitHub(label string, githubID int64, login, signupIP string return "", nil, err } + // Re-issue the most recent active token instead of creating a new one, so + // repeated logins don't pile up tokens or rotate the tunnel ID. + row := tx.QueryRow(s.dialect.RewriteQuery(QLatestActiveTokenForAccount), accountID) + if tok, err := scanToken(row); err == nil { + raw, hash := newSecret() + if _, err := tx.Exec(s.dialect.RewriteQuery(QReissueToken), hash, raw[:12], label, tok.ID); err != nil { + return "", nil, err + } + if err := tx.Commit(); err != nil { + return "", nil, err + } + tok.Prefix = raw[:12] + tok.Label = label + tok.AccountLogin = login + return raw, tok, nil + } else if !errors.Is(err, sql.ErrNoRows) { + return "", nil, err + } + var active int if err := tx.QueryRow(s.dialect.RewriteQuery(QCountActiveTokensForAccount), accountID).Scan(&active); err != nil { return "", nil, err diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 3403f78..9ef6270 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -156,3 +156,30 @@ func TestCleanupUsesShorterWindowForOpenTokens(t *testing.T) { t.Fatal("stale open token still valid") } } + +func TestIssueGitHubReissuesLatestActiveToken(t *testing.T) { + s := openTemp(t) + raw1, tok1, err := s.IssueGitHub("laptop", 42, "octocat", "1.2.3.4", true) + if err != nil { + t.Fatal(err) + } + raw2, tok2, err := s.IssueGitHub("laptop", 42, "octocat", "5.6.7.8", true) + if err != nil { + t.Fatal(err) + } + if raw1 == raw2 { + t.Fatal("re-issue returned the same secret") + } + if tok1.ID != tok2.ID || tok1.TunnelID != tok2.TunnelID { + t.Fatalf("re-issue created a new token: %q/%q vs %q/%q", tok1.ID, tok1.TunnelID, tok2.ID, tok2.TunnelID) + } + if tok2.Prefix == tok1.Prefix { + t.Fatal("prefix was not updated") + } + if _, err := s.Validate(raw1); !errors.Is(err, ErrInvalidToken) { + t.Fatalf("old token still validates: %v", err) + } + if got, err := s.Validate(raw2); err != nil || got.ID != tok1.ID { + t.Fatalf("new token does not validate: %v", err) + } +}