Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ on:
pull_request:
push:
branches: [main]
workflow_call:

permissions:
contents: read
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions cmd/inlet/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions internal/store/dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/url"
"regexp"
"strings"
)

Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" }
Expand All @@ -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 (?, ?)
Expand Down
26 changes: 26 additions & 0 deletions internal/store/dialect_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
34 changes: 29 additions & 5 deletions internal/store/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand Down
8 changes: 8 additions & 0 deletions internal/store/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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'`

Expand Down
19 changes: 19 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions internal/store/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading