HanyanOS 部署手记

Introduction

A self-hosted infrastructure with seven public-facing domains demands robust TLS certificate management. HanyanOS routes traffic for chenyun.org, www.chenyun.org, n8n.chenyun.org, photo.chenyun.org, mail.chenyun.org, hanyan.chenyun.org, and rss.chenyun.org through a single N100 server. Each subdomain requires valid, trusted certificates.

This post covers the certificate automation pipeline built around acme.sh with AWS Route53 DNS-01 challenge, enabling wildcard (*.chenyun.org) certificate issuance and fully automated renewal — no open ports, no webroot conflicts, no manual intervention.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌─────────────────────────────────────────────────────────────────┐
│ SSL Certificate Pipeline │
├─────────────────────────────────────────────────────────────────┤
│ │
│ acme.sh ──► AWS Route53 API ──► Let's Encrypt ──► ECC Certs │
│ │ (DNS-01 Challenge) │
│ │ │
│ ▼ │
│ /home/michael/.acme.sh/chenyun.org_ecc/ │
│ │ │
│ ▼ (manual/scripted copy) │
│ /etc/ssl/chenyun/ │
│ │ │
│ ▼ (symlink) │
│ /etc/letsencrypt/live/chenyun.org/ (compat path) │
│ │ │
│ ▼ │
│ Nginx SNI Proxy (all 7 virtual hosts) │
│ │
└─────────────────────────────────────────────────────────────────┘

1. Why DNS-01 Challenge

HTTP-01 challenges require port 80 accessibility and per-domain webroot configuration — problematic for a server behind FRP tunnels where traffic routing can be asymmetrical. TLS-ALPN-01 requires direct port 443 on the domain. Both break when your VPS reverse-proxies to a home server.

DNS-01 challenge solves this:

  • Wildcard support: *.chenyun.org covers all subdomains in one cert
  • No open ports: Validation happens entirely via DNS TXT records
  • Offline validation: Works even if the upstream VPS is restarting
  • No webroot conflicts: Every service can serve HTTPS without knowing about ACME

The cost: you need API access to your DNS provider. For AWS Route53, that means an IAM user with route53:ChangeResourceRecordSets and route53:GetChange permissions.

2. acme.sh Installation & Account Setup

acme.sh is a pure-shell ACME client — no dependencies beyond curl, openssl, and cron. Install as a non-root user:

1
curl https://get.acme.sh | sh

This creates ~/.acme.sh/ with the client binary, DNS API plugins, and account configuration. No sudo needed for issuance — only the deploy step requires root (for writing to /etc/ssl/).

The account configuration stores the default ACME server and AWS credentials:

1
2
3
4
~/.acme.sh/account.conf
├── DEFAULT_ACME_SERVER='https://acme-v02.api.letsencrypt.org/directory'
├── SAVED_AWS_ACCESS_KEY_ID='AKIA...'
└── SAVED_AWS_SECRET_ACCESS_KEY='...'

Security note: AWS credentials are stored in plaintext in account.conf. This file is only readable by the michael user. For production environments, consider using AWS IAM Instance Profiles or a secrets manager — but for a single-user N100, file permissions suffice.

3. Wildcard Certificate Issuance

Issue a wildcard ECC certificate (P-256 / secp256r1):

1
2
3
4
5
6
7
8
export AWS_ACCESS_KEY_ID='AKIA...'
export AWS_SECRET_ACCESS_KEY='...'
acme.sh --issue \
--dns dns_aws \
--domain chenyun.org \
--domain *.chenyun.org \
--keylength ec-256 \
--server letsencrypt

The dns_aws plugin:

  1. Creates a TXT record _acme-challenge.chenyun.org in Route53
  2. Polls until the record propagates (typically 10-60 seconds)
  3. Triggers Let’s Encrypt validation
  4. Removes the TXT record after validation

Result (stored in ~/.acme.sh/chenyun.org_ecc/):

1
2
3
4
5
6
7
8
chenyun.org_ecc/
├── chenyun.org.cer # leaf certificate
├── chenyun.org.key # private key (EC P-256, 227 bytes)
├── fullchain.cer # leaf + intermediate + root (2,865 bytes)
├── ca.cer # CA certificate
├── chenyun.org.conf # domain config + renewal metadata
├── chenyun.org.csr # certificate signing request
└── chenyun.org.csr.conf # CSR config

The domain config stores metadata including issue date, renewal deadlines, and API endpoints:

1
2
3
4
5
6
7
Le_Domain='chenyun.org'
Le_Alt='*.chenyun.org'
Le_Webroot='dns_aws'
Le_Keylength='ec-256'
Le_CertCreateTimeStr='2026-05-14T15:17:18Z'
Le_NextRenewTimeStr='2026-07-14T00:25:25Z'
Le_API='https://acme-v02.api.letsencrypt.org/directory'

4. Certificate Deployment

acme.sh stores certificates in ~/.acme.sh/<domain>_ecc/. For nginx to use them, they must be readable by the nginx worker process (usually www-data).

The HanyanOS deployment uses a two-layer strategy:

Layer 1: Copy certs to /etc/ssl/chenyun/ (root-owned, nginx-readable)

1
2
3
4
5
6
install -o root -g root -m 644 \
/home/michael/.acme.sh/chenyun.org_ecc/fullchain.cer \
/etc/ssl/chenyun/fullchain.cer
install -o root -g root -m 600 \
/home/michael/.acme.sh/chenyun.org_ecc/chenyun.org.key \
/etc/ssl/chenyun/chenyun.org.key

Layer 2: Certbot-style symlinks for compatibility

1
2
3
/etc/letsencrypt/live/chenyun.org/
├── fullchain.pem -> /etc/ssl/chenyun/fullchain.cer
└── privkey.pem -> /etc/ssl/chenyun/chenyun.org.key

Nginx configuration references the certbot-compatible path:

1
2
ssl_certificate     /etc/letsencrypt/live/chenyun.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chenyun.org/privkey.pem;

This design means you can swap the certificate source (acme.sh, certbot, manual CA) without touching nginx configuration — only the symlink target changes.

Important: Note the permission mismatch — fullchain.cer is 644 (world-readable), while chenyun.org.key is 600 (owner-only). The nginx worker must have CAP_DAC_OVERRIDE or be part of the ssl-cert group to read the key file.

5. The Second Certificate: hanyan.chenyun.org

A separate certificate exists for hanyan.chenyun.org — deployed to /tmp/ for specific services:

1
2
3
Le_Domain='hanyan.chenyun.org'
Le_RealCertPath='/tmp/hanyan-fullchain.pem'
Le_RealKeyPath='/tmp/hanyan-key.pem'

This is used for Agent-to-Agent TLS communication. The /tmp/ deploy path avoids permission issues when non-root processes (OpenClaw agents) need direct TLS access without reading /etc/ssl/.

6. Automation: Cron-Based Renewal

Let’s Encrypt certificates are valid for 90 days. acme.sh schedules renewal at 60 days (30 days before expiry):

1
56 20 * * * "/home/michael/.acme.sh"/acme.sh --cron --home "/home/michael/.acme.sh" > /dev/null

Runs daily at 20:56. acme.sh checks each certificate’s Le_NextRenewTimeStr against the current date and only renews if within the renewal window. Renewal is idempotent — re-running before expiry is a no-op.

The renewal flow:

  1. Renew via DNS challenge (same dns_aws plugin)
  2. Overwrite certificate files in ~/.acme.sh/<domain>_ecc/
  3. Does not auto-deploy — the deploy step must be triggered manually or via --renew-hook

Lesson learned: Initially, deployment was not part of the renewal hook. After the first automatic renewal, nginx was still serving the old certificate until the next manual deploy, causing a brief gap where browsers showed “Certificate Expires Soon” warnings. The fix: add a --renew-hook that copies to /etc/ssl/chenyun/ and reloads nginx:

1
2
3
4
5
6
acme.sh --issue \
--dns dns_aws \
--domain chenyun.org \
--domain *.chenyun.org \
--keylength ec-256 \
--renew-hook "cp ... /etc/ssl/chenyun/ && nginx -s reload"

7. Nginx TLS Configuration

Each virtual host in the SNI proxy shares a common TLS configuration:

1
2
3
4
5
6
7
8
9
ssl_certificate     /etc/letsencrypt/live/chenyun.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chenyun.org/privkey.pem;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;

The ECDSA P-256 key (output of --keylength ec-256) enables TLS 1.3 with perfect forward secrecy, smaller handshake payloads than RSA, and CPU-efficient key exchange — ideal for an N100’s modest resources.

8. TLS Certificate Validation

Verify the deployed certificate:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# View certificate details
openssl x509 -in /etc/letsencrypt/live/chenyun.org/fullchain.pem -text -noout

# Check SAN entries (should include *.chenyun.org)
openssl x509 -in /etc/letsencrypt/live/chenyun.org/fullchain.pem \
-text -noout | grep -A1 "Subject Alternative Name"

# Verify chain
openssl verify -untrusted /etc/letsencrypt/live/chenyun.org/fullchain.pem \
/etc/letsencrypt/live/chenyun.org/fullchain.pem

# Test TLS 1.3 handshake
echo | openssl s_client -connect photo.chenyun.org:443 -tls1_3 \
2>/dev/null | grep -E "Protocol|Cipher"

9. Security Considerations

Concern Mitigation
AWS key exposure account.conf is user-readable only; restrict IAM to route53:* on the hosted zone
Key theft Private key at 600 permissions; no remote backup
Certificate transparency Automatic — Let’s Encrypt logs all certs to CT logs
OCSP stapling Configure ssl_stapling on; in nginx for revocation checks
DNS propagation delays Route53 is near-instant; dns_aws plugin polls with 10s intervals

10. Lessons Learned

  1. Renewal hooks are non-negotiable: Without a --renew-hook, auto-renewal produces fresh certs that sit unused. Always include the deploy-and-reload step.

  2. Symlink indirection pays off: By pointing nginx at certbot-style paths and symlinking to the actual cert source, you can switch ACME clients, CAs, or deployment strategies without touching every virtual host config.

  3. ECC over RSA for embedded: On the N100’s Intel N100 (Alder Lake-N, 4 cores), ECDSA P-256 handshake completes ~3× faster than RSA 2048. The cert file is also ~400 bytes smaller — marginal, but every byte counts on constrained systems.

  4. Route53 rate limits: If you manage many domains, the dns_aws plugin’s default polling can hit Route53 API rate limits. Set SAVED_AWS_DNS_SLOWRATE=1 in account.conf to add jitter.

  5. Test with staging first: Always issue against --server https://acme-staging-v02.api.letsencrypt.org/directory first. A misconfigured DNS challenge can bump into Let’s Encrypt’s 5-failures-per-hour rate limit.

Conclusion

The acme.sh + Route53 DNS-01 pipeline provides fully automated, zero-touch certificate management for all seven HanyanOS domains. The wildcard ECC certificate covers every subdomain, renews unattended, and the symlink-based deployment decouples the certificate source from the web server configuration.

For a self-hosted infrastructure behind FRP tunnels, DNS-01 challenge is the only practical choice — and with Route53’s API, the setup is both reliable and maintainable with about 20 lines of configuration and a single cron entry.


Part of the HanyanOS 部署手记 series. Next: TBD.