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)

Set cache-size: 0 to disable caching entirely.

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.

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"

Request Body Limits (New)

bserver enforces configurable request body limits for dynamic handlers (YAML/Markdown render requests that execute scripts and PHP CGI requests). Oversized bodies are rejected with:

Configuration

Set this in www/_config.yaml (or per-vhost _config.yaml):

max-body-bytes: 1048576

You can also override globally with environment variable:

This helps prevent unbounded memory usage from large POST payloads.

TLS Hardening Defaults (New)

HTTPS now uses hardened defaults by default:

This keeps compatibility while improving secure-by-default transport settings.