config

package
v1.0.0-rc.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 14 Imported by: 0

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

func CanonicalHost(host string) string

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

func IsHealthcheckRequest(err error) bool

IsHealthcheckRequest reports whether err signals the -healthcheck flag.

func IsVersionRequest

func IsVersionRequest(err error) bool

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

func Load(args []string) (Config, error)

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

func (c Config) CookiesSecure() bool

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

func (c Config) TLSEnabled() bool

TLSEnabled reports whether direct TLS serving is configured.

func (Config) Validate

func (c Config) Validate() error

Validate checks the resolved config for obvious inconsistencies.

func (Config) Warnings

func (c Config) Warnings() []string

Warnings returns non-fatal configuration advisories the server logs at startup (distinct from Validate's fatal errors).

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) Enabled

func (s SSOConfig) Enabled() bool

Enabled reports whether the SSO gate is configured.

func (SSOConfig) HasAllowlist

func (s SSOConfig) HasAllowlist() bool

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

func (s SSOConfig) PermitsIdentity(email string) bool

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

func (s SSOConfig) ResolvedScopes() []string

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL