The SkyEye Handbook
SkyEye is a single binary that stands in front of your application, filters every request, and bans attackers before your server ever hears from them. This handbook takes you from download to daily operation, one chapter at a time.
Here is the whole mental model, in three sentences:
- It's the front door. SkyEye owns your public port, terminates the connection, and forwards only clean traffic to your app, like nginx but with a built-in defense system.
- One binary, two roles. Run
skyeyewith no arguments and it's the server. Run it with a command likeskyeye statusorskyeye bansand it's a client talking to the running server. - One file configures everything. A single
skyeye.yamlnext to the binary holds every setting. Every setting has a sensible default, so your file stays tiny.
http://127.0.0.1:8080. On first run it writes a fully-commented starter skyeye.yaml for you, and it never overwrites an existing one.If you only read two chapters, read Getting started and How SkyEye defends you. Everything else is here for the day you need it.
Getting started
SkyEye is one self-contained executable: no dependencies, no database, no installer. Getting to a protected app takes three steps.
Step 1: Run it once
# ports 80 and 443 need root
sudo ./skyeye
On this first run SkyEye writes a starter skyeye.yaml next to itself, starts listening on port 80, and forwards traffic to http://127.0.0.1:8080. If that's where your app lives, you are already protected.
Step 2: Point it at your app
Open skyeye.yaml and set one line to wherever your application listens:
backend_url: http://127.0.0.1:3000
Step 3: Make it a service
For a real server you want SkyEye to start on boot and restart if it ever dies. One systemd unit does it:
# /etc/systemd/system/skyeye.service
[Unit]
Description=SkyEye
After=network.target
[Service]
WorkingDirectory=/etc/skyeye
ExecStart=/usr/local/bin/skyeye
Restart=always
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now skyeye
Keep skyeye.yaml in the WorkingDirectory so SkyEye finds it automatically.
The config file
Everything lives in skyeye.yaml. There is no second file, no environment soup, no web panel. Three rules to live by:
- Only keep the keys you change. Every setting has a built-in default. A production config is often three lines long.
- Edit, then
skyeye reload. Changes apply live, between one request and the next, with zero dropped connections. - Lines starting with
#are comments. Use them.
A realistic production config
This is what a typical finished file looks like: one app, HTTPS, and an office IP that can never be banned:
backend_url: http://127.0.0.1:8080
tls: true
domains: [api.example.com]
allowlist:
- 198.51.100.7 # office
Reload vs. restart
Almost everything applies live with skyeye reload: routes, the allowlist, rate limits, ban durations, thresholds. A few settings rebind network sockets and need a full restart instead:
listen_addrand the TLS group (tls,tls_addr,domains,cert_dir)backend_url
skyeye reload and a changed setting needs a restart, SkyEye tells you and keeps the running value until you restart.HTTPS
Two lines give you automatic HTTPS, forever:
tls: true
domains: [example.com, www.example.com]
With that in place, SkyEye:
- owns port 443 and fetches a free certificate from Let's Encrypt on the first request to each domain,
- renews certificates automatically before they expire. No certbot, no cron jobs, no 3am expired-certificate outages,
- turns port 80 into a redirect: every plain-HTTP visitor is sent to HTTPS.
Before you flip it on
- DNS must point at this server for every domain you list. Let's Encrypt verifies by visiting you.
- Ports 80 and 443 must both be open. Port 80 isn't optional. It's how domains are validated.
skyeye-certs folder. It holds your certificates and Let's Encrypt account key. Lose it and SkyEye simply re-issues on next start, but keeping it avoids rate-limit trouble if you reinstall often. Change its location with cert_dir.All listed domains share the same routes. Routing is by path, not by domain. If you need different backends per domain, keep that split in your app for now.
Routing
One app needs no routing at all; backend_url is enough. The moment you run two services on one server, add a routes list. It's SkyEye's version of nginx location blocks, readable by humans:
routes:
- prefix: /api/
target: http://127.0.0.1:9000
- prefix: /
target: http://127.0.0.1:3000
How a route is chosen
- A request matches a route when its path starts with the route's
prefix. - Longest prefix wins:
/api/usersgoes to/api/, not/. /is the catch-all. Order in the file doesn't matter. SkyEye sorts for you.
What every route does for free
You never configure any of this. It's on for all routes, always:
- The original
HostplusX-Real-IP,X-Forwarded-ForandX-Forwarded-Protoare forwarded to your backend. - WebSockets and event streams just work, and stay open for hours.
- Responses stream through immediately, with no buffering.
The route options
| Field | Type | What it does |
|---|---|---|
prefix | string required | Path prefix to match, e.g. /api/. |
target | string | Backend URL, e.g. http://127.0.0.1:8080. Use this or targets. |
targets | list | Several backend URLs. SkyEye load-balances visitors across them and skips a server that stops answering. |
lb | string | How to balance: round_robin (default) or ip_hash, which keeps each visitor on the same server. |
strip | bool | Drop the prefix before forwarding. Usually auto-detected, see below. |
body_mb | number | Max request body for this route in MB, overriding the global max_body_mb (default 10). 0 = unlimited. |
headers | map | Extra request headers sent to the backend: an internal key, a marker, a tenant tag. |
Prefix stripping, in one table
Should the backend see the full public path, or the path with the prefix removed? SkyEye auto-detects from the target: a target with a path strips, a bare host keeps. Set strip yourself only to override.
| Route | Request | Backend receives |
|---|---|---|
/livekit/ → ...:7880/ strip | /livekit/rtc | /rtc |
/ → ...:8080 keep | /pricing | /pricing |
/api/ → ...:8080/v2/ strip | /api/users | /v2/users |
Recipe: load-balance across several servers
List more than one server under targets and SkyEye spreads visitors across them automatically. A server that stops answering is taken out of rotation for 10 seconds, and its traffic flows to the others. No health-check setup needed.
routes:
- prefix: /
targets:
- http://127.0.0.1:8081 # server a
- http://127.0.0.1:8082 # server b
- http://127.0.0.1:8083 # server c
If your app keeps login sessions in per-server memory, add lb: ip_hash so each visitor always lands on the same server:
routes:
- prefix: /
targets:
- http://127.0.0.1:8081
- http://127.0.0.1:8082
lb: ip_hash
Recipe: several microservices
routes:
- prefix: /auth/
target: http://127.0.0.1:4001
- prefix: /payments/
target: http://127.0.0.1:4002
- prefix: /
target: http://127.0.0.1:8080
Recipe: big uploads on one route only
Keep the global cap tight and open up just the upload endpoint:
max_body_mb: 10
routes:
- prefix: /upload
target: http://127.0.0.1:8080
body_mb: 200
- prefix: /
target: http://127.0.0.1:8080
Recipe: a secret header for an internal API
routes:
- prefix: /internal/
target: http://127.0.0.1:9000
headers:
X-Internal-Key: s3cr3t-shared-token
- prefix: /
target: http://127.0.0.1:8080
Recipe: public path ≠ backend path
Public /api/..., but the backend serves it under /v2/...:
routes:
- prefix: /api/
target: http://127.0.0.1:9000/v2/
# /api/users -> /v2/users
- prefix: /
target: http://127.0.0.1:8080
Behind Cloudflare / nginx
Skip this chapter entirely if SkyEye faces the internet directly. The default behavior is already correct.
If a proxy you control sits in front (Cloudflare, or an nginx you're keeping), every connection SkyEye sees comes from the proxy. Without help, SkyEye would treat all your visitors as one IP, and might even ban your own edge. Two lines fix it:
trust_proxy_headers: true
trusted_proxy_hops: 1 # how many proxies sit in front
SkyEye then reads the real client IP the safe way: it prefers Cloudflare's CF-Connecting-IP when present, and otherwise reads X-Forwarded-For counting from the right (the trusted end), so a client can't forge a header to frame someone else or dodge a ban.
Recipe: behind Cloudflare
Cloudflare terminates HTTPS at the edge and forwards to SkyEye, so TLS stays off here:
tls: false
trust_proxy_headers: true
trusted_proxy_hops: 1
routes:
- prefix: /
target: http://127.0.0.1:8080
trust_proxy_headers at false when SkyEye is directly exposed to the internet. Trusting these headers on the open internet lets anyone forge their IP, evade bans, or get others banned.How SkyEye defends you
You don't have to configure any of this. It's all on by default. But knowing how it thinks makes the numbers in the next chapter obvious. SkyEye watches every request with three detectors.
1. Known attack paths
Hundreds of paths on the internet are only ever requested by attackers: .env files, git internals, SSH keys, database dumps, webshells, admin-panel probes. SkyEye knows them on sight, including ones wrapped in layers of percent-encoding, in the path or the query string, which it peels off before judging. One hit and the visitor is banned for 7 days. No warnings.
2. Scanners betrayed by their guessing
New attack tools give themselves away by probing paths that don't exist. SkyEye counts 404s per visitor:
- 30 suspicious 404s in a minute (dotfiles,
.php,.bak…): banned for an hour. - 120 of any kind in a minute: banned, so probing "clean" paths doesn't dodge it.
- 100 suspicious 404s in an hour: catches slow scanners that stay under the fast limits.
3. Flooding
Every visitor gets a fair pace: 120 requests per minute before SkyEye answers 429 Too Many Requests, and 400 per minute before an hour-long ban. Static assets (images, CSS, JS, fonts, media) never count, so an asset-heavy page loaded by a whole office behind one IP punishes no one.
Repeat offenders have it worse
SkyEye keeps an offense record for 90 days. A second automatic ban lasts at least 2 weeks, and a third and beyond at least 30 days. Scanners that plan to wait out their sentence find a longer one waiting.
What honest visitors experience
- A customer with a broken image link is never banned, because asset 404s don't count as suspicious.
- An office sharing one IP never trips the rate limit on normal browsing.
- Bans cover an IPv6 attacker's whole
/64block, so address-hopping buys nothing, while honest IPv6 users are unaffected. - And your own IPs can be made untouchable. That's the allowlist chapter.
Tuning the defense
The defaults fit a typical API or web app. Change them only when reality tells you to, for example a legitimate heavy client hitting 429s. Edit, then skyeye reload. Everything here applies live.
Pace limits (under rate:)
| Key | Default | Meaning |
|---|---|---|
limit_max | 120 | Requests per window before a 429. |
hard_max | 400 | Requests per window before a ban. |
window_seconds | 60 | The sliding window length. |
Scanner detection (under storm_404:)
| Key | Default | Meaning |
|---|---|---|
threshold | 30 | Suspicious 404s per window before a ban. |
any_threshold | 120 | 404s of any kind per window before a ban. |
window_seconds | 60 | The fast window length. |
slow_threshold | 100 | Suspicious 404s over the slow window. Catches patient scanners. |
slow_window_seconds | 3600 | The slow window length. |
Ban lengths (under ban:, in seconds)
| Key | Default | Meaning |
|---|---|---|
restricted_seconds | 604800 | Known attack-path hit. 604800 = 7 days. |
storm_404_seconds | 3600 | Scanner ban. 3600 = 1 hour. |
rate_abuse_seconds | 3600 | Flooding ban. 3600 = 1 hour. |
skyeye ban always uses exactly the duration you type.People you never ban
Some addresses must never be locked out, no matter what they do: your office, your uptime monitor, a partner's crawler. Put them on the allowlist:
allowlist:
- 198.51.100.7 # office
- 203.0.113.20 # uptime monitor
- 2001:db8::1 # IPv6, covers its whole /64
Then skyeye reload. An allowlisted address:
- skips every check: bans, rate limits, scanner detection, all of it,
- has any existing ban and offense history lifted the moment it's added,
- cannot be banned even by hand, so a typo in a
skyeye bancommand can never lock out something critical, - survives restarts, because it lives in the config file, not in memory.
Day-to-day commands
Your terminal is the dashboard. There is no web panel to secure and no login to leak. The CLI talks to the running server over a local socket that never touches the network. Run commands on the same machine, as the same user the server runs as (use sudo if the server runs as root), from the server's directory.
| Command | What it does |
|---|---|
skyeye status | The wall at a glance: uptime, active bans by reason, tracker sizes. |
skyeye stats | Traffic totals, requests/sec, top requesters, top 404 sources, top offenders. |
skyeye bans | Every active ban: ID, target, time left, reason. |
skyeye ban <ip> [dur] [reason] | Ban by hand. Durations the way you think them: 30m, 2h, 7d. Default 1h. |
skyeye unban <id|ip> | Lift a ban and forgive its offense history. |
skyeye check <ip> | The full story of one address: banned or not, why, until when, offenses, current rate. |
skyeye reload | Apply config changes live, zero dropped connections. |
skyeye stop | Graceful shutdown. |
Three moments you'll actually have
"Someone says they're blocked." Get their IP and ask SkyEye for the full story, then decide:
skyeye check 203.0.113.42
skyeye unban 203.0.113.42 # lifts the ban AND forgives the history
"I found an abuser in my logs." Don't wait for a rule to trip:
skyeye ban 45.155.205.11 7d scanner from access logs
"Is it actually doing anything?"
skyeye status
skyeye stats
Coming from nginx
For the classic front-door job (own the port, terminate TLS, route to backends) SkyEye replaces nginx one-to-one, and adds the defense layer nginx doesn't have. Your config translates line for line:
| nginx | SkyEye (skyeye.yaml) |
|---|---|
server_name x.com; | domains: [x.com] |
client_max_body_size 10m; | max_body_mb: 10 |
location /a/ { proxy_pass …:7880/; } | route /a/ → …:7880/ |
location / { proxy_pass …:8080; } | route / → …:8080 |
WebSocket Upgrade headers | automatic |
proxy_buffering off; | automatic. Responses always stream |
Host / X-Real-IP / X-Forwarded-* | forwarded automatically |
| 443 SSL via certbot | tls: true |
| 80 → 301 redirect | automatic when TLS is on |
The cutover, in order
- Write your
skyeye.yaml:tls: true, yourdomains, your routes. - Stop nginx to free ports 80 and 443.
- Start SkyEye (
sudo skyeye, or the systemd service from Getting started). - Visit your site over HTTPS. The certificate issues on that first request.
Good to know
- Bans reset on restart, by design. All ban state lives in memory, so a restart is the escape hatch for any bad ban. The allowlist is unaffected. It lives in the config file.
- Long connections never get cut. WebSockets and event streams hold for hours. Every byte streams through the moment it arrives.
- It's a gateway, not a web server. SkyEye does reverse proxying, routing, load balancing, TLS and defense. It does not serve static files, gzip, cache, or speak HTTP/2.
- Non-HTTP traffic goes around it. WebRTC media over UDP, for example, flows straight to its service. SkyEye handles the HTTP and WebSocket side. Keep those ports open in your firewall.
Advanced settings
Most deployments never touch these. They exist for the day you need them:
| Key | Default | When you'd change it |
|---|---|---|
request_timeout_seconds | 120 | Max time to read a request (WebSockets and streams exempt). Raise it if users upload big files over slow connections; 0 disables. |
ipv6_ban_prefix | 64 | How wide an IPv6 ban reaches. Lower to 48 if an attacker rotates through a huge allocation. |
listen_addr | :80 | Run the HTTP listener on a different port. restart |
tls_addr | :443 | Run the HTTPS listener on a different port. restart |
cert_dir | skyeye-certs | Where certificates are cached. Back it up. restart |
control_socket | /tmp/skyeye.sock | Where the CLI socket lives. If you move it, run CLI commands from the server's directory or set SKYEYE_CONFIG. restart |
One environment variable exists: SKYEYE_CONFIG=/path/to/skyeye.yaml points both the server and the CLI at a config outside the working directory.