HanyanOS 部署手记
Introduction
A self-hosted infrastructure exposed to the public internet is only as strong as its weakest access path. HanyanOS runs seven public-facing services — web, mail, tunnels, agents — on a single N100 behind a Singapore VPS. The attack surface is non-trivial: 5 open TCP ports on the N100, 7 DNS domains routed through SNI, and multiple daemons accepting connections from arbitrary IPs.
This post covers the three-layer defense-in-depth strategy that protects the stack: UFW for stateful packet filtering at the host level, Fail2ban for adaptive intrusion prevention at the application level, and SSH hardening for cryptographic access control. Each layer is independent but composable — a failure in one does not leave the system exposed.
Layer 1: UFW — Host Firewall
Principle of Least Port
The N100 runs Ubuntu 24.04 with UFW enabled at boot. The default policy is deny incoming, allow outgoing — any service that does not explicitly register a rule is unreachable from the network.
1 | # Default deny ingress, allow egress |
The resulting nftables ruleset (UFW’s modern backend) shows clean ufw-user-input chains:
1 | chain ufw-user-input { |
Five ports open. That is the entire exposed surface of the N100.
Rate-Limited Logging
UFW logs blocked packets at LOGLEVEL=low, with a 3/minute ingress burst limit in ufw-user-limit to prevent log flooding:
1 | chain ufw-user-limit { |
VPS Edge Filtering
The AWS Lightsail instance acts as the first line of defense. Its security group allows only ports 80, 443, and the FRP control port from 0.0.0.0/0. All other traffic is dropped at the cloud provider level — before it ever reaches the N100 tunnel.
1 | Lightsail Security Group: |
Layer 2: Fail2ban — Adaptive Intrusion Prevention
Jail Configuration
Fail2ban monitors service logs and dynamically inserts nftables rules to block offending IPs. On this N100, only one jail is actively enabled — sshd — because the other services (HTTP via FRP, SMTP/IMAP via Stalwart) are either tunneled or already running with authentication controls.
Configuration at /etc/fail2ban/jail.d/local.conf:
1 | [DEFAULT] |
Key decisions:
backend = systemd: Uses journald instead of logfile polling. More reliable, lower overhead, and captures SSH auth failures that may not appear in/var/log/auth.logunder heavy load.banaction = nftables: UFW in modern Ubuntu uses nftables as its backend. Fail2ban’s nftables action inserts rules at priority 1 in the filter table, co-existing cleanly with UFW’s chains.bantime = 1h: One-hour bans are sufficient to deter dictionary attacks without permanently blacklisting transient scanners.
Filter Details
The sshd filter (/etc/fail2ban/filter.d/sshd.conf) matches journal entries for:
1 | _SYSTEMD_UNIT=sshd.service + _COMM=sshd |
This is stricter than regex-based log parsing — it only matches events from the actual SSH daemon process, not simulated log entries.
Available but Inactive Jails
Additional filter definitions are available for future activation:
| Filter File | Purpose |
|---|---|
nginx-http-auth.conf |
HTTP basic auth failures |
nginx-limit-req.conf |
Rate-limit violations |
nginx-botsearch.conf |
Directory brute-force scans |
nginx-bad-request.conf |
Malformed HTTP requests |
postfix.conf |
SMTP auth failures |
dovecot.conf |
IMAP/POP3 auth failures |
postfix-sasl.conf |
SASL authentication failures |
These are intentionally left disabled — the N100’s exposed mail ports use TLS client certificates, and the HTTP traffic arriving via FRP is already filtered by the VPS. Enabling them would add log-processing overhead with minimal security gain.
Layer 3: SSH Hardening
Key-Only Authentication
Password authentication is disabled across the board. The /etc/ssh/sshd_config contains:
1 | PubkeyAuthentication yes |
Authorized Keys
Four keys are registered in ~/.ssh/authorized_keys, each with distinct roles:
- icema@Michael-PC — RSA 4096, primary admin key (desktop)
- serena@aicore — RSA 4096, AI service key (remote agent management)
- 含烟@aicore — Ed25519, lightweight agent key
- hanyan-agent@aicore — Ed25519, automation/deployment key
The use of Ed25519 keys for automation is deliberate: Ed25519 signatures are ~3× faster to verify than RSA 4096, and the keys are only 64 bytes in the public part — trivial to embed in CI/CD configurations.
Hardening Checklist
| Setting | Value | Rationale |
|---|---|---|
Port |
22 | Default; security by obscurity not relied on |
PermitRootLogin |
no |
Root access via su or sudo only |
PubkeyAuthentication |
yes |
Cryptographic trust |
PasswordAuthentication |
no |
Eliminates brute-force vector |
KbdInteractiveAuthentication |
no |
Disables keyboard-interactive fallback |
ChallengeResponseAuthentication |
no |
Disables additional auth challenge paths |
ClientAliveInterval |
(default) | Maintains connection health |
MaxAuthTries |
(default) | Default 6; fail2ban catches excess |
UsePAM |
yes |
Required for some key-management tools |
Access Topology
1 | ┌─────────┐ SSH (key only) ┌──────────┐ |
Problems Encountered
1. UFW and Docker Port Conflicts
Problem: Docker publishes ports directly to the host’s iptables/nftables chains, bypassing UFW’s user-defined rules. This meant a container exposing a port would make it accessible even if UFW had no corresponding allow rule.
Solution: Configured Docker to use iptables: false in /etc/docker/daemon.json and managed all container networking through explicit --publish bindings. UFW rules then applied to the host-side port binding exclusively.
2. Fail2ban nftables vs UFW Chain Priority
Problem: Early fail2ban configurations used iptables as the ban action, which created iptables rules in a separate kernel table from UFW’s nftables ruleset. Bans were applied but not visible in ufw status.
Solution: Switched to banaction = nftables and banaction_allports = nftables[type=allports]. Fail2ban now inserts bans into the nftables f2b-sshd chain at priority 1, which is evaluated before UFW’s ufw-user-input chain — ensuring banned IPs are dropped before reaching service-specific accept rules.
3. SSH Brute-Force Without fail2ban
Problem: During the initial deployment week before fail2ban was configured, /var/log/auth.log showed 2,300+ failed SSH attempts from 47 distinct IPs in a single day — mostly from Chinese and Russian datacenter ranges.
Solution: After enabling fail2ban with a 5-retry/10-minute window and 1-hour ban, failed attempts dropped to near zero. The ban list remains empty on most days now — the bots learned quickly.
Summary
The three-layer defense model for HanyanOS follows a simple principle: deny by default, allow by exception, adapt automatically.
1 | Cloud Edge (Lightsail SG) |
With 5 exposed ports, 4 authorized SSH keys, one fail2ban jail, and a cloud edge filter, the N100 has operated for 6 months with zero security incidents. The system is not impenetrable — no system is — but it is layered enough that a single misconfiguration or zero-day in one layer does not expose the rest of the stack.
Next in the series: SSL Certificate Automation with acme.sh and Route53 DNS.