Server Features

bserver includes several production-oriented features that work automatically without configuration.

Virtual Host Resolution

bserver uses the HTTP Host header to resolve requests to virtual host directories under the www/ base directory. Each directory name corresponds to a domain.

Resolution Order

  1. Direct match — if www/<hostname> exists as a directory (or symlink), the request is served from there.
  2. Known vhost fallback — if the hostname is one subdomain deeper than a known vhost directory (e.g., www.example.com when www/example.com exists), the request is served from www/default.
  3. Unknown domain rejection — if the hostname doesn't match any vhost directory and isn't one subdomain deeper than one, the server responds with 421 Misdirected Request. The request is not served.

This means www.example.com and api.example.com automatically work when you create www/example.com, but deeply nested bogus domains like update.update.update.m.example.com are rejected immediately.

For domains that need more than one level of subdomains, create a symlink:

cd www && ln -s example.com deep.sub.example.com

The default Directory

The www/default directory serves as a fallback for known vhosts that don't have their own directory. For example, if www/example.com exists and someone visits www.example.com, the request is served from www/default (since www/www.example.com doesn't exist, but www.example.com is one level deeper than the known example.com vhost).

If the default directory doesn't exist, requests that would fall back to it receive a 404 Not Found.

Render Cache

bserver caches rendered YAML and markdown pages in memory. When the same page is requested again, the cached HTML is served directly without re-rendering. This significantly reduces CPU usage for sites with many visitors.

How It Works

Cache Eviction

Entries are evicted in three ways:

  1. File change — fsnotify detects a source file was modified, created, renamed, or deleted.
  2. Age expiry — entries older than the configured max age are discarded on the next access (default: 15 minutes).
  3. Size pressure — when total cache size exceeds the limit, the least recently used entries are evicted first (LRU).

RAM Detection

At startup, bserver checks available system memory on Linux by reading /proc/meminfo. If available RAM is limited, the cache size is automatically reduced:

A warning is logged when the effective cache size is lower than the configured maximum. On non-Linux platforms, the configured maximum is used as-is.

Configuration

These settings go in _config.yaml (in the www directory):

Setting Default Description
cache-size 1024 Maximum cache size in MB (0 to disable)
cache-age 900 Maximum entry age in seconds (15 minutes)
static-age 86400 Maximum Cache-Control age for static files in seconds (24 hours)
max-body-size 10 Maximum request body size in MB (0 to disable)

Set cache-size: 0 to disable caching entirely.

Set max-body-size: 0 to allow unlimited request bodies (not recommended). The request body is always piped to scripts on stdin, so the practical limit is set by max-body-size rather than by OS environment-variable limits.

Cache-Control Headers

bserver sets Cache-Control headers on all responses to help browsers and proxies cache content efficiently.

Rendered Pages

YAML and markdown pages receive a Cache-Control: public, max-age=N header where N matches the cache-age setting (default 900 seconds / 15 minutes). This tells browsers to reuse the page without re-requesting it for that duration.

Static Files

For static files (images, CSS, JavaScript, fonts, etc.), bserver uses a heuristic based on the file's last modification time:

For example, a CSS file last modified 2 hours ago gets max-age=3600 (1 hour). A logo image unchanged for 30 days gets max-age=86400 (24 hours, the cap).

This approach means frequently-updated files are re-checked sooner, while stable files are cached longer.

TLS Certificate Management

bserver automatically manages TLS certificates for HTTPS. To protect against bogus domains exhausting Let's Encrypt rate limits, certificate requests are restricted to known virtual hosts.

Which Domains Get Let's Encrypt Certificates

A domain qualifies for a Let's Encrypt certificate only if it passes the same known-vhost check used for request routing:

  1. Direct match — a directory exists at www/<domain> (e.g., www/example.com)
  2. One subdomain deeper — the parent domain has a directory (e.g., www.example.com works when www/example.com exists)

Deeply nested bogus domains like a.b.c.d.example.com are rejected without contacting Let's Encrypt.

Domains Without a Virtual Host

Requests to unknown domains are rejected at two levels:

  1. TLS layer — a self-signed certificate is returned (no Let's Encrypt request is made), preventing bogus domains from exhausting LE rate limits.
  2. HTTP layer — the server responds with 421 Misdirected Request without serving any content. This 421 counts as an error for the rate limiter, so persistent scanners are blocked after 10 attempts.

Private and Non-Public Domains

IP addresses and domains with non-public suffixes (.local, .test, .internal, etc.) always get self-signed certificates without contacting Let's Encrypt.

Security Headers

Every response includes these security headers automatically:

Header Value Purpose
X-Content-Type-Options nosniff Prevents browsers from MIME-sniffing
X-Frame-Options SAMEORIGIN Blocks framing by other sites (clickjacking protection)
Referrer-Policy strict-origin-when-cross-origin Limits referrer information sent to other origins

These are applied as middleware, so they cover all responses including static files, rendered pages, error pages, and PHP output.

Request Logging

Every HTTP request is logged with the client IP address, hostname, HTTP method, path, response status code, and duration:

203.0.113.42 example.com GET / 200 12ms
203.0.113.42 example.com GET /about 200 3ms
198.51.100.7 example.com GET /missing 404 1ms

The IP address is extracted from the TCP connection source (RemoteAddr). This makes it easy to identify repeated requests from the same source, spot scanning patterns, and correlate with rate limiting events.

Cached responses are typically much faster than first renders, making it easy to spot cache misses in the logs.

Rate Limiting

bserver automatically rate-limits IP addresses that make too many consecutive failed requests (status 400 or higher). This protects against scanning, fishing, and brute-force attacks without affecting normal traffic.

How It Works

  1. Every response is tracked per client IP address.
  2. Each error response (4xx or 5xx) increments a consecutive error counter for that IP.
  3. Any successful response (2xx or 3xx) resets the counter to zero.
  4. When an IP accumulates 10 consecutive errors, it is blocked.

This means legitimate users who occasionally hit a 404 are unaffected — a single successful page view resets the counter entirely.

Blocked Requests

When a blocked IP sends a request, the server skips all normal request processing (no routing, no rendering, no file I/O) and responds with a minimal drop response using one of several randomized strategies:

The randomized responses are designed to confuse automated scanners and make it difficult for attackers to distinguish between a block and a genuine server issue. Only the first blocked request is logged (with "dropped" in place of the status code) to avoid flooding the log.

Escalating Penalties

Each time an IP is blocked, the penalty duration doubles:

Offense Block Duration
1st 10 minutes
2nd 20 minutes
3rd 40 minutes
4th 80 minutes
... ...
9th+ ~42 hours (cap)

The penalty level is preserved across blocks, so a persistent attacker faces increasingly long timeouts. The penalty history is cleared when the IP has been idle for at least 1 hour after its block expires.

Example Log Output

A scanning attack against a known vhost (error paths on a valid domain):

198.51.100.7 example.com POST /webhook/upload 404 106ms
198.51.100.7 example.com POST /webhook/files 404 109ms
...
198.51.100.7 rate-limited after 10 consecutive errors (penalty: 10m0s)
198.51.100.7 example.com POST /webhook/batch dropped

A scanning attack using bogus domains (rejected at the vhost level):

198.51.100.7 bogus.update.m.example.com GET / 421 0s
198.51.100.7 bogus.update.m.example.com GET /admin 421 0s
...
198.51.100.7 rate-limited after 10 consecutive errors (penalty: 10m0s)
198.51.100.7 bogus.update.m.example.com GET / dropped

Only the first dropped request is logged; subsequent drops from the same IP during the same penalty period are silently discarded.

HTTP to HTTPS Redirect

When HTTPS is active (port 443 is available), all HTTP requests are automatically redirected to HTTPS with a 308 Permanent Redirect status. The only exception is ACME HTTP-01 challenge requests from Let's Encrypt, which are handled on port 80 to complete certificate issuance.

When HTTPS is not available (port 443 cannot be bound), HTTP serves requests directly with the full middleware chain (logging, security headers, rate limiting).

Privilege Dropping

After binding to privileged ports (80 and 443), bserver drops privileges to the nobody user. This limits the impact of any potential security vulnerability — even if the server process is compromised, it runs with minimal filesystem and system permissions.

Privilege dropping is automatic and logged at startup:

Dropped privileges to nobody (UID=65534 GID=65534)

If the nobody user doesn't exist or privilege dropping fails for any reason, the server continues as the current user and logs a warning.

Port Fallback

If port 80 is unavailable (e.g., another process is using it, or the server is running without root privileges), bserver automatically tries alternative ports 8000 through 8099 and uses the first available one.

This makes it easy to run bserver in development without sudo:

Warning: cannot listen on :80 (trying alternative ports)
Using alternative HTTP port: :8000

If port 443 is unavailable, HTTPS is disabled and the server runs HTTP-only. A warning is logged but the server continues normally.

Graceful Shutdown

bserver handles SIGINT (Ctrl+C) and SIGTERM signals gracefully:

  1. Stops accepting new connections
  2. Waits up to 10 seconds for in-flight requests to complete
  3. Closes the render cache and file watchers
  4. Exits cleanly

This means deployments using systemctl restart or container orchestrators won't drop active requests.

Allowed File Types

bserver only serves files whose extension appears in the allowed-types list. Requests for unlisted extensions return 404 even if the file exists. This stops accidental exposure of configuration, secrets, or backup files (.env, .json, .sql, .log, etc.).

The default list is permissive enough for typical web content (HTML, CSS, JS, images, fonts, audio, video, PDF, etc.). To customize, set types: in _config.yaml or the TYPES environment variable:

# www/_config.yaml or www/example.com/_config.yaml
types:
  - yaml
  - md
  - png
  - svg
  - css
  - js

The types setting can be overridden per-vhost.

Per-vhost HTTP

_config.yaml accepts allow-http: true on a per-vhost basis. When set, the vhost is served over plain HTTP instead of being redirected to HTTPS. This is intended for constrained clients (IoT devices, embedded systems) that cannot do TLS. A warning is logged because session cookies and other secrets may transit in cleartext.

# www/iot.example.com/_config.yaml
allow-http: true

Favicons

Every vhost gets a /favicon.ico even without an favicon.ico file. By default, bserver generates one on the fly using the first three letters of the domain name, white on black. Drop a real favicon.ico into the vhost's document root to use that instead.

To customize the generated icon, add a _favicon.yaml to the vhost root:

# Text mode
text: ABC
color: white
background: navy
# Image mode — scales an image to a square ICO
image: logo.png
fit: contain   # contain | crop | stretch

The generated icons are cached in memory and regenerated when _favicon.yaml (or the source image) changes on disk.

Debug Mode

Append ?debug to any URL to emit HTML comments throughout the rendered output that trace name resolution, format selection, and rendering depth.

For production, set debug-token in _config.yaml (or the DEBUG_TOKEN environment variable) to require a secret: ?debug=<token>. With a token configured, the bare ?debug no longer works — a constant-time compare gates access. With no token configured, ?debug is open (development default).

JS Heap Cap

Each embedded-JavaScript invocation has a soft heap-growth cap to protect against runaway scripts:

# www/_config.yaml
js-heap-mb: 128   # 0 disables the check

The cap is sampled every 100 ms; a single huge allocation between probes can still escape, so a cgroup or other deployment-level memory limit is a useful belt-and-braces backstop on production hosts.

Memory Monitor

bserver includes a built-in memory monitor that logs heap, goroutine, and cache statistics on an interval and writes a pprof heap dump when growth exceeds a threshold. The relevant _config.yaml keys (all optional, all have sensible defaults):

These are diagnostics, not safety limits. The JS heap cap above and your OS/cgroup limits are what actually stop runaway memory use.

Version Flag

Use -version to print the build version and exit:

$ bserver -version
bserver dev

Override the version at build time with:

go build -ldflags "-X main.Version=1.0.0"