fix(config): Datenbank-Passwort mit Sonderzeichen bricht den Start
Die Verbindungszeichenkette wurde mit fmt.Sprintf zusammengeklebt. Ein Passwort mit % und @ ergab eine kaputte URL: parse "postgres://aether:8UoNj%CoWJ7ze@qy@localhost:5432/aether": invalid URL escape "%Co" Der Server kam nicht über die Migrationen hinaus. Die key=value-Form hätte dasselbe Problem mit einem Leerzeichen gehabt, nur stiller. config.DSN() liefert jetzt eine postgres://-URL, gebaut mit net/url und net.JoinHostPort (klammert IPv6). pgxpool und golang-migrate nehmen sie beide unverändert – dsnToURL, splitDSN, indexByte und orDefault in pkg/db sind ersatzlos entfallen. Test deckt %, @, :, /, Leerzeichen und IPv6 ab. Dazu: DB_HOST und DB_PORT fehlten in der schlanken .env.example, weshalb localhost statt postgres gegriffen hat. APP_ENV ergänzt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
16ab6f385b
commit
35ebf609b7
5 files changed
+153
-71
No files matched your search
@@ -7,6 +7,11 @@
|
|||||||
|
|
||||||
# ── Pflicht ───────────────────────────────────────────────────────────────────
|
# ── Pflicht ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Steuert nur die Form der Logausgabe: `development` schreibt sie farbig und
|
||||||
|
# mehrzeilig, `production` als JSON für deinen Logsammler.
|
||||||
|
APP_ENV=production
|
||||||
|
|
||||||
|
|
||||||
# Wie die Cloud von außen erreichbar ist. Wird für OIDC-Rücksprünge,
|
# Wie die Cloud von außen erreichbar ist. Wird für OIDC-Rücksprünge,
|
||||||
# WOPI-Rückrufe und Freigabe-Links benutzt – ein falscher Wert fällt erst
|
# WOPI-Rückrufe und Freigabe-Links benutzt – ein falscher Wert fällt erst
|
||||||
# dort auf, nicht beim Start.
|
# dort auf, nicht beim Start.
|
||||||
@@ -15,8 +20,14 @@ PUBLIC_URL=https://cloud.example.com
|
|||||||
# openssl rand -hex 32
|
# openssl rand -hex 32
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# `postgres` ist der Dienstname aus dem Compose, nicht localhost – aus dem
|
||||||
|
# Container heraus ist localhost er selbst. Ohne diese Zeile greift die
|
||||||
|
# Voreinstellung `localhost`, und der Start endet in „migrations failed".
|
||||||
|
DB_HOST=postgres
|
||||||
|
DB_PORT=5432
|
||||||
DB_NAME=aether
|
DB_NAME=aether
|
||||||
DB_USER=aether
|
DB_USER=aether
|
||||||
|
# Jedes Zeichen ist erlaubt, auch % @ : / und Leerzeichen.
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
|
|
||||||
# Wohin die Dateien auf dem Wirt kommen. Ein gewöhnliches Verzeichnis, kein
|
# Wohin die Dateien auf dem Wirt kommen. Ein gewöhnliches Verzeichnis, kein
|
||||||
|
|||||||
+41
@@ -646,6 +646,47 @@ breiteste.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Datenbank-Passwort mit Sonderzeichen (24.08.2026)
|
||||||
|
|
||||||
|
Die Verbindungszeichenkette wurde mit `fmt.Sprintf` zusammengeklebt. Beim
|
||||||
|
ersten Aufbau einer frischen Umgebung fiel das sofort auf:
|
||||||
|
|
||||||
|
```
|
||||||
|
FTL migrations failed
|
||||||
|
error="parse \"postgres://aether:8UoNj%CoWJ7ze@qy@localhost:5432/aether\":
|
||||||
|
invalid URL escape \"%Co\""
|
||||||
|
```
|
||||||
|
|
||||||
|
`@` und `:` beenden den Anmeldeteil einer URL, `%` leitet eine Maskierung ein.
|
||||||
|
Die `key=value`-Form hätte dasselbe Problem mit einem **Leerzeichen** gehabt,
|
||||||
|
nur stiller: Der Rest dahinter wäre als eigener Schlüssel gelesen worden.
|
||||||
|
Jetzt baut `net/url` die Adresse, und die Umrechnung von Hand in `pkg/db` ist
|
||||||
|
ersatzlos weg.
|
||||||
|
|
||||||
|
- [ ] `DB_PASSWORD` mit `%`, `@`, `:`, `/` und Leerzeichen → Server startet,
|
||||||
|
Migrationen laufen, Anmeldung geht 🔎 (gemessen mit `8UoNj%CoWJ7ze@qy`
|
||||||
|
in einer eigenen Umgebung: `migrations up to date`, `/health` 200,
|
||||||
|
Login 200)
|
||||||
|
- [ ] `go test ./pkg/config/...` deckt die Fälle ab, ohne dass eine Datenbank
|
||||||
|
laufen muss 🔎
|
||||||
|
- [ ] IPv6 als `DB_HOST` (`::1`) wird geklammert, der Port bleibt erhalten 🔎
|
||||||
|
|
||||||
|
> **Keine Regel „welche Zeichen erlaubt sind".** Wer die erst nachschlagen
|
||||||
|
> muss, wählt am Ende ein schwächeres Passwort.
|
||||||
|
|
||||||
|
### Dazu: `DB_HOST` fehlte im schlanken Beispiel
|
||||||
|
|
||||||
|
Die `.env.example` im Wurzelverzeichnis führte `DB_NAME`, `DB_USER` und
|
||||||
|
`DB_PASSWORD`, aber nicht `DB_HOST`. Die Voreinstellung ist `localhost` – und
|
||||||
|
aus dem Container heraus ist das er selbst. Im Log stand deshalb
|
||||||
|
`@localhost:5432`, obwohl Postgres als `postgres` im Compose-Netz lief.
|
||||||
|
|
||||||
|
- [ ] Frische Umgebung nur aus `docker-compose.example.yml` + `.env.example`
|
||||||
|
startet ohne Nacharbeit
|
||||||
|
- [ ] `APP_ENV`, `DB_HOST` und `DB_PORT` stehen darin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Handy: eine Kopfzeile statt zwei (24.08.2026)
|
## Handy: eine Kopfzeile statt zwei (24.08.2026)
|
||||||
|
|
||||||
Die eigene Leiste über der Kopfzeile („☰ Aether") ist weg. Sie enthielt nur
|
Die eigene Leiste über der Kopfzeile („☰ Aether") ist weg. Sie enthielt nur
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -218,11 +219,37 @@ func Load() (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DSN liefert eine `postgres://`-URL – nicht die `key=value`-Form.
|
||||||
|
//
|
||||||
|
// Vorher wurde hier mit fmt.Sprintf zusammengeklebt, und beide Formate haben
|
||||||
|
// Sonderzeichen, die dabei kaputtgehen:
|
||||||
|
//
|
||||||
|
// - In der URL-Form beenden `@` und `:` den Anmeldeteil, und `%` leitet eine
|
||||||
|
// Maskierung ein. Ein Passwort wie `8UoNj%CoWJ7ze@qy` ergab
|
||||||
|
// `postgres://aether:8UoNj%CoWJ7ze@qy@localhost:5432/...` und damit
|
||||||
|
// „invalid URL escape %Co". Der Server kam nicht über die Migrationen
|
||||||
|
// hinaus – gemessen bei der ersten Einrichtung.
|
||||||
|
// - In der key=value-Form trennt das Leerzeichen die Paare. Ein Passwort mit
|
||||||
|
// Leerzeichen wäre dort genauso kaputtgegangen, nur stiller: Der Rest
|
||||||
|
// hinter dem Leerzeichen wäre als eigener Schlüssel gelesen worden.
|
||||||
|
//
|
||||||
|
// Beides erledigt jetzt die Standardbibliothek. `url.UserPassword` maskiert,
|
||||||
|
// was maskiert werden muss, und `net.JoinHostPort` klammert eine IPv6-Adresse.
|
||||||
|
// pgxpool und golang-migrate nehmen beide diese URL unverändert an – die
|
||||||
|
// Umrechnung von Hand in pkg/db ist damit ersatzlos weggefallen.
|
||||||
|
//
|
||||||
|
// Ein Passwort darf danach jedes Zeichen enthalten. Das ist keine
|
||||||
|
// Bequemlichkeit: Wer erst herausfinden muss, welche Zeichen erlaubt sind,
|
||||||
|
// wählt am Ende ein schwächeres.
|
||||||
func (c *Config) DSN() string {
|
func (c *Config) DSN() string {
|
||||||
return fmt.Sprintf(
|
u := url.URL{
|
||||||
"host=%s port=%s dbname=%s user=%s password=%s sslmode=disable",
|
Scheme: "postgres",
|
||||||
c.DBHost, c.DBPort, c.DBName, c.DBUser, c.DBPassword,
|
User: url.UserPassword(c.DBUser, c.DBPassword),
|
||||||
)
|
Host: net.JoinHostPort(c.DBHost, c.DBPort),
|
||||||
|
Path: "/" + c.DBName,
|
||||||
|
RawQuery: "sslmode=disable",
|
||||||
|
}
|
||||||
|
return u.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) IsDev() bool {
|
func (c *Config) IsDev() bool {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseDSN(s string) (*url.URL, error) { return url.Parse(s) }
|
||||||
|
|
||||||
|
// Ein Passwort darf jedes Zeichen enthalten. Die Fälle hier sind nicht
|
||||||
|
// ausgedacht: `8UoNj%CoWJ7ze@qy` stammt aus einer echten Einrichtung und
|
||||||
|
// legte den Server lahm – „invalid URL escape %Co", noch vor den Migrationen.
|
||||||
|
func TestDSNEscapesPassword(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
pw string
|
||||||
|
}{
|
||||||
|
{"prozent und at", "8UoNj%CoWJ7ze@qy"},
|
||||||
|
{"doppelpunkt", "gehei:mnis"},
|
||||||
|
{"schraegstrich", "a/b?c#d"},
|
||||||
|
{"leerzeichen", "mit leer zeichen"},
|
||||||
|
{"nur sonderzeichen", "%%@@::////"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c := &Config{
|
||||||
|
DBHost: "postgres", DBPort: "5432", DBName: "aether",
|
||||||
|
DBUser: "aether", DBPassword: tc.pw,
|
||||||
|
}
|
||||||
|
got := c.DSN()
|
||||||
|
// Die Probe ist nicht der erzeugte Text, sondern ob er sich
|
||||||
|
// wieder auseinandernehmen lässt – genau das tun pgx und
|
||||||
|
// golang-migrate mit ihm.
|
||||||
|
u, err := parseDSN(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DSN nicht parsebar: %v (%s)", err, got)
|
||||||
|
}
|
||||||
|
if pw, _ := u.User.Password(); pw != tc.pw {
|
||||||
|
t.Errorf("Passwort kam als %q zurück, erwartet %q", pw, tc.pw)
|
||||||
|
}
|
||||||
|
if u.User.Username() != "aether" {
|
||||||
|
t.Errorf("Nutzer %q", u.User.Username())
|
||||||
|
}
|
||||||
|
if u.Host != "postgres:5432" {
|
||||||
|
t.Errorf("Host %q", u.Host)
|
||||||
|
}
|
||||||
|
if u.Path != "/aether" {
|
||||||
|
t.Errorf("Datenbank %q", u.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 muss geklammert werden, sonst frisst der Doppelpunkt den Port.
|
||||||
|
func TestDSNIPv6(t *testing.T) {
|
||||||
|
c := &Config{DBHost: "::1", DBPort: "5432", DBName: "aether", DBUser: "u"}
|
||||||
|
u, err := parseDSN(c.DSN())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nicht parsebar: %v", err)
|
||||||
|
}
|
||||||
|
if u.Hostname() != "::1" || u.Port() != "5432" {
|
||||||
|
t.Errorf("Host %q Port %q", u.Hostname(), u.Port())
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-67
@@ -25,13 +25,17 @@ func Connect(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
|||||||
return pool, nil
|
return pool, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate führt die eingebetteten Migrationen aus.
|
||||||
|
//
|
||||||
|
// `dsn` ist die postgres://-URL aus config.DSN(). Hier stand früher eine
|
||||||
|
// eigene Umrechnung von key=value in eine URL, die Nutzer und Passwort
|
||||||
|
// unmaskiert hineinschrieb – siehe die Begründung bei config.DSN().
|
||||||
func Migrate(dsn string) error {
|
func Migrate(dsn string) error {
|
||||||
src, err := iofs.New(migrationsFS, "migrations")
|
src, err := iofs.New(migrationsFS, "migrations")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load migrations: %w", err)
|
return fmt.Errorf("load migrations: %w", err)
|
||||||
}
|
}
|
||||||
// convert pgx DSN → lib/pq DSN format for migrate
|
m, err := migrate.NewWithSourceInstance("iofs", src, dsn)
|
||||||
m, err := migrate.NewWithSourceInstance("iofs", src, "postgres://"+dsnToURL(dsn))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("init migrate: %w", err)
|
return fmt.Errorf("init migrate: %w", err)
|
||||||
}
|
}
|
||||||
@@ -40,68 +44,3 @@ func Migrate(dsn string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// dsnToURL converts "host=... port=... dbname=... user=... password=... sslmode=..."
|
|
||||||
// to a postgres:// URL for golang-migrate.
|
|
||||||
func dsnToURL(dsn string) string {
|
|
||||||
// pgxpool DSN is already accepted by migrate if we prefix with postgres://
|
|
||||||
// Actually golang-migrate accepts the postgres:// URL format.
|
|
||||||
// Since our DSN is key=value, we need to convert it.
|
|
||||||
// Simplest approach: parse key=value pairs.
|
|
||||||
params := map[string]string{}
|
|
||||||
for _, part := range splitDSN(dsn) {
|
|
||||||
if len(part) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
idx := indexByte(part, '=')
|
|
||||||
if idx < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
params[part[:idx]] = part[idx+1:]
|
|
||||||
}
|
|
||||||
host := params["host"]
|
|
||||||
port := params["port"]
|
|
||||||
if port == "" {
|
|
||||||
port = "5432"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s:%s@%s:%s/%s?sslmode=%s",
|
|
||||||
params["user"], params["password"],
|
|
||||||
host, port, params["dbname"],
|
|
||||||
orDefault(params["sslmode"], "disable"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitDSN(dsn string) []string {
|
|
||||||
var parts []string
|
|
||||||
cur := ""
|
|
||||||
for _, r := range dsn {
|
|
||||||
if r == ' ' {
|
|
||||||
if cur != "" {
|
|
||||||
parts = append(parts, cur)
|
|
||||||
cur = ""
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cur += string(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cur != "" {
|
|
||||||
parts = append(parts, cur)
|
|
||||||
}
|
|
||||||
return parts
|
|
||||||
}
|
|
||||||
|
|
||||||
func indexByte(s string, b byte) int {
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
if s[i] == b {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func orDefault(s, def string) string {
|
|
||||||
if s == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user