SkyEye

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 skyeye with no arguments and it's the server. Run it with a command like skyeye status or skyeye bans and it's a client talking to the running server.
  • One file configures everything. A single skyeye.yaml next to the binary holds every setting. Every setting has a sensible default, so your file stays tiny.
You can skip configuration entirely. With no config file at all, SkyEye listens on port 80 and forwards everything to 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.
What SkyEye is for. SkyEye guards applications: APIs, app servers, microservices, anything that listens on a port. It is not a web server. It does not host static websites from disk. Simple rule: apps on ports, SkyEye. Files on disk, nginx or a CDN.

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.

That's a complete setup. The full defense (attack-path bans, scanner detection, rate limiting) is already active with zero configuration. The next chapters add HTTPS and routing when you need them.

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_addr and the TLS group (tls, tls_addr, domains, cert_dir)
  • backend_url
You don't have to memorize this list. If you 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.
Back up the 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/users goes 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 Host plus X-Real-IP, X-Forwarded-For and X-Forwarded-Proto are 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

FieldTypeWhat it does
prefixstring requiredPath prefix to match, e.g. /api/.
targetstringBackend URL, e.g. http://127.0.0.1:8080. Use this or targets.
targetslistSeveral backend URLs. SkyEye load-balances visitors across them and skips a server that stops answering.
lbstringHow to balance: round_robin (default) or ip_hash, which keeps each visitor on the same server.
stripboolDrop the prefix before forwarding. Usually auto-detected, see below.
body_mbnumberMax request body for this route in MB, overriding the global max_body_mb (default 10). 0 = unlimited.
headersmapExtra 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.

RouteRequestBackend 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
What a route can't do (yet): per-route timeouts, response-header rewriting, authentication, caching, gzip, or per-domain routing. If you need one of these today, keep nginx or your app in the loop for that piece.

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
Warning: leave 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 /64 block, so address-hopping buys nothing, while honest IPv6 users are unaffected.
  • And your own IPs can be made untouchable. That's the allowlist chapter.
Bans live in memory, on purpose. Restarting SkyEye clears every ban and offense record. It's the ultimate undo button if anything was ever banned wrongly. The allowlist is different: it lives in the config file and survives every restart.

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

KeyDefaultMeaning
limit_max120Requests per window before a 429.
hard_max400Requests per window before a ban.
window_seconds60The sliding window length.

Scanner detection (under storm_404:)

KeyDefaultMeaning
threshold30Suspicious 404s per window before a ban.
any_threshold120404s of any kind per window before a ban.
window_seconds60The fast window length.
slow_threshold100Suspicious 404s over the slow window. Catches patient scanners.
slow_window_seconds3600The slow window length.

Ban lengths (under ban:, in seconds)

KeyDefaultMeaning
restricted_seconds604800Known attack-path hit. 604800 = 7 days.
storm_404_seconds3600Scanner ban. 3600 = 1 hour.
rate_abuse_seconds3600Flooding ban. 3600 = 1 hour.
Escalation for repeat offenders (2 weeks, then 30 days, with a 90-day memory) applies to automatic bans only. A manual 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 ban command can never lock out something critical,
  • survives restarts, because it lives in the config file, not in memory.
Do this on day one for your office IP and your uptime monitor. It's the one piece of configuration every deployment should have.

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.

CommandWhat it does
skyeye statusThe wall at a glance: uptime, active bans by reason, tracker sizes.
skyeye statsTraffic totals, requests/sec, top requesters, top 404 sources, top offenders.
skyeye bansEvery 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 reloadApply config changes live, zero dropped connections.
skyeye stopGraceful 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:

nginxSkyEye (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 headersautomatic
proxy_buffering off;automatic. Responses always stream
Host / X-Real-IP / X-Forwarded-*forwarded automatically
443 SSL via certbottls: true
80 → 301 redirectautomatic when TLS is on

The cutover, in order

  1. Write your skyeye.yaml: tls: true, your domains, your routes.
  2. Stop nginx to free ports 80 and 443.
  3. Start SkyEye (sudo skyeye, or the systemd service from Getting started).
  4. Visit your site over HTTPS. The certificate issues on that first request.
Keep nginx if you rely on something SkyEye deliberately doesn't do: serving static files from disk, gzip, caching, or HTTP/2. SkyEye can also sit behind nginx for just the defense. See Behind Cloudflare / nginx.

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:

KeyDefaultWhen you'd change it
request_timeout_seconds120Max time to read a request (WebSockets and streams exempt). Raise it if users upload big files over slow connections; 0 disables.
ipv6_ban_prefix64How wide an IPv6 ban reaches. Lower to 48 if an attacker rotates through a huge allocation.
listen_addr:80Run the HTTP listener on a different port. restart
tls_addr:443Run the HTTPS listener on a different port. restart
cert_dirskyeye-certsWhere certificates are cached. Back it up. restart
control_socket/tmp/skyeye.sockWhere 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.