Browse Source

feat: add geoblock role to drop outbound traffic to China IPs

Uses ipset + iptables to block OUTPUT to CN IP ranges from ipdeny.com.
Atomic ipset swap, systemd boot restore, daily cron refresh.
Applied to both relay and landing servers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
kotoyuuko 3 weeks ago
parent
commit
97025df341

+ 2 - 0
openspec/changes/archive/2026-04-22-block-cn-destinations/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-22

+ 43 - 0
openspec/changes/archive/2026-04-22-block-cn-destinations/design.md

@@ -0,0 +1,43 @@
+## Context
+
+Both servers (relay and landing) run as proxy endpoints. Clients are already configured to route China traffic directly via Surge rules. Any China-destined traffic that reaches the servers is unwanted. We need server-side enforcement to drop outbound connections to CN IP ranges.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Block outbound TCP/UDP to China IP ranges on both servers using ipset + iptables
+- Automate CN IP list download and periodic refresh
+- Idempotent Ansible role applicable to all hosts
+
+**Non-Goals:**
+- Blocking inbound from China (servers need to accept connections from Chinese clients)
+- DNS-level blocking
+- Application-layer filtering
+
+## Decisions
+
+### 1. ipset + iptables over raw iptables rules
+
+Use `ipset` to store the China CIDR list as a hash:net set, then a single iptables rule referencing the set. This is far more efficient than thousands of individual iptables rules.
+
+**Why over nftables**: iptables + ipset is universally available on Ubuntu/Debian and simpler to manage via Ansible. nftables sets would also work but add migration complexity.
+
+### 2. IP list source: ipdeny.com aggregated zones
+
+Use `https://www.ipdeny.com/ipblocks/data/aggregated/cn-aggregated.zone` for the China CIDR list. It's a widely-used, frequently updated, plain-text list of CIDR blocks.
+
+**Why over MaxMind GeoLite2**: No account/license required. Plain text format is trivial to load into ipset. Aggregated format minimizes the number of entries.
+
+### 3. Refresh via cron
+
+A daily cron job downloads the latest CN zone file and reloads the ipset. The update script is idempotent — it creates a temporary set, swaps it atomically, then destroys the old one.
+
+### 4. iptables rule placement
+
+The iptables rule is inserted in the OUTPUT chain to block traffic originating from the server itself (proxy daemon forwarding). A corresponding FORWARD chain rule is not needed since these servers don't act as network routers.
+
+## Risks / Trade-offs
+
+- **[IP list accuracy]** → ipdeny.com may lag behind real-time IP allocations. Mitigation: daily refresh keeps it reasonably current; exact accuracy is not critical for this use case.
+- **[Legitimate CN access blocked]** → If a server needs to reach a Chinese API (unlikely for proxy servers), it would be blocked. Mitigation: add specific IPs to an allowlist variable if needed.
+- **[ipset persistence across reboot]** → ipset sets are in-memory and lost on reboot. Mitigation: the update script runs at boot via a systemd service, and the cron job refreshes daily.

+ 25 - 0
openspec/changes/archive/2026-04-22-block-cn-destinations/proposal.md

@@ -0,0 +1,25 @@
+## Why
+
+Both proxy servers should refuse to forward traffic to China-destined IPs. Since clients already access Chinese services directly (per the china-direct-bypass Surge rules), any China-bound traffic reaching the servers is either misconfigured or unwanted. Blocking it server-side adds a defense-in-depth layer and prevents the servers from being used to access domestic Chinese services.
+
+## What Changes
+
+- Create a new Ansible role `geoblock` that downloads China IP CIDR lists and configures iptables to drop outbound traffic to those ranges
+- Apply the role to both relay and landing servers
+- Set up a cron job to periodically refresh the IP list
+
+## Capabilities
+
+### New Capabilities
+- `geoblock-cn`: Server-side blocking of outbound connections to China IP ranges via iptables
+
+### Modified Capabilities
+
+(none)
+
+## Impact
+
+- New `roles/geoblock/` Ansible role applied to all servers
+- iptables rules added on both servers blocking outbound to CN IP ranges
+- Cron job for periodic IP list updates
+- `site.yml` updated to include the geoblock role

+ 57 - 0
openspec/changes/archive/2026-04-22-block-cn-destinations/specs/geoblock-cn/spec.md

@@ -0,0 +1,57 @@
+## ADDED Requirements
+
+### Requirement: ipset and iptables are installed on all servers
+The geoblock role SHALL ensure `ipset` and `iptables` packages are installed.
+
+#### Scenario: Packages installed
+- **WHEN** the geoblock role runs
+- **THEN** `ipset` and `iptables` are installed and available
+
+### Requirement: China IP CIDR list is downloaded
+The geoblock role SHALL download the aggregated China CIDR list from ipdeny.com to a local file on each server.
+
+#### Scenario: Initial download
+- **WHEN** the geoblock role runs for the first time
+- **THEN** the CN aggregated zone file is downloaded to a configurable path (default: `/etc/geoblock/cn.zone`)
+
+### Requirement: ipset is populated with China CIDR ranges
+The geoblock role SHALL create an ipset named `cn-block` of type `hash:net` and populate it with all CIDRs from the downloaded zone file.
+
+#### Scenario: ipset created and loaded
+- **WHEN** the geoblock update script runs
+- **THEN** an ipset named `cn-block` exists containing all China CIDR entries
+- **THEN** the set is created atomically (build temp set, swap, destroy old)
+
+### Requirement: iptables blocks outbound to China IPs
+The geoblock role SHALL add an iptables OUTPUT chain rule that drops packets matching the `cn-block` ipset.
+
+#### Scenario: Outbound to China IP is dropped
+- **WHEN** the server attempts to send a packet to an IP in the `cn-block` ipset
+- **THEN** the packet is dropped by iptables
+
+#### Scenario: Outbound to non-China IP is allowed
+- **WHEN** the server attempts to send a packet to an IP NOT in the `cn-block` ipset
+- **THEN** the packet is allowed through
+
+### Requirement: CN IP list is refreshed daily via cron
+The geoblock role SHALL configure a cron job that re-downloads the CN zone file and reloads the ipset daily.
+
+#### Scenario: Daily refresh
+- **WHEN** the cron job fires
+- **THEN** the latest CN zone file is downloaded
+- **THEN** the ipset is atomically reloaded with updated data
+
+### Requirement: ipset is restored on boot
+The geoblock role SHALL configure a systemd service that runs at boot to restore the ipset and iptables rule, ensuring the block survives reboots.
+
+#### Scenario: Server reboots
+- **WHEN** the server restarts
+- **THEN** the geoblock systemd service loads the CN zone into ipset
+- **THEN** the iptables OUTPUT rule referencing `cn-block` is applied
+
+### Requirement: Geoblock role is applied to all servers
+The geoblock role SHALL be applied to both relay and landing servers via `site.yml`.
+
+#### Scenario: Both servers have geoblock
+- **WHEN** `site.yml` is run
+- **THEN** the geoblock role runs on hosts in both `relay` and `landing` groups

+ 11 - 0
openspec/changes/archive/2026-04-22-block-cn-destinations/tasks.md

@@ -0,0 +1,11 @@
+## 1. Geoblock Role
+
+- [x] 1.1 Create `roles/geoblock/defaults/main.yml` with default variables (zone URL, file paths, ipset name, cron schedule)
+- [x] 1.2 Create `roles/geoblock/templates/geoblock-update.sh.j2` — script to download CN zone, build temp ipset, swap atomically, add iptables rule if missing
+- [x] 1.3 Create `roles/geoblock/templates/geoblock.service.j2` — systemd oneshot unit that runs the update script at boot
+- [x] 1.4 Create `roles/geoblock/tasks/main.yml` — install ipset/iptables, deploy update script, run initial load, deploy systemd service, configure daily cron
+- [x] 1.5 Create `roles/geoblock/handlers/main.yml` — handler to reload geoblock if config changes
+
+## 2. Integration
+
+- [x] 2.1 Update `site.yml` to apply the geoblock role to all hosts (after base, before proxy roles)

+ 57 - 0
openspec/specs/geoblock-cn/spec.md

@@ -0,0 +1,57 @@
+## ADDED Requirements
+
+### Requirement: ipset and iptables are installed on all servers
+The geoblock role SHALL ensure `ipset` and `iptables` packages are installed.
+
+#### Scenario: Packages installed
+- **WHEN** the geoblock role runs
+- **THEN** `ipset` and `iptables` are installed and available
+
+### Requirement: China IP CIDR list is downloaded
+The geoblock role SHALL download the aggregated China CIDR list from ipdeny.com to a local file on each server.
+
+#### Scenario: Initial download
+- **WHEN** the geoblock role runs for the first time
+- **THEN** the CN aggregated zone file is downloaded to a configurable path (default: `/etc/geoblock/cn.zone`)
+
+### Requirement: ipset is populated with China CIDR ranges
+The geoblock role SHALL create an ipset named `cn-block` of type `hash:net` and populate it with all CIDRs from the downloaded zone file.
+
+#### Scenario: ipset created and loaded
+- **WHEN** the geoblock update script runs
+- **THEN** an ipset named `cn-block` exists containing all China CIDR entries
+- **THEN** the set is created atomically (build temp set, swap, destroy old)
+
+### Requirement: iptables blocks outbound to China IPs
+The geoblock role SHALL add an iptables OUTPUT chain rule that drops packets matching the `cn-block` ipset.
+
+#### Scenario: Outbound to China IP is dropped
+- **WHEN** the server attempts to send a packet to an IP in the `cn-block` ipset
+- **THEN** the packet is dropped by iptables
+
+#### Scenario: Outbound to non-China IP is allowed
+- **WHEN** the server attempts to send a packet to an IP NOT in the `cn-block` ipset
+- **THEN** the packet is allowed through
+
+### Requirement: CN IP list is refreshed daily via cron
+The geoblock role SHALL configure a cron job that re-downloads the CN zone file and reloads the ipset daily.
+
+#### Scenario: Daily refresh
+- **WHEN** the cron job fires
+- **THEN** the latest CN zone file is downloaded
+- **THEN** the ipset is atomically reloaded with updated data
+
+### Requirement: ipset is restored on boot
+The geoblock role SHALL configure a systemd service that runs at boot to restore the ipset and iptables rule, ensuring the block survives reboots.
+
+#### Scenario: Server reboots
+- **WHEN** the server restarts
+- **THEN** the geoblock systemd service loads the CN zone into ipset
+- **THEN** the iptables OUTPUT rule referencing `cn-block` is applied
+
+### Requirement: Geoblock role is applied to all servers
+The geoblock role SHALL be applied to both relay and landing servers via `site.yml`.
+
+#### Scenario: Both servers have geoblock
+- **WHEN** `site.yml` is run
+- **THEN** the geoblock role runs on hosts in both `relay` and `landing` groups

+ 7 - 0
roles/geoblock/defaults/main.yml

@@ -0,0 +1,7 @@
+---
+geoblock_zone_url: "https://www.ipdeny.com/ipblocks/data/aggregated/cn-aggregated.zone"
+geoblock_zone_path: /etc/geoblock/cn.zone
+geoblock_script_path: /usr/local/bin/geoblock-update.sh
+geoblock_ipset_name: cn-block
+geoblock_cron_hour: "3"
+geoblock_cron_minute: "17"

+ 4 - 0
roles/geoblock/handlers/main.yml

@@ -0,0 +1,4 @@
+---
+- name: reload geoblock
+  ansible.builtin.command:
+    cmd: "{{ geoblock_script_path }}"

+ 51 - 0
roles/geoblock/tasks/main.yml

@@ -0,0 +1,51 @@
+---
+- name: Install ipset and iptables
+  ansible.builtin.apt:
+    name:
+      - ipset
+      - iptables
+    state: present
+
+- name: Create geoblock config directory
+  ansible.builtin.file:
+    path: "{{ geoblock_zone_path | dirname }}"
+    state: directory
+    owner: root
+    group: root
+    mode: "0755"
+
+- name: Deploy geoblock update script
+  ansible.builtin.template:
+    src: geoblock-update.sh.j2
+    dest: "{{ geoblock_script_path }}"
+    owner: root
+    group: root
+    mode: "0755"
+  notify: reload geoblock
+
+- name: Deploy geoblock systemd service
+  ansible.builtin.template:
+    src: geoblock.service.j2
+    dest: /etc/systemd/system/geoblock.service
+    owner: root
+    group: root
+    mode: "0644"
+
+- name: Run initial geoblock load
+  ansible.builtin.command:
+    cmd: "{{ geoblock_script_path }}"
+    creates: "{{ geoblock_zone_path }}"
+
+- name: Enable geoblock service for boot
+  ansible.builtin.systemd:
+    name: geoblock
+    daemon_reload: yes
+    enabled: yes
+
+- name: Configure daily cron for geoblock refresh
+  ansible.builtin.cron:
+    name: "geoblock-refresh"
+    hour: "{{ geoblock_cron_hour }}"
+    minute: "{{ geoblock_cron_minute }}"
+    job: "{{ geoblock_script_path }}"
+    user: root

+ 27 - 0
roles/geoblock/templates/geoblock-update.sh.j2

@@ -0,0 +1,27 @@
+#!/bin/bash
+set -euo pipefail
+
+ZONE_URL="{{ geoblock_zone_url }}"
+ZONE_FILE="{{ geoblock_zone_path }}"
+IPSET_NAME="{{ geoblock_ipset_name }}"
+IPSET_TMP="${IPSET_NAME}-tmp"
+
+mkdir -p "$(dirname "$ZONE_FILE")"
+
+curl -fsSL -o "$ZONE_FILE" "$ZONE_URL"
+
+ipset create "$IPSET_TMP" hash:net -exist
+ipset flush "$IPSET_TMP"
+
+while IFS= read -r cidr; do
+    [[ -z "$cidr" || "$cidr" == \#* ]] && continue
+    ipset add "$IPSET_TMP" "$cidr" -exist
+done < "$ZONE_FILE"
+
+ipset create "$IPSET_NAME" hash:net -exist
+ipset swap "$IPSET_TMP" "$IPSET_NAME"
+ipset destroy "$IPSET_TMP"
+
+if ! iptables -C OUTPUT -m set --match-set "$IPSET_NAME" dst -j DROP 2>/dev/null; then
+    iptables -A OUTPUT -m set --match-set "$IPSET_NAME" dst -j DROP
+fi

+ 12 - 0
roles/geoblock/templates/geoblock.service.j2

@@ -0,0 +1,12 @@
+[Unit]
+Description=Load China IP geoblock rules
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart={{ geoblock_script_path }}
+RemainAfterExit=yes
+
+[Install]
+WantedBy=multi-user.target

+ 1 - 0
site.yml

@@ -3,6 +3,7 @@
   hosts: all
   roles:
     - base
+    - geoblock
 
 - name: Deploy Shadowsocks on relay server
   hosts: relay