Documentation
¶
Overview ¶
Package config resolves TableX's runtime configuration from (in increasing precedence) built-in defaults, an optional TOML file, environment variables (TABLEX_*), and command-line flags.
The config defines the listen address, TLS, session policy, optional predefined servers, and the SSRF/ad-hoc-login controls described in docs/security.md. Credentials are never required here: the default mode is ad-hoc cookie login where nothing is persisted.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func CanonicalHost ¶
CanonicalHost returns host trimmed of surrounding whitespace, with a bracketed IP literal ("[::1]", "[fe80::1%eth0]") normalized to bare form ("::1"). URL-style brackets are a spelling, not a distinct host: left intact they would defeat the string-compared allow/denylist matching (a configured "::1" must match a posted "[::1]" and vice versa) and lead net.JoinHostPort to double-bracket the DSN authority ("[[::1]]:5432"). Anything that is not a valid bracketed IP literal is returned unchanged. Load applies this to the allow/denylists and predefined server hosts; the login handler applies it to the posted ad-hoc host; auth.CheckHost applies it to both sides of every match as a backstop.
func IsHealthcheckRequest ¶
IsHealthcheckRequest reports whether err signals the -healthcheck flag.
func IsVersionRequest ¶
IsVersionRequest reports whether err signals the -version flag.
Types ¶
type AuditConfig ¶
type AuditConfig struct {
// File is a JSON Lines path. Its DIRECTORY must exist; the file is created
// 0600 because it records account names and client addresses.
File string `toml:"file"`
// MaxBytes rotates File once it would exceed this, keeping one ".1"
// generation. It is a floor against filling a disk unattended, not a
// retention policy — point File somewhere your own rotation handles, or ship
// the lines, which is what the JSON Lines format is for. <= 0 uses the
// built-in default.
MaxBytes int64 `toml:"max_bytes"`
// Log writes events through TableX's own logger, and therefore to stderr —
// which is the container log stream, journald, or whatever the host already
// collects. This is how the trail reaches syslog without TableX speaking it.
Log bool `toml:"log"`
// Statements records the SQL text of each statement TableX runs on a user's
// behalf, not merely that a request happened. Statement text can carry row
// data (an INSERT's values, a WHERE clause), which is the one reason an
// operator might want the rest of the trail without it.
Statements bool `toml:"statements"`
}
AuditConfig turns on the audit trail (internal/audit) — a durable record of who changed what, distinct from the access log that has always gone to stderr.
It is on when it has at least one destination, and off otherwise: there is no separate `enabled` flag to contradict the destinations. A block that names none is refused rather than silently ignored.
func (AuditConfig) Enabled ¶
func (a AuditConfig) Enabled() bool
Enabled reports whether the audit trail has anywhere to write.
type Config ¶
type Config struct {
Listen string `toml:"listen"`
TLSCert string `toml:"tls_cert"`
TLSKey string `toml:"tls_key"`
// MaxExactCount is the row-count ceiling for Browse/Structure renders
// (phpMyAdmin's $cfg['MaxExactCount'] plus its MaxExactCountViews role).
// It bounds the work a row count may cost in two ways: when the engine's
// statistics estimate a relation above it, the page shows the estimate;
// when there is no usable estimate at all — a VIEW on every engine, a
// never-analyzed relation, or an engine with no estimator (SQLite) — the
// count runs but stops here and reports a lower bound. Either way the page
// marks the total approximate and offers a "count exact" affordance, which
// is the one path that may run an unbounded COUNT(*). <= 0 always counts
// exactly.
MaxExactCount int `toml:"max_exact_count"`
// PoolCap is the process-wide budget of cached per-database connection
// pools (each pool holds up to PoolMaxConns connections). When opening one
// more pool would exceed it, the session's least-recently-used idle pool is
// closed first; if nothing is evictable the request fails rather than growing
// unboundedly. <= 0 removes the cap.
PoolCap int `toml:"pool_cap"`
// PoolMaxConns / PoolIdleConns size each database connection pool. The
// product PoolCap × PoolMaxConns bounds TableX's outbound connections to
// one server, which is what has to stay clear of the server's own
// max_connections. <= 0 falls back to the driver defaults.
PoolMaxConns int `toml:"pool_max_conns"`
PoolIdleConns int `toml:"pool_idle_conns"`
// ReadStmtTimeout bounds a single GENERATED read statement (introspection,
// browse, count, estimate) so a few slow reads cannot tie up a whole pool
// until the client disconnects. It is deliberately NOT applied to the SQL
// console, export streaming, any pinned script, or any generated mutation —
// see driver.ReadStmtTimeout. <= 0 falls back to the driver default.
ReadStmtTimeout time.Duration `toml:"read_stmt_timeout"`
// MaxConcurrentDBOps caps how many requests may simultaneously hold a
// PRIVATE database connection — a streaming export, a SQL console script or
// a SQL import. Those deliberately sit outside PoolCap (they must not share
// or evict a cached pool), so without this nothing bounded them: enough
// parallel exports could exhaust the DATABASE's max_connections and take
// down every other client of that server. Over the cap a request is refused
// with 503 + Retry-After rather than queued indefinitely. <= 0 removes the
// cap.
MaxConcurrentDBOps int `toml:"max_concurrent_db_ops"`
// SessionQueryBudget caps how many statements ONE session may submit per
// SessionQueryWindow. MaxConcurrentDBOps bounds how much work runs at once;
// this bounds how much one session may ask for over time, which is the
// dimension a single logged-in user can otherwise saturate on their own.
//
// It charges the SQL a user WROTE — the console, EXPLAIN, and a SQL import —
// and deliberately not the queries TableX generates for them. Charging those
// would make ordinary navigation run out of budget (one page render costs
// several introspection reads), and they are already bounded per request by
// read_stmt_timeout and the pool caps. <= 0 removes the budget.
SessionQueryBudget int `toml:"session_query_budget"`
// SessionQueryWindow is the period SessionQueryBudget is counted over. It is
// a fixed window, not a sliding one: the count resets when the window rolls,
// so the honest worst case is two budgets' worth across a window boundary.
SessionQueryWindow time.Duration `toml:"session_query_window"`
Session SessionConfig `toml:"session"`
Security SecurityConfig `toml:"security"`
SSO SSOConfig `toml:"sso"`
Storage StorageConfig `toml:"storage"`
Audit AuditConfig `toml:"audit"`
Restrict RestrictConfig `toml:"restrict"`
Metrics MetricsConfig `toml:"metrics"`
Servers []ServerConfig `toml:"servers"`
}
Config is the fully-resolved application configuration.
func Default ¶
func Default() Config
Default returns the built-in defaults (used when nothing overrides them).
func Load ¶
Load resolves configuration from args (typically os.Args[1:]), the environment, and an optional TOML file. It returns the config and, when the user passes -help, flag.ErrHelp.
func (Config) CookiesSecure ¶
CookiesSecure reports whether cookies must carry the Secure attribute (and the __Host- prefix) and HSTS be emitted. True when TableX serves TLS directly, or when the operator opts in for a TLS-terminating reverse proxy. See docs/security.md §7.
func (Config) ServerByName ¶
func (c Config) ServerByName(name string) (ServerConfig, bool)
ServerByName returns the predefined server with the given name.
func (Config) TLSEnabled ¶
TLSEnabled reports whether direct TLS serving is configured.
type MetricsConfig ¶
type MetricsConfig struct {
Enabled bool `toml:"enabled"`
// Token, when set, is required as `Authorization: Bearer <token>`. Compared
// in constant time.
Token string `toml:"token"`
// AllowCIDRs, when non-empty, restricts scraping to these networks (CIDRs or
// bare IPs). The address compared is the one TableX resolves for every other
// purpose, so X-Forwarded-For is honoured only from a configured trusted
// proxy — otherwise this would be a header away from meaningless.
AllowCIDRs []string `toml:"allow_cidrs"`
}
MetricsConfig exposes /metrics in the Prometheus text exposition format.
Off by default, and when on it is ACCESS-CONTROLLED rather than public. That is the whole reason this block has three keys instead of one: the numbers describe TableX's internals — how many sessions exist, how much work is in flight, whether the audit trail is failing — and none of it belongs to an anonymous caller. /healthz stays a bare "ok" for the same reason, so a container probe never has to be given a credential.
Enabling it without either a token or an address allowlist refuses startup. There is no safe default to pick on the operator's behalf: guessing "loopback only" would silently break the common case (a Prometheus on another host), and guessing "anyone" would publish the internals of a database admin tool.
func (MetricsConfig) Authorizes ¶
func (m MetricsConfig) Authorizes() (needToken, needNetwork bool)
Authorizes reports what a scrape must satisfy. Both checks apply when both are configured: an allowlisted network still needs the token.
type RestrictConfig ¶
type RestrictConfig struct {
// ReadOnly refuses every state-changing request. That includes the SQL
// console and SQL import, because TableX will not try to decide whether
// somebody's SQL writes — a classifier is the wrong thing to stake a
// read-only guarantee on. Exports, browsing and every other read are
// unaffected.
ReadOnly bool `toml:"read_only"`
// AllowConsole permits the SQL console and SQL import: arbitrary SQL, under
// the user's own credentials. Defaults to true. Turning it off leaves the
// generated operations — browse, edit, structure — working, which is the
// point: it removes the path whose reach TableX cannot describe.
AllowConsole bool `toml:"allow_console"`
// AllowDDL permits schema and access-control changes: create/drop database
// and schema, the structure editor, table operations, stored programs,
// accounts and grants. Defaults to true. Row edits (INSERT/UPDATE/DELETE)
// are NOT DDL and stay available — the distinction is deliberate, because
// "let them fix data but not reshape it" is the common ask.
AllowDDL bool `toml:"allow_ddl"`
// Databases, when non-empty, is the set of databases the UI may address.
// Every route naming a database outside it is refused.
//
// Its limit has to be stated: it is a ROUTE restriction. While the console
// is enabled, a user can name any database their credentials can reach in
// SQL, and TableX does not parse their statements to stop them. Startup
// warns about that combination. Paired with allow_console = false it is a
// real confinement; on its own it is navigation scoping.
Databases []string `toml:"database_allowlist"`
}
RestrictConfig narrows what a logged-in user may do, BELOW what their database grants already allow. It is defence in depth and nothing more: the database's own privileges remain the real boundary, and an operator who needs a user not to be able to drop a table should also not grant them DROP.
What it buys is the case grants cannot express — an operator who must use a privileged account (a shared read-only console, a support login on a DBA credential) and wants TableX itself to refuse the dangerous half.
Enforcement is in the middleware, keyed on the route. It is deliberately NOT in the templates: a hidden button is not a control, and every restriction here must hold against a request typed by hand.
func (RestrictConfig) DatabaseAllowed ¶
func (r RestrictConfig) DatabaseAllowed(name string) bool
DatabaseAllowed reports whether a database may be addressed. An empty allowlist permits everything.
func (RestrictConfig) Restricted ¶
func (r RestrictConfig) Restricted() bool
Restricted reports whether any restriction is in force.
type SSOConfig ¶
type SSOConfig struct {
// Issuer is the provider's base URL. Discovery reads
// <issuer>/.well-known/openid-configuration.
Issuer string `toml:"issuer"`
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
// RedirectURL must be the absolute URL of TableX's callback route and must be
// registered with the provider. It cannot be derived from the request: behind
// a proxy the Host and scheme TableX sees need not be the ones the browser
// used, and a redirect_uri the provider does not recognize fails the exchange.
RedirectURL string `toml:"redirect_url"`
// Scopes is the scope list to request; openid is always included (without
// it the provider is not doing OIDC at all), so an empty list resolves to
// just ["openid"]. No default is ever assigned to this field — the
// disabled-block validation relies on a set Scopes meaning the OPERATOR
// set it; see ResolvedScopes.
Scopes []string `toml:"scopes"`
// AllowedEmails / AllowedDomains narrow WHICH verified identities may reach
// the login form. Empty means any identity the provider vouches for, which is
// the right default only when the provider's own audience is already the set
// of people who should be here.
AllowedEmails []string `toml:"allowed_emails"`
AllowedDomains []string `toml:"allowed_domains"`
}
SSOConfig puts an OpenID Connect provider IN FRONT OF the login form.
It is deliberately not "log in with SSO". TableX opens every database connection with the user's OWN credentials — that is what makes the audit trail's "account" field the truthful answer to whose privileges a statement ran under, and what lets the promise that TableX never stores a credential stand. Authenticating a PERSON does not produce database credentials, so SSO here is an EXTRA FACTOR: you must pass the provider to reach the login form, and then you still supply your own credentials.
The two alternatives were considered and rejected on purpose. Mapping SSO users onto predefined servers would give one-click access but make every user share one database identity, which collapses the audit trail to "someone who passed SSO". Storing per-user credentials encrypted in the metadata database would give both, at the cost of reversing a documented guarantee and making the encryption key the most valuable secret in the deployment.
Off unless Issuer is set. Half-configured refuses startup, for the same reason MetricsConfig does: a provider that silently does not engage is worse than one that fails loudly, because the gate it was supposed to be is simply absent.
func (SSOConfig) HasAllowlist ¶
HasAllowlist reports whether the operator narrowed WHO may pass the gate by email. It is also the condition under which an unverified email must be refused: an allowlist matches on the email string, and on a self-service provider an unverified email is an attacker-choosable string — register victim@allowed-domain.com and the list falls. With no allowlist the email is never matched against anything, so verification is not required.
func (SSOConfig) PermitsIdentity ¶
PermitsIdentity reports whether a verified identity may proceed to the login form. The subject is accepted on the strength of the provider alone; the allowlists, when set, narrow it further by email. Callers must have already refused an unverified email when HasAllowlist is true — this only matches the string.
func (SSOConfig) ResolvedScopes ¶
ResolvedScopes returns the scopes to request, always including openid.
type SecurityConfig ¶
type SecurityConfig struct {
AllowAdHoc bool `toml:"allow_adhoc"` // permit arbitrary host login
BlockPrivate bool `toml:"block_private"` // block loopback/private hosts for ad-hoc (link-local/metadata are always blocked)
SecureCookies bool `toml:"secure_cookies"` // force Secure/__Host- cookies + HSTS behind a TLS-terminating proxy
HostAllowlist []string `toml:"host_allowlist"` // if set, only these hosts may be targeted
HostDenylist []string `toml:"host_denylist"` // always-blocked hosts
LoginRateWindow time.Duration `toml:"login_rate_window"` // throttle window; keys: IP, (IP,user), (IP,predefined-server)
LoginRateMax int `toml:"login_rate_max"` // max attempts per window
// LoginAccountMax caps failed attempts against ONE ACCOUNT NAME per window,
// across every source address. Every other login key above starts with the
// client IP, so the throttle they provide is per-source: an IPv6 /64, a
// botnet, or anything else with addresses to spend gets login_rate_max
// attempts EACH against the same account. This is the key that is not keyed
// on the attacker's choice of address.
//
// It is deliberately more permissive than login_rate_max, because it is
// shared: several people behind different addresses may be typing the same
// shared account name, and one of them fat-fingering must not lock the rest
// out. <= 0 disables it (and Warnings says so).
LoginAccountMax int `toml:"login_account_max"`
TrustedProxy bool `toml:"trusted_proxy"` // legacy intent flag; X-Forwarded-For is honored only with trusted_proxy_cidrs
// TrustedProxyCIDRs lists the reverse proxies (CIDRs or bare IPs) whose
// X-Forwarded-For entries may be skipped when resolving the client IP for
// rate limiting and logs. XFF is parsed right-to-left, stopping at the
// first address outside these ranges; when the list is empty XFF is never
// consulted — a bare trusted_proxy=true would otherwise mean "trust any
// client-supplied header", which is spoofable.
TrustedProxyCIDRs []string `toml:"trusted_proxy_cidrs"`
}
SecurityConfig captures login/SSRF hardening knobs.
type ServerConfig ¶
type ServerConfig struct {
Name string `toml:"name"`
Engine string `toml:"engine"`
Host string `toml:"host"`
Port int `toml:"port"`
Socket string `toml:"socket"`
Database string `toml:"database"`
FilePath string `toml:"file"`
User string `toml:"user"`
Password string `toml:"password"`
SSLMode string `toml:"sslmode"`
Params map[string]string `toml:"params"`
}
ServerConfig is a predefined server users can pick on the login page without typing host/port. Only User/Password are collectible at login (each collected only when left empty here); the connection topology — Host/Port/Database/ SSLMode/FilePath — comes solely from this config, never from a posted value. A predefined PostgreSQL server on a managed host that blocks the default `postgres` database must therefore set Database here (an empty Database still defaults to "postgres" at connect time).
type SessionConfig ¶
type SessionConfig struct {
CookieName string `toml:"cookie_name"`
IdleTimeout time.Duration `toml:"idle_timeout"`
AbsoluteTimeout time.Duration `toml:"absolute_timeout"`
}
SessionConfig controls cookie and timeout policy.
type StorageConfig ¶
type StorageConfig struct {
Engine string `toml:"engine"`
Host string `toml:"host"`
Port int `toml:"port"`
Socket string `toml:"socket"`
Database string `toml:"database"`
FilePath string `toml:"file"`
User string `toml:"user"`
Password string `toml:"password"`
SSLMode string `toml:"sslmode"`
Params map[string]string `toml:"params"`
// TablePrefix is prepended to every metadata table name. It is concatenated
// into DDL, so storage.Open validates its shape rather than escaping it —
// quoting would let an absurd prefix through as a legal identifier.
TablePrefix string `toml:"table_prefix"`
}
StorageConfig points TableX at a database of its OWN, for the state the application needs to outlive a process — today the session envelopes that make sessions survive a restart and work behind a load balancer (internal/storage).
Engine empty (the default) means no metadata database and no behaviour change whatsoever: sessions live in process memory exactly as they always have.
These credentials are operator-defined, like a predefined server's, which is the documented exception to "credentials are never persisted" (docs/security.md §2). The metadata database holds live session ids, so it is as sensitive as the cookies it stands in for — give TableX its own database and its own account.
func (StorageConfig) Enabled ¶
func (s StorageConfig) Enabled() bool
Enabled reports whether a metadata database is configured.