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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Default deny ingress, allow egress
ufw default deny incoming
ufw default allow outgoing

# Explicit allow-list
ufw allow 22/tcp # SSH (key-only auth)
ufw allow 80/tcp # Nginx → FRP → VPS ingress
ufw allow 25/tcp # SMTP (inbound mail relay)
ufw allow 465/tcp # SMTP Submission (TLS)
ufw allow 587/tcp # SMTP Submission (STARTTLS)
ufw allow 993/tcp # IMAPS (mail retrieval)

# Explicit deny-list for unneeded services
ufw deny 137,138/udp # NetBIOS
ufw deny 139,445/tcp # SMB
ufw deny 2283/tcp # Reserved / unused

The resulting nftables ruleset (UFW’s modern backend) shows clean ufw-user-input chains:

1
2
3
4
5
6
7
8
9
10
11
chain ufw-user-input {
tcp dport 22 accept # SSH
tcp dport 80 accept # HTTP (FRP relay)
tcp dport 25 accept # SMTP
tcp dport 465 accept # SMTPS
tcp dport 587 accept # Submission
tcp dport 993 accept # IMAPS
udp dport {137,138} drop
tcp dport {139,445} drop
tcp dport 2283 drop
}

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
2
3
4
5
chain ufw-user-limit {
limit rate 3/minute burst 5 packets
log prefix "[UFW LIMIT BLOCK] "
reject
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
Lightsail Security Group:
┌────────────────────────────┐
│ Inbound Rules │
│ 80/tcp → 0.0.0.0/0 │
│ 443/tcp → 0.0.0.0/0 │
│ 7000/tcp→ 0.0.0.0/0 (FRP)│
│ * → ✗ Deny All │
└────────────┬───────────────┘
│ FRP Tunnel

┌────────────────────────────┐
│ N100 UFW (5 ports) │
│ 22, 80, 25, 465, 587, 993│
└────────────────────────────┘

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
2
3
4
5
6
7
8
9
10
[DEFAULT]
banaction = nftables
banaction_allports = nftables[type=allports]
backend = systemd

[sshd]
enabled = true
maxretry = 5
findtime = 10m
bantime = 1h

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.log under 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
2
3
4
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no

Authorized Keys

Four keys are registered in ~/.ssh/authorized_keys, each with distinct roles:

  1. icema@Michael-PC — RSA 4096, primary admin key (desktop)
  2. serena@aicore — RSA 4096, AI service key (remote agent management)
  3. 含烟@aicore — Ed25519, lightweight agent key
  4. 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
2
3
4
5
6
7
8
9
10
┌─────────┐   SSH (key only)   ┌──────────┐
│ Admin │ ──────────────────▶│ N100 │
│ Desktop │ │ Port 22 │
└─────────┘ └────┬─────┘

┌─────────┐ SSH (key only) ┌────▼─────┐
│ aicore │ ──────────────────▶│ fail2ban │
│ Agents │ │ nftables │
└─────────┘ │ block ✗ │
└──────────┘

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
2
3
4
5
6
7
8
9
10
11
12
13
Cloud Edge (Lightsail SG)
│ blocks all except 80/443/7000

Host Firewall (UFW/nftables)
│ blocks all except 6 TCP ports

Intrusion Prevention (Fail2ban)
│ dynamically blocks repeat offenders

SSH Hardening (key-only, no PAM auth)
│ cryptographic access control

N100 Services

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.