Compare commits
6
Commits
0cf834f8fe
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dedcf6101 | ||
|
|
2658d7403d | ||
|
|
d1ea7aaff2 | ||
|
|
c14be296a8 | ||
|
|
b834cd06f3 | ||
|
|
22ffe2f617 |
No files matched your search
@@ -32,6 +32,10 @@ RADIUS_SECRET=
|
|||||||
# Applied on FIRST start only; change later via the console itself.
|
# Applied on FIRST start only; change later via the console itself.
|
||||||
TECHNITIUM_ADMIN_PASSWORD=
|
TECHNITIUM_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# ── DNS zone for VPN client registration (A + PTR via learn-address) ──
|
||||||
|
# Leave empty to disable client DNS registration entirely.
|
||||||
|
VPN_DNS_ZONE=roam.atlashorizon.net
|
||||||
|
|
||||||
# ── Optional (defaults shown) ─────────────────────────────────────────
|
# ── Optional (defaults shown) ─────────────────────────────────────────
|
||||||
# Duo Auth Proxy version; pin (e.g. 6.5.1) for deterministic updates
|
# Duo Auth Proxy version; pin (e.g. 6.5.1) for deterministic updates
|
||||||
#DUO_AUTHPROXY_VERSION=latest
|
#DUO_AUTHPROXY_VERSION=latest
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Oswald↔Trochalakis tunnel — this box never carries it.
|
|||||||
| Oswald LAN | `192.168.7.0/24` | AH-OSWLDC |
|
| Oswald LAN | `192.168.7.0/24` | AH-OSWLDC |
|
||||||
| WG transfer | `172.31.255.0/24` | hub .1, Troch .2, Oswald .3 |
|
| WG transfer | `172.31.255.0/24` | hub .1, Troch .2, Oswald .3 |
|
||||||
| OpenVPN clients | `172.31.8.0/24` | routed (not NATed) to both sites |
|
| OpenVPN clients | `172.31.8.0/24` | routed (not NATed) to both sites |
|
||||||
|
| WG travel routers | `172.31.9.0/24` | GL.iNet roaming peers, hub .1 ([docs](docs/TRAVEL-ROUTERS.md)) |
|
||||||
|
|
||||||
Public endpoint: `vpn.atlashorizon.net` — only 22/tcp (SSH), 51820/udp
|
Public endpoint: `vpn.atlashorizon.net` — only 22/tcp (SSH), 51820/udp
|
||||||
(WireGuard) and 1194/udp (OpenVPN) are open. RADIUS binds loopback only;
|
(WireGuard) and 1194/udp (OpenVPN) are open. RADIUS binds loopback only;
|
||||||
|
|||||||
+11
-4
@@ -3,9 +3,10 @@
|
|||||||
# later run uses — key-only auth, passwordless sudo — verify it actually
|
# later run uses — key-only auth, passwordless sudo — verify it actually
|
||||||
# works, then lock sshd down to keys. Run once per fresh VPS:
|
# works, then lock sshd down to keys. Run once per fresh VPS:
|
||||||
#
|
#
|
||||||
# ansible-playbook bootstrap.yml # root already has your key
|
# ansible-playbook bootstrap.yml
|
||||||
# ansible-playbook bootstrap.yml -k # root still uses a password
|
#
|
||||||
# # (needs sshpass installed)
|
# It prompts for the root SSH password (needs sshpass on the control
|
||||||
|
# node). If root already authenticates with your key, just press Enter.
|
||||||
#
|
#
|
||||||
# Afterwards set `ansible_user: ansible` in inventory.yml and never
|
# Afterwards set `ansible_user: ansible` in inventory.yml and never
|
||||||
# connect as root again. Overridable with -e:
|
# connect as root again. Overridable with -e:
|
||||||
@@ -22,10 +23,16 @@
|
|||||||
- name: Create the automation user (connects as root)
|
- name: Create the automation user (connects as root)
|
||||||
hosts: vpnhub
|
hosts: vpnhub
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
|
vars_prompt:
|
||||||
|
- name: bootstrap_root_password
|
||||||
|
prompt: "Root SSH password (Enter if root already uses your key)"
|
||||||
|
private: true
|
||||||
|
default: ""
|
||||||
vars:
|
vars:
|
||||||
ansible_user: root # play var — outranks inventory on purpose
|
ansible_user: root # play var — outranks inventory on purpose
|
||||||
|
ansible_password: "{{ bootstrap_root_password }}"
|
||||||
bootstrap_user: ansible
|
bootstrap_user: ansible
|
||||||
bootstrap_ssh_public_key_file: "~/.ssh/id_ed25519.pub"
|
bootstrap_ssh_public_key_file: "~/.ssh/togo-ansible.pub"
|
||||||
tasks:
|
tasks:
|
||||||
- name: Create the user (no password hash set, account password locked)
|
- name: Create the user (no password hash set, account password locked)
|
||||||
ansible.builtin.user:
|
ansible.builtin.user:
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ radius_secret: ""
|
|||||||
# console (this var must still match for the init job's API login).
|
# console (this var must still match for the init job's API login).
|
||||||
technitium_admin_password: ""
|
technitium_admin_password: ""
|
||||||
|
|
||||||
|
# DNS zone where VPN clients get registered as <username>.<zone> with a
|
||||||
|
# matching PTR (Technitium primary zone, created by the init job). Set to
|
||||||
|
# "" to disable registration.
|
||||||
|
vpn_dns_zone: roam.atlashorizon.net
|
||||||
|
|
||||||
|
# ── Travel routers (GL.iNet etc.) as roaming WireGuard peers ──
|
||||||
|
# Each gets a /32 from 172.31.9.0/24 (hub is 172.31.9.1). Generate the
|
||||||
|
# keypair on the router (or wg genkey) and paste the PUBLIC key here.
|
||||||
|
# The init job also registers <name>.<vpn_dns_zone> A + PTR records.
|
||||||
|
wg_travel_peers: []
|
||||||
|
#wg_travel_peers:
|
||||||
|
# - { name: glinet-1, public_key: "xxxx=", ip: 172.31.9.11 }
|
||||||
|
# - { name: glinet-2, public_key: "yyyy=", ip: 172.31.9.12 }
|
||||||
|
# - { name: glinet-3, public_key: "zzzz=", ip: 172.31.9.13 }
|
||||||
|
|
||||||
# ── Optional ──
|
# ── Optional ──
|
||||||
# Pin for deterministic Duo updates (e.g. "6.5.1"); bumping it forces a
|
# Pin for deterministic Duo updates (e.g. "6.5.1"); bumping it forces a
|
||||||
# refetch of the tarball on the next rebuild.
|
# refetch of the tarball on the next rebuild.
|
||||||
|
|||||||
+52
-14
@@ -23,6 +23,7 @@
|
|||||||
vpnhub_base_dir: /opt
|
vpnhub_base_dir: /opt
|
||||||
vpnhub_stack_dir: "{{ vpnhub_base_dir }}/vpnhub"
|
vpnhub_stack_dir: "{{ vpnhub_base_dir }}/vpnhub"
|
||||||
vpnhub_force_rebuild: false
|
vpnhub_force_rebuild: false
|
||||||
|
vpnhub_host_dns: [1.1.1.1, 9.9.9.9]
|
||||||
public_iface: "{{ ansible_default_ipv4.interface }}"
|
public_iface: "{{ ansible_default_ipv4.interface }}"
|
||||||
wg_peers_configured: >-
|
wg_peers_configured: >-
|
||||||
{{ (troch_wg_public_key | default('')) != ''
|
{{ (troch_wg_public_key | default('')) != ''
|
||||||
@@ -30,9 +31,9 @@
|
|||||||
|
|
||||||
handlers:
|
handlers:
|
||||||
- name: reload firewall
|
- name: reload firewall
|
||||||
|
# the reload flushes the Docker-managed chains, so restart docker after
|
||||||
ansible.builtin.shell: |
|
ansible.builtin.shell: |
|
||||||
netfilter-persistent reload
|
netfilter-persistent reload
|
||||||
# the reload flushes Docker's chains — put them back
|
|
||||||
if systemctl is-active --quiet docker; then systemctl restart docker; fi
|
if systemctl is-active --quiet docker; then systemctl restart docker; fi
|
||||||
|
|
||||||
- name: restart wireguard
|
- name: restart wireguard
|
||||||
@@ -50,17 +51,18 @@
|
|||||||
chdir: "{{ vpnhub_stack_dir }}"
|
chdir: "{{ vpnhub_stack_dir }}"
|
||||||
|
|
||||||
- name: recreate duo-authproxy
|
- name: recreate duo-authproxy
|
||||||
ansible.builtin.command: docker compose up -d --force-recreate duo-authproxy
|
# --build: entrypoint/cfg-template changes are baked into the image
|
||||||
|
ansible.builtin.command: docker compose up -d --build --force-recreate duo-authproxy
|
||||||
args:
|
args:
|
||||||
chdir: "{{ vpnhub_stack_dir }}"
|
chdir: "{{ vpnhub_stack_dir }}"
|
||||||
|
|
||||||
- name: recreate openvpn
|
- name: recreate openvpn
|
||||||
ansible.builtin.command: docker compose up -d --force-recreate openvpn
|
ansible.builtin.command: docker compose up -d --build --force-recreate openvpn
|
||||||
args:
|
args:
|
||||||
chdir: "{{ vpnhub_stack_dir }}"
|
chdir: "{{ vpnhub_stack_dir }}"
|
||||||
|
|
||||||
- name: recreate stack
|
- name: recreate stack
|
||||||
ansible.builtin.command: docker compose up -d --force-recreate
|
ansible.builtin.command: docker compose up -d --build --force-recreate
|
||||||
args:
|
args:
|
||||||
chdir: "{{ vpnhub_stack_dir }}"
|
chdir: "{{ vpnhub_stack_dir }}"
|
||||||
|
|
||||||
@@ -76,9 +78,12 @@
|
|||||||
update_cache: true
|
update_cache: true
|
||||||
cache_valid_time: 3600
|
cache_valid_time: 3600
|
||||||
|
|
||||||
|
# Distro-agnostic: ansible_distribution is Ubuntu or Debian, and
|
||||||
|
# Docker publishes separate repos per distro (a Debian codename
|
||||||
|
# like trixie does not exist under linux/ubuntu and vice versa).
|
||||||
- name: Docker apt signing key
|
- name: Docker apt signing key
|
||||||
ansible.builtin.get_url:
|
ansible.builtin.get_url:
|
||||||
url: https://download.docker.com/linux/ubuntu/gpg
|
url: "https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg"
|
||||||
dest: /etc/apt/keyrings/docker.asc
|
dest: /etc/apt/keyrings/docker.asc
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
|
|
||||||
@@ -87,7 +92,7 @@
|
|||||||
repo: >-
|
repo: >-
|
||||||
deb [arch={{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}
|
deb [arch={{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}
|
||||||
signed-by=/etc/apt/keyrings/docker.asc]
|
signed-by=/etc/apt/keyrings/docker.asc]
|
||||||
https://download.docker.com/linux/ubuntu
|
https://download.docker.com/linux/{{ ansible_distribution | lower }}
|
||||||
{{ ansible_distribution_release }} stable
|
{{ ansible_distribution_release }} stable
|
||||||
filename: docker
|
filename: docker
|
||||||
|
|
||||||
@@ -124,10 +129,19 @@
|
|||||||
sysctl_file: /etc/sysctl.d/99-vpnhub.conf
|
sysctl_file: /etc/sysctl.d/99-vpnhub.conf
|
||||||
reload: true
|
reload: true
|
||||||
|
|
||||||
# Technitium binds 0.0.0.0:53 — systemd-resolved's stub listener on
|
# Technitium binds 0.0.0.0:53 — free the port while keeping host DNS
|
||||||
# 127.0.0.53:53 would collide with it.
|
# working. Ubuntu runs systemd-resolved (stub on 127.0.0.53:53 must be
|
||||||
|
# disabled); plain Debian has no resolved and just needs a sane static
|
||||||
|
# /etc/resolv.conf.
|
||||||
|
- name: Check whether systemd-resolved is in use
|
||||||
|
tags: host
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /run/systemd/resolve/resolv.conf
|
||||||
|
register: resolved_upstream
|
||||||
|
|
||||||
- name: resolved drop-in directory
|
- name: resolved drop-in directory
|
||||||
tags: host
|
tags: host
|
||||||
|
when: resolved_upstream.stat.exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: /etc/systemd/resolved.conf.d
|
path: /etc/systemd/resolved.conf.d
|
||||||
state: directory
|
state: directory
|
||||||
@@ -135,6 +149,7 @@
|
|||||||
|
|
||||||
- name: Disable systemd-resolved stub listener (frees :53 for Technitium)
|
- name: Disable systemd-resolved stub listener (frees :53 for Technitium)
|
||||||
tags: host
|
tags: host
|
||||||
|
when: resolved_upstream.stat.exists
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: /etc/systemd/resolved.conf.d/99-vpnhub.conf
|
dest: /etc/systemd/resolved.conf.d/99-vpnhub.conf
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
@@ -143,14 +158,29 @@
|
|||||||
DNSStubListener=no
|
DNSStubListener=no
|
||||||
notify: restart systemd-resolved
|
notify: restart systemd-resolved
|
||||||
|
|
||||||
- name: Point /etc/resolv.conf at resolved's upstream file (not the dead stub)
|
- name: Point /etc/resolv.conf at resolved upstream file (not the dead stub)
|
||||||
tags: host
|
tags: host
|
||||||
|
when: resolved_upstream.stat.exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
src: /run/systemd/resolve/resolv.conf
|
src: /run/systemd/resolve/resolv.conf
|
||||||
dest: /etc/resolv.conf
|
dest: /etc/resolv.conf
|
||||||
state: link
|
state: link
|
||||||
force: true
|
force: true
|
||||||
|
|
||||||
|
- name: Static resolv.conf (no systemd-resolved on this host)
|
||||||
|
tags: host
|
||||||
|
when: not resolved_upstream.stat.exists
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/resolv.conf
|
||||||
|
follow: false # replace a dangling symlink rather than write through it
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
# Managed by Ansible (vpnhub). Public resolvers on purpose: the
|
||||||
|
# host must resolve registries/repos even when the stack is down.
|
||||||
|
{% for ns in vpnhub_host_dns %}
|
||||||
|
nameserver {{ ns }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
- name: Firewall rules (rendered from host/rules.v4/v6.template)
|
- name: Firewall rules (rendered from host/rules.v4/v6.template)
|
||||||
tags: [host, firewall]
|
tags: [host, firewall]
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
@@ -189,15 +219,23 @@
|
|||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: "RackNerd WG public key: {{ wg_pubkey.stdout }}"
|
msg: "RackNerd WG public key: {{ wg_pubkey.stdout }}"
|
||||||
|
|
||||||
- name: Deploy wg0.conf (rendered from host/wg0.conf.example)
|
- name: Deploy wg0.conf (rendered from host/wg0.conf.example + travel peers)
|
||||||
tags: [host, wireguard]
|
tags: [host, wireguard]
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: /etc/wireguard/wg0.conf
|
dest: /etc/wireguard/wg0.conf
|
||||||
mode: "0600"
|
mode: "0600"
|
||||||
content: "{{ lookup('file', playbook_dir ~ '/../host/wg0.conf.example')
|
content: |
|
||||||
| replace('<RACKNERD_WG_PRIVATE_KEY>', wg_key_raw.content | b64decode | trim)
|
{{ lookup('file', playbook_dir ~ '/../host/wg0.conf.example')
|
||||||
| replace('<TROCHALAKIS_WG_PUBLIC_KEY>', troch_wg_public_key | default('<TROCHALAKIS_WG_PUBLIC_KEY>', true))
|
| replace('<RACKNERD_WG_PRIVATE_KEY>', wg_key_raw.content | b64decode | trim)
|
||||||
| replace('<OSWALD_WG_PUBLIC_KEY>', oswald_wg_public_key | default('<OSWALD_WG_PUBLIC_KEY>', true)) }}\n"
|
| replace('<TROCHALAKIS_WG_PUBLIC_KEY>', troch_wg_public_key | default('<TROCHALAKIS_WG_PUBLIC_KEY>', true))
|
||||||
|
| replace('<OSWALD_WG_PUBLIC_KEY>', oswald_wg_public_key | default('<OSWALD_WG_PUBLIC_KEY>', true)) }}
|
||||||
|
{% for p in wg_travel_peers | default([]) %}
|
||||||
|
|
||||||
|
# ── Travel router: {{ p.name }} (roaming /32, appended by Ansible) ──
|
||||||
|
[Peer]
|
||||||
|
PublicKey = {{ p.public_key }}
|
||||||
|
AllowedIPs = {{ p.ip }}/32
|
||||||
|
{% endfor %}
|
||||||
no_log: true
|
no_log: true
|
||||||
notify: restart wireguard
|
notify: restart wireguard
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ RADIUS_SECRET={{ radius_secret }}
|
|||||||
|
|
||||||
TECHNITIUM_ADMIN_PASSWORD={{ technitium_admin_password }}
|
TECHNITIUM_ADMIN_PASSWORD={{ technitium_admin_password }}
|
||||||
|
|
||||||
|
VPN_DNS_ZONE={{ vpn_dns_zone | default('roam.atlashorizon.net') }}
|
||||||
|
VPN_TRAVEL_PEERS={% for p in wg_travel_peers | default([]) %}{{ p.name }}:{{ p.ip }}{{ "," if not loop.last else "" }}{% endfor %}
|
||||||
|
|
||||||
DUO_AUTHPROXY_VERSION={{ duo_authproxy_version | default('latest') }}
|
DUO_AUTHPROXY_VERSION={{ duo_authproxy_version | default('latest') }}
|
||||||
|
|
||||||
# /opt layout — consumed by compose ${*_DIR} interpolation
|
# /opt layout — consumed by compose ${*_DIR} interpolation
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
client
|
||||||
|
dev tun
|
||||||
|
proto udp
|
||||||
|
remote connect.atlashorizon.net 1194
|
||||||
|
resolv-retry infinite
|
||||||
|
nobind
|
||||||
|
persist-key
|
||||||
|
persist-tun
|
||||||
|
dhcp-option DNS 172.31.8.1
|
||||||
|
|
||||||
|
remote-cert-tls server
|
||||||
|
tls-version-min 1.2
|
||||||
|
data-ciphers AES-256-GCM:CHACHA20-POLY1305
|
||||||
|
auth SHA256
|
||||||
|
|
||||||
|
auth-user-pass
|
||||||
|
auth-retry interact
|
||||||
|
hand-window 120
|
||||||
|
verb 3
|
||||||
|
|
||||||
|
<ca>
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDTjCCAjagAwIBAgIUFUdfgwj+K1Tt/fHBAvkfbCzjVhkwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwFjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMjYwNzE2MDQyNTQyWhcNMzYw
|
||||||
|
NzEzMDQyNTQyWjAWMRQwEgYDVQQDDAtFYXN5LVJTQSBDQTCCASIwDQYJKoZIhvcN
|
||||||
|
AQEBBQADggEPADCCAQoCggEBAKFZW5dKKR0/l6iPab513g8mCD+h8Kg8VmFY+PYS
|
||||||
|
jURdeb1hhTtVYuRir8jVKBmX1ck/KSATomzozhA97CpAkrZSrruHGwtJLAZDr+oQ
|
||||||
|
Fj7bzH2T94Pk35IbwI1HgIg9ZG18yONo9Lv6ODECZpVLQHzZpsBPWbWTSLiGYylp
|
||||||
|
1EiUo9PW7Dwx5xl/X1/oFjs+2wzv2F9GZZNNqQfDWupq18KwobUzpUAgNXbB1ZiV
|
||||||
|
AU//XWJg/s4fSdSsWY5ih+PoJawQBpdTxd4DWwC/tK3NDia/Rr5oeL6RctlO+Tps
|
||||||
|
BGjOtP5OHdqnuEC0QbuJcc9b8wuBdQzrWV5weCUOjMD6F6UCAwEAAaOBkzCBkDAP
|
||||||
|
BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSEH5TGxRg/19B0j1Gih0XUWTJW3DBR
|
||||||
|
BgNVHSMESjBIgBSEH5TGxRg/19B0j1Gih0XUWTJW3KEapBgwFjEUMBIGA1UEAwwL
|
||||||
|
RWFzeS1SU0EgQ0GCFBVHX4MI/itU7f3xwQL5H2ws41YZMAsGA1UdDwQEAwIBBjAN
|
||||||
|
BgkqhkiG9w0BAQsFAAOCAQEAoI0jRxiKlNPbxSguWqW3Lr5t/a20Ntck5YY/6I3z
|
||||||
|
CR97hRJhLHOfrni4EOKASpEW/afFezH/yuQyYwWxjzkJYUnKA1SQxQYGtaQbV+yu
|
||||||
|
0n6uWwvhBwZTsESe0SrWLgw1IhKf609cRt6F/DdaX7oujSgJCQT/yzNjmCHHts87
|
||||||
|
4qEN/fM3yE5peacO3g1zc0wjhdlG4sp8GJcpXFnCDcJsfNbgj9JYZTHulL9dhxmh
|
||||||
|
NZKMUGK3pXIQq0IU12GDh9gKCFUpe3ILfQ8gJCm/u1EYRSoC27ZDmtD+Pgsjwr0E
|
||||||
|
uLKM4f2c43Kq2XLdldreuRHMPLOZ0XQDo3lOdfVRp8Rcqw==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
</ca>
|
||||||
|
<tls-crypt>
|
||||||
|
-----BEGIN OpenVPN Static key V1-----
|
||||||
|
5fedf4b825a32eb88722a14f801ef416
|
||||||
|
2d015c4b5c41654c556108fc2fa5006e
|
||||||
|
f83c97ea210113935dbaf47469ba9cd4
|
||||||
|
752f928caf48926b47b6da2b4573f6fa
|
||||||
|
ed909502d027ba02e509dc48c6542d74
|
||||||
|
53c3013ce00e443b43faac4f8f87717f
|
||||||
|
1fe942bd1f1412fb38566a88ba7e0d5a
|
||||||
|
b722ea79cb4a1b5e172185d2644db81b
|
||||||
|
33fdc631815e47f6ae5ce099beee5c37
|
||||||
|
b0880c7ff8f9c4ff7a8302e7eb692ce9
|
||||||
|
eab50f743de8aa91bff266b2dc4f504c
|
||||||
|
775137ae64d71c79e35a40e8bedb8f67
|
||||||
|
65bfa02d05bfc2a5e8824eff9be34a26
|
||||||
|
a8c8b151a215ae26c67c006e431c8a57
|
||||||
|
8985d2feec6787b629b2e30dfdc5ffea
|
||||||
|
a0d26b0b0f1aca83ccbfc77b660cceed
|
||||||
|
-----END OpenVPN Static key V1-----
|
||||||
|
</tls-crypt>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# AtlasHorizon VPN — client profile template.
|
# AtlasHorizon VPN — client profile template.
|
||||||
# Per user: paste ca.crt, the user's cert/key, and ta.key (from the offline
|
# ONE profile for everyone: paste ca.crt and ta.key (from the offline CA,
|
||||||
# CA, see openvpn/pki/README.md) into the inline blocks below, save as
|
# see openvpn/pki/README.md) into the inline blocks below and distribute.
|
||||||
# <username>.ovpn, import into OpenVPN Connect.
|
# No per-user certificates — identity is AD user/pass + Duo.
|
||||||
#
|
#
|
||||||
# Login: AD username; password field takes either
|
# Login: AD username; password field takes either
|
||||||
# ADpassword -> Duo push
|
# ADpassword -> Duo push
|
||||||
@@ -33,12 +33,6 @@ verb 3
|
|||||||
<ca>
|
<ca>
|
||||||
# ----- paste ca.crt -----
|
# ----- paste ca.crt -----
|
||||||
</ca>
|
</ca>
|
||||||
<cert>
|
|
||||||
# ----- paste <username>.crt -----
|
|
||||||
</cert>
|
|
||||||
<key>
|
|
||||||
# ----- paste <username>.key -----
|
|
||||||
</key>
|
|
||||||
<tls-crypt>
|
<tls-crypt>
|
||||||
# ----- paste ta.key -----
|
# ----- paste ta.key -----
|
||||||
</tls-crypt>
|
</tls-crypt>
|
||||||
+4
-1
@@ -29,7 +29,9 @@ services:
|
|||||||
- DNS_SERVER_ADMIN_PASSWORD=${TECHNITIUM_ADMIN_PASSWORD}
|
- DNS_SERVER_ADMIN_PASSWORD=${TECHNITIUM_ADMIN_PASSWORD}
|
||||||
- DNS_SERVER_WEB_SERVICE_HTTP_PORT=5380
|
- DNS_SERVER_WEB_SERVICE_HTTP_PORT=5380
|
||||||
- DNS_SERVER_RECURSION=UseSpecifiedNetworkACL
|
- DNS_SERVER_RECURSION=UseSpecifiedNetworkACL
|
||||||
- DNS_SERVER_RECURSION_NETWORK_ACL=127.0.0.0/8,172.31.8.0/24,172.31.255.0/24,192.168.20.0/24,192.168.7.0/24
|
# NOTE: seeded on first start only — on an existing deploy, add new
|
||||||
|
# networks in the console instead (Settings -> Recursion).
|
||||||
|
- DNS_SERVER_RECURSION_NETWORK_ACL=127.0.0.0/8,172.31.8.0/24,172.31.9.0/24,172.31.255.0/24,192.168.20.0/24,192.168.7.0/24
|
||||||
volumes:
|
volumes:
|
||||||
- ${TECHNITIUM_DIR:-./technitium}/config:/etc/dns
|
- ${TECHNITIUM_DIR:-./technitium}/config:/etc/dns
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -69,6 +71,7 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ${OPENVPN_DIR:-./openvpn}/server.conf:/etc/openvpn/server.conf:ro
|
- ${OPENVPN_DIR:-./openvpn}/server.conf:/etc/openvpn/server.conf:ro
|
||||||
|
- ${OPENVPN_DIR:-./openvpn}/learn-address.sh:/etc/openvpn/learn-address.sh:ro
|
||||||
- ${OPENVPN_DIR:-./openvpn}/pki:/etc/openvpn/pki:ro
|
- ${OPENVPN_DIR:-./openvpn}/pki:/etc/openvpn/pki:ro
|
||||||
- openvpn-state:/var/lib/openvpn
|
- openvpn-state:/var/lib/openvpn
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
+7
-4
@@ -13,7 +13,8 @@ Add the RackNerd peer to each site's existing WG config. The sites
|
|||||||
[Peer] # RackNerd hub
|
[Peer] # RackNerd hub
|
||||||
PublicKey = <RACKNERD_WG_PUBLIC_KEY> # printed by host/setup.sh
|
PublicKey = <RACKNERD_WG_PUBLIC_KEY> # printed by host/setup.sh
|
||||||
Endpoint = vpn.atlashorizon.net:51820
|
Endpoint = vpn.atlashorizon.net:51820
|
||||||
AllowedIPs = 172.31.255.1/32, 172.31.8.0/24
|
# .8 = OpenVPN clients, .9 = travel routers (docs/TRAVEL-ROUTERS.md)
|
||||||
|
AllowedIPs = 172.31.255.1/32, 172.31.8.0/24, 172.31.9.0/24
|
||||||
PersistentKeepalive = 25
|
PersistentKeepalive = 25
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ LAN router (or directly on the DCs):
|
|||||||
| Destination | Via |
|
| Destination | Via |
|
||||||
|------------------|------------------------|
|
|------------------|------------------------|
|
||||||
| `172.31.8.0/24` | site WireGuard box |
|
| `172.31.8.0/24` | site WireGuard box |
|
||||||
|
| `172.31.9.0/24` | site WireGuard box |
|
||||||
| `172.31.255.1/32`| site WireGuard box |
|
| `172.31.255.1/32`| site WireGuard box |
|
||||||
|
|
||||||
Verify no asymmetry: `tracert 172.31.8.10` from a DC must leave via the
|
Verify no asymmetry: `tracert 172.31.8.10` from a DC must leave via the
|
||||||
@@ -42,9 +44,10 @@ local WG box → RackNerd, never via the other site.
|
|||||||
|
|
||||||
## 3. Windows Firewall / segmentation on the DCs
|
## 3. Windows Firewall / segmentation on the DCs
|
||||||
|
|
||||||
Allow from **`172.31.8.0/24`** (roaming clients, real source IPs): standard
|
Allow from **`172.31.8.0/24`** (OpenVPN clients, real source IPs) and
|
||||||
AD client ports — DNS 53, Kerberos 88/464, LDAP 389/636, SMB 445, RPC 135 +
|
**`172.31.9.0/24`** (travel routers): standard AD client ports — DNS 53,
|
||||||
49152-65535, NTP 123, GC 3268/3269.
|
Kerberos 88/464, LDAP 389/636, SMB 445, RPC 135 + 49152-65535, NTP 123,
|
||||||
|
GC 3268/3269.
|
||||||
|
|
||||||
Allow from **`172.31.255.1/32`** (the RackNerd host itself):
|
Allow from **`172.31.255.1/32`** (the RackNerd host itself):
|
||||||
- TCP/UDP 53 → AHDC01, AHDC03, AH-OSWLDC (Technitium forwarder queries)
|
- TCP/UDP 53 → AHDC01, AHDC03, AH-OSWLDC (Technitium forwarder queries)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Travel routers (GL.iNet) as roaming WireGuard peers
|
||||||
|
|
||||||
|
The three GL.iNet routers connect to the hub as always-on WireGuard
|
||||||
|
clients from `172.31.9.0/24` (hub = `172.31.9.1`). They are roaming
|
||||||
|
*clients*, not sites: they initiate to the hub, carry no LAN routes of
|
||||||
|
their own on the hub side, and NAT their attached devices behind their
|
||||||
|
single WG address — so the DCs see one IP per router.
|
||||||
|
|
||||||
|
Security model (be aware): unlike OpenVPN users there is **no Duo** here —
|
||||||
|
possession of the router's private key is the whole credential. Each
|
||||||
|
router has its own keypair; if one is lost, remove its entry from
|
||||||
|
`wg_travel_peers` and re-run the playbook — that revokes it completely.
|
||||||
|
|
||||||
|
## Hub side (all via Ansible)
|
||||||
|
|
||||||
|
1. Generate a keypair per router — in the GL.iNet UI (it shows the public
|
||||||
|
key) or manually: `wg genkey | tee priv | wg pubkey`.
|
||||||
|
2. Fill `wg_travel_peers` in `group_vars/vpnhub.yml`:
|
||||||
|
```yaml
|
||||||
|
wg_travel_peers:
|
||||||
|
- { name: glinet-1, public_key: "xxxx=", ip: 172.31.9.11 }
|
||||||
|
- { name: glinet-2, public_key: "yyyy=", ip: 172.31.9.12 }
|
||||||
|
- { name: glinet-3, public_key: "zzzz=", ip: 172.31.9.13 }
|
||||||
|
```
|
||||||
|
3. `ansible-playbook site.yml` — appends the peers to wg0.conf (restarts
|
||||||
|
wg0: brief blip for all tunnels), adds the firewall forwards, and
|
||||||
|
registers `<name>.roam.atlashorizon.net` A + PTR records.
|
||||||
|
4. **Existing deploys only**: add `172.31.9.0/24` to the Technitium
|
||||||
|
recursion ACL by hand (console → Settings → Recursion) — that setting
|
||||||
|
was seeded at first boot and isn't re-read from the compose env.
|
||||||
|
|
||||||
|
## Router side (paste into the GL.iNet WireGuard client UI)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Interface]
|
||||||
|
PrivateKey = <this router's private key>
|
||||||
|
Address = 172.31.9.11/24 # .12 / .13 for the others
|
||||||
|
DNS = 172.31.9.1 # Technitium — AD names resolve
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = <RACKNERD_WG_PUBLIC_KEY> # printed by setup/playbook
|
||||||
|
Endpoint = vpn.atlashorizon.net:51820
|
||||||
|
# FULL TUNNEL (GL.iNet's default, and usually what you want on hotel
|
||||||
|
# wifi): everything rides the VPN; internet egresses via the VPS
|
||||||
|
# (masqueraded by the hub firewall).
|
||||||
|
AllowedIPs = 0.0.0.0/0
|
||||||
|
# Split-tunnel alternative — only AD/hub networks ride the VPN:
|
||||||
|
#AllowedIPs = 192.168.20.0/24, 192.168.7.0/24, 172.31.9.0/24, 172.31.255.1/32
|
||||||
|
PersistentKeepalive = 25
|
||||||
|
```
|
||||||
|
|
||||||
|
## Site side (once, both sites — same drill as the OpenVPN subnet)
|
||||||
|
|
||||||
|
- Add `172.31.9.0/24` to the RackNerd peer's AllowedIPs in each site's
|
||||||
|
WG config.
|
||||||
|
- Return route: `172.31.9.0/24` via the site WG box (LAN router or DCs).
|
||||||
|
- DC/Windows firewall: allow AD ports from `172.31.9.0/24`.
|
||||||
|
- Optional: AD Sites & Services subnet object for `172.31.9.0/24`.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wg show wg0 # router peers: recent handshakes
|
||||||
|
ping 172.31.9.11 # from the hub
|
||||||
|
dig @127.0.0.1 -x 172.31.9.11 +short # glinet-1.roam.atlashorizon.net.
|
||||||
|
# from a device behind the router:
|
||||||
|
# ping ahdc01.ad.atlashorizon.net (resolves via 172.31.9.1, routes via hub)
|
||||||
|
```
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# Duo publishes no official image; build from their source tarball.
|
# Duo publishes no official image; build from their source tarball.
|
||||||
# Install flags match Duo's documented non-interactive install.
|
# Install flags match Duo's documented non-interactive install.
|
||||||
|
#
|
||||||
|
# Slimming option: switch BOTH stages to debian:bookworm-slim (~80MB less,
|
||||||
|
# and the base layer is shared with the openvpn image). Both must move
|
||||||
|
# together — the proxy links against the builder's glibc. Alpine/musl
|
||||||
|
# will not work.
|
||||||
|
#FROM debian:bookworm-slim AS build
|
||||||
|
|
||||||
FROM ubuntu:24.04 AS build
|
FROM ubuntu:24.04 AS build
|
||||||
|
|
||||||
@@ -20,6 +26,8 @@ RUN groupadd -r -g 35505 duo \
|
|||||||
&& ./install --install-dir /opt/duoauthproxy --service-user duo \
|
&& ./install --install-dir /opt/duoauthproxy --service-user duo \
|
||||||
--log-group duo --create-init-script no --enable-selinux no
|
--log-group duo --create-init-script no --enable-selinux no
|
||||||
|
|
||||||
|
# Slim variant (must match the build stage base — see note above):
|
||||||
|
#FROM debian:bookworm-slim
|
||||||
FROM ubuntu:24.04
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
# gettext-base = envsubst for rendering authproxy.cfg from env vars
|
# gettext-base = envsubst for rendering authproxy.cfg from env vars
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ debug=false
|
|||||||
|
|
||||||
[ad_client]
|
[ad_client]
|
||||||
host=${TROCH_DC1_IP}
|
host=${TROCH_DC1_IP}
|
||||||
host_2=${TROCH_DC2_IP}
|
;host_2=${TROCH_DC2_IP}
|
||||||
service_account_username=${DUO_SVC_USER}
|
service_account_username=${DUO_SVC_USER}
|
||||||
service_account_password=${DUO_SVC_PASSWORD}
|
service_account_password=${DUO_SVC_PASSWORD}
|
||||||
search_dn=${AD_SEARCH_DN}
|
search_dn=${AD_SEARCH_DN}
|
||||||
@@ -22,7 +22,7 @@ ssl_ca_certs_file=/certs/ad-ldaps-ca.pem
|
|||||||
ssl_verify_hostname=false
|
ssl_verify_hostname=false
|
||||||
; Restrict VPN access to one AD group (fill DUO_SECURITY_GROUP_DN in .env
|
; Restrict VPN access to one AD group (fill DUO_SECURITY_GROUP_DN in .env
|
||||||
; and uncomment):
|
; and uncomment):
|
||||||
;security_group_dn=${DUO_SECURITY_GROUP_DN}
|
security_group_dn=${DUO_SECURITY_GROUP_DN}
|
||||||
|
|
||||||
[radius_server_auto]
|
[radius_server_auto]
|
||||||
ikey=${DUO_IKEY}
|
ikey=${DUO_IKEY}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ if [ ! -f /certs/ad-ldaps-ca.pem ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
envsubst '${TROCH_DC1_IP} ${TROCH_DC2_IP} ${DUO_IKEY} ${DUO_SKEY} ${DUO_API_HOST} ${DUO_SVC_USER} ${DUO_SVC_PASSWORD} ${AD_SEARCH_DN} ${RADIUS_SECRET}' \
|
envsubst '${TROCH_DC1_IP} ${TROCH_DC2_IP} ${DUO_IKEY} ${DUO_SKEY} ${DUO_API_HOST} ${DUO_SVC_USER} ${DUO_SVC_PASSWORD} ${AD_SEARCH_DN} ${RADIUS_SECRET} ${DUO_SECURITY_GROUP_DN}' \
|
||||||
< /opt/duoauthproxy/conf/authproxy.cfg.template \
|
< /opt/duoauthproxy/conf/authproxy.cfg.template \
|
||||||
> /opt/duoauthproxy/conf/authproxy.cfg
|
> /opt/duoauthproxy/conf/authproxy.cfg
|
||||||
chmod 600 /opt/duoauthproxy/conf/authproxy.cfg
|
chmod 600 /opt/duoauthproxy/conf/authproxy.cfg
|
||||||
|
|||||||
@@ -43,12 +43,28 @@
|
|||||||
-A FORWARD -i tun0 -o wg0 -s 172.31.8.0/24 -d 192.168.7.0/24 -j ACCEPT
|
-A FORWARD -i tun0 -o wg0 -s 172.31.8.0/24 -d 192.168.7.0/24 -j ACCEPT
|
||||||
-A FORWARD -i tun0 -o wg0 -s 172.31.8.0/24 -d 172.31.255.0/24 -j ACCEPT
|
-A FORWARD -i tun0 -o wg0 -s 172.31.8.0/24 -d 172.31.255.0/24 -j ACCEPT
|
||||||
|
|
||||||
|
# Travel routers (roaming WG peers, wg0 -> wg0) -> both sites
|
||||||
|
-A FORWARD -i wg0 -o wg0 -s 172.31.9.0/24 -d 192.168.20.0/24 -j ACCEPT
|
||||||
|
-A FORWARD -i wg0 -o wg0 -s 172.31.9.0/24 -d 192.168.7.0/24 -j ACCEPT
|
||||||
|
|
||||||
|
# Travel routers in FULL-TUNNEL mode (AllowedIPs 0.0.0.0/0, the GL.iNet
|
||||||
|
# default): internet egress via this box, masqueraded below in *nat.
|
||||||
|
-A FORWARD -i wg0 -o ${PUBIF} -s 172.31.9.0/24 -j ACCEPT
|
||||||
|
|
||||||
# Site -> client NEW connections (e.g. RDP/RSAT from a DC to a roaming
|
# Site -> client NEW connections (e.g. RDP/RSAT from a DC to a roaming
|
||||||
# laptop). Off by default; uncomment if you need it.
|
# laptop). Off by default; uncomment if you need it.
|
||||||
#-A FORWARD -i wg0 -o tun0 -d 172.31.8.0/24 -j ACCEPT
|
#-A FORWARD -i wg0 -o tun0 -d 172.31.8.0/24 -j ACCEPT
|
||||||
|
|
||||||
COMMIT
|
COMMIT
|
||||||
|
|
||||||
|
# Internet egress NAT for full-tunnel travel routers ONLY — site-bound
|
||||||
|
# traffic (192.168.x, 172.31.x) never matches this because it leaves via
|
||||||
|
# wg0, not ${PUBIF}. OpenVPN clients stay split-tunnel/unNATed.
|
||||||
|
*nat
|
||||||
|
:POSTROUTING ACCEPT [0:0]
|
||||||
|
-A POSTROUTING -s 172.31.9.0/24 -o ${PUBIF} -j MASQUERADE
|
||||||
|
COMMIT
|
||||||
|
|
||||||
# ── Fallback per project brief ─────────────────────────────────────────
|
# ── Fallback per project brief ─────────────────────────────────────────
|
||||||
# If site-side return routing for 172.31.8.0/24 becomes a pain, NAT the
|
# If site-side return routing for 172.31.8.0/24 becomes a pain, NAT the
|
||||||
# clients behind the wg0 address instead (DCs then see 172.31.255.1):
|
# clients behind the wg0 address instead (DCs then see 172.31.255.1):
|
||||||
|
|||||||
+10
-1
@@ -3,7 +3,8 @@
|
|||||||
# paste in each site's public key afterwards.
|
# paste in each site's public key afterwards.
|
||||||
|
|
||||||
[Interface]
|
[Interface]
|
||||||
Address = 172.31.255.1/24
|
# .255 = site transfer net, .9 = roaming WG peers (travel routers)
|
||||||
|
Address = 172.31.255.1/24, 172.31.9.1/24
|
||||||
ListenPort = 51820
|
ListenPort = 51820
|
||||||
PrivateKey = <RACKNERD_WG_PRIVATE_KEY>
|
PrivateKey = <RACKNERD_WG_PRIVATE_KEY>
|
||||||
# wg-quick auto-adds routes for each peer's AllowedIPs — that is exactly
|
# wg-quick auto-adds routes for each peer's AllowedIPs — that is exactly
|
||||||
@@ -25,3 +26,11 @@ AllowedIPs = 172.31.255.3/32, 192.168.7.0/24
|
|||||||
# Deliberately NOT listing the other site's LAN under either peer:
|
# Deliberately NOT listing the other site's LAN under either peer:
|
||||||
# Oswald<->Trochalakis traffic must stay on their direct tunnel, never
|
# Oswald<->Trochalakis traffic must stay on their direct tunnel, never
|
||||||
# hairpin through this box.
|
# hairpin through this box.
|
||||||
|
|
||||||
|
# ── Travel routers (roaming /32 peers from 172.31.9.0/24) ──
|
||||||
|
# The Ansible deploy appends these from the wg_travel_peers list in
|
||||||
|
# group_vars. For manual setups, copy this block per router:
|
||||||
|
#[Peer] # glinet-1
|
||||||
|
#PublicKey = <ROUTER_WG_PUBLIC_KEY>
|
||||||
|
#AllowedIPs = 172.31.9.11/32
|
||||||
|
# (no Endpoint — routers initiate and keep alive from behind NAT)
|
||||||
+1
-1
@@ -3,7 +3,7 @@ FROM debian:bookworm-slim
|
|||||||
# libpam-radius-auth: PAM -> RADIUS bridge to the Duo Auth Proxy on 127.0.0.1
|
# libpam-radius-auth: PAM -> RADIUS bridge to the Duo Auth Proxy on 127.0.0.1
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
openvpn libpam-radius-auth iproute2 procps ca-certificates \
|
openvpn libpam-radius-auth iproute2 procps ca-certificates socat curl jq \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY pam-openvpn /etc/pam.d/openvpn
|
COPY pam-openvpn /etc/pam.d/openvpn
|
||||||
|
|||||||
@@ -11,4 +11,19 @@ chmod 600 /etc/pam_radius_auth.conf
|
|||||||
mkdir -p /var/lib/openvpn
|
mkdir -p /var/lib/openvpn
|
||||||
[ -c /dev/net/tun ] || { mkdir -p /dev/net; mknod /dev/net/tun c 10 200; }
|
[ -c /dev/net/tun ] || { mkdir -p /dev/net; mknod /dev/net/tun c 10 200; }
|
||||||
|
|
||||||
|
# OpenVPN does NOT pass its inherited environment to hook scripts — it
|
||||||
|
# builds a clean env for them. Hand learn-address.sh its settings via a
|
||||||
|
# file it sources (the hook runs as nobody after the privilege drop).
|
||||||
|
sq() { printf '%s' "$1" | sed "s/'/'\\\\''/g"; }
|
||||||
|
if [ -n "${TECHNITIUM_ADMIN_PASSWORD:-}" ] && [ -n "${VPN_DNS_ZONE:-}" ]; then
|
||||||
|
{
|
||||||
|
printf "TECHNITIUM_ADMIN_PASSWORD='%s'\n" "$(sq "$TECHNITIUM_ADMIN_PASSWORD")"
|
||||||
|
printf "VPN_DNS_ZONE='%s'\n" "$(sq "$VPN_DNS_ZONE")"
|
||||||
|
} > /etc/openvpn/learn-address.env
|
||||||
|
chown nobody:nogroup /etc/openvpn/learn-address.env
|
||||||
|
chmod 400 /etc/openvpn/learn-address.env
|
||||||
|
else
|
||||||
|
rm -f /etc/openvpn/learn-address.env
|
||||||
|
fi
|
||||||
|
|
||||||
exec openvpn --cd /etc/openvpn --config server.conf
|
exec openvpn --cd /etc/openvpn --config server.conf
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# OpenVPN learn-address hook: registers VPN clients in Technitium DNS.
|
||||||
|
# add/update <ip> <cn> -> A <cn>.$VPN_DNS_ZONE + PTR, TTL 60
|
||||||
|
# delete <ip> -> removes both records
|
||||||
|
#
|
||||||
|
# CRITICAL: this script must ALWAYS exit 0 — a non-zero exit makes
|
||||||
|
# OpenVPN reject the client's address. DNS registration is best-effort;
|
||||||
|
# it must never take the VPN down with it.
|
||||||
|
|
||||||
|
OP="$1"; ADDR="$2"; CN="${3:-}"
|
||||||
|
|
||||||
|
API="http://127.0.0.1:5380/api"
|
||||||
|
|
||||||
|
log() { echo "learn-address: $*"; }
|
||||||
|
log "invoked: op=$OP addr=$ADDR cn=${CN:-<none>}"
|
||||||
|
|
||||||
|
# OpenVPN gives hooks a clean environment — settings come from the file
|
||||||
|
# the entrypoint writes, not from the container env.
|
||||||
|
[ -f /etc/openvpn/learn-address.env ] && . /etc/openvpn/learn-address.env
|
||||||
|
|
||||||
|
[ -n "${TECHNITIUM_ADMIN_PASSWORD:-}" ] || { log "TECHNITIUM_ADMIN_PASSWORD not available; skipping"; exit 0; }
|
||||||
|
[ -n "${VPN_DNS_ZONE:-}" ] || { log "VPN_DNS_ZONE not in env; skipping"; exit 0; }
|
||||||
|
|
||||||
|
TOKEN=$(curl -sf -m 5 -G "$API/user/login" \
|
||||||
|
--data-urlencode "user=admin" \
|
||||||
|
--data-urlencode "pass=$TECHNITIUM_ADMIN_PASSWORD" 2>/dev/null \
|
||||||
|
| jq -r '.token // empty')
|
||||||
|
[ -n "$TOKEN" ] || { log "Technitium API login failed; skipping $OP $ADDR"; exit 0; }
|
||||||
|
AUTH="Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# 172.31.8.5 -> 5.8.31.172.in-addr.arpa
|
||||||
|
PTR_HOST=$(printf '%s' "$ADDR" | awk -F. '{print $4"."$3"."$2"."$1".in-addr.arpa"}')
|
||||||
|
|
||||||
|
deregister() {
|
||||||
|
# PTR tells us which A record this IP belonged to
|
||||||
|
TARGET=$(curl -sf -m 5 -H "$AUTH" -G "$API/zones/records/get" \
|
||||||
|
--data-urlencode "domain=$PTR_HOST" 2>/dev/null \
|
||||||
|
| jq -r '.response.records[]? | select(.type=="PTR") | .rData.ptrName' | head -n1)
|
||||||
|
[ -n "$TARGET" ] || return 0
|
||||||
|
curl -sf -m 5 -H "$AUTH" -G "$API/zones/records/delete" \
|
||||||
|
--data-urlencode "domain=$PTR_HOST" \
|
||||||
|
--data-urlencode "type=PTR" \
|
||||||
|
--data-urlencode "ptrName=$TARGET" >/dev/null 2>&1 || log "PTR delete failed for $ADDR"
|
||||||
|
curl -sf -m 5 -H "$AUTH" -G "$API/zones/records/delete" \
|
||||||
|
--data-urlencode "domain=$TARGET" \
|
||||||
|
--data-urlencode "zone=$VPN_DNS_ZONE" \
|
||||||
|
--data-urlencode "type=A" \
|
||||||
|
--data-urlencode "ipAddress=$ADDR" >/dev/null 2>&1 || log "A delete failed for $TARGET/$ADDR"
|
||||||
|
}
|
||||||
|
|
||||||
|
register() {
|
||||||
|
# sanitize CN (= AD username) into a DNS label
|
||||||
|
NAME=$(printf '%s' "$CN" | tr '[:upper:]' '[:lower:]' \
|
||||||
|
| tr -c 'a-z0-9-' '-' | sed 's/^-*//;s/-*$//' | cut -c1-63)
|
||||||
|
[ -n "$NAME" ] || return 0
|
||||||
|
curl -sf -m 5 -H "$AUTH" -G "$API/zones/records/add" \
|
||||||
|
--data-urlencode "domain=$NAME.$VPN_DNS_ZONE" \
|
||||||
|
--data-urlencode "zone=$VPN_DNS_ZONE" \
|
||||||
|
--data-urlencode "type=A" \
|
||||||
|
--data-urlencode "ttl=60" \
|
||||||
|
--data-urlencode "ipAddress=$ADDR" \
|
||||||
|
--data-urlencode "ptr=true" \
|
||||||
|
--data-urlencode "createPtrZone=true" >/dev/null 2>&1 \
|
||||||
|
|| log "A/PTR add failed for $NAME/$ADDR"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$OP" in
|
||||||
|
add) register ;;
|
||||||
|
update) deregister; register ;; # IP reassigned to a different client
|
||||||
|
delete) deregister ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
+26
-27
@@ -1,10 +1,14 @@
|
|||||||
# OpenVPN PKI — offline CA workflow
|
# OpenVPN PKI — offline CA workflow (server cert only)
|
||||||
|
|
||||||
|
There are **no individual client certificates** in this design — users
|
||||||
|
authenticate with AD password + Duo. The PKI exists solely so clients can
|
||||||
|
verify they're talking to the real server (`remote-cert-tls server`), and
|
||||||
|
`ta.key` (tls-crypt) gates who can attempt a handshake at all.
|
||||||
|
|
||||||
The CA private key **never touches the VPS**. Run easy-rsa on an offline
|
The CA private key **never touches the VPS**. Run easy-rsa on an offline
|
||||||
machine (or at minimum a machine that isn't this server), then copy only the
|
machine, then copy only the issued material here.
|
||||||
issued material here.
|
|
||||||
|
|
||||||
## One-time CA + server cert (on the offline machine)
|
## One-time setup (on the offline machine)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/OpenVPN/easy-rsa.git && cd easy-rsa/easyrsa3
|
git clone https://github.com/OpenVPN/easy-rsa.git && cd easy-rsa/easyrsa3
|
||||||
@@ -12,39 +16,34 @@ git clone https://github.com/OpenVPN/easy-rsa.git && cd easy-rsa/easyrsa3
|
|||||||
./easyrsa build-ca # sets the CA passphrase — keep offline
|
./easyrsa build-ca # sets the CA passphrase — keep offline
|
||||||
./easyrsa build-server-full racknerd-vpn nopass
|
./easyrsa build-server-full racknerd-vpn nopass
|
||||||
openvpn --genkey tls-crypt ta.key # shared tls-crypt key
|
openvpn --genkey tls-crypt ta.key # shared tls-crypt key
|
||||||
./easyrsa gen-crl
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Copy to the VPS (this directory)
|
## Copy to the VPS (this directory)
|
||||||
|
|
||||||
| File on offline box | Name here |
|
| File on offline box | Name here |
|
||||||
|--------------------------------------|---------------|
|
|--------------------------------|--------------|
|
||||||
| `pki/ca.crt` | `ca.crt` |
|
| `pki/ca.crt` | `ca.crt` |
|
||||||
| `pki/issued/racknerd-vpn.crt` | `server.crt` |
|
| `pki/issued/racknerd-vpn.crt` | `server.crt` |
|
||||||
| `pki/private/racknerd-vpn.key` | `server.key` |
|
| `pki/private/racknerd-vpn.key` | `server.key` |
|
||||||
| `ta.key` | `ta.key` |
|
| `ta.key` | `ta.key` |
|
||||||
| `pki/crl.pem` (optional, see server.conf) | `crl.pem` |
|
|
||||||
|
|
||||||
`chmod 600 server.key ta.key` after copying.
|
`chmod 600 server.key ta.key` after copying.
|
||||||
|
|
||||||
## Per-user client certs (offline)
|
## Client profile
|
||||||
|
|
||||||
```bash
|
Build ONE `.ovpn` from `client/client.ovpn.template`, pasting `ca.crt` and
|
||||||
./easyrsa build-client-full nick nopass
|
`ta.key` into the inline blocks. The same profile goes to every user.
|
||||||
```
|
|
||||||
|
|
||||||
Build the client's `.ovpn` from `client/client.ovpn.template`, pasting
|
## Revoking access
|
||||||
`ca.crt`, the client cert/key, and `ta.key` into the inline blocks.
|
|
||||||
CN should match the AD username — the server logs use it
|
|
||||||
(`username-as-common-name` keeps them consistent).
|
|
||||||
|
|
||||||
## Revoking a client
|
No client certs means no CRL — access control lives where the identity
|
||||||
|
lives:
|
||||||
|
|
||||||
```bash
|
- **Disable the AD account** (or remove it from the VPN group if you use
|
||||||
./easyrsa revoke nick && ./easyrsa gen-crl
|
`security_group_dn`) — kills new logins immediately.
|
||||||
```
|
- Remove/disable the user in **Duo** as backup.
|
||||||
|
- If a *profile* leaks (ta.key compromised): regenerate `ta.key`, replace
|
||||||
Copy the fresh `crl.pem` here, uncomment `crl-verify` in `server.conf`, and
|
it here, restart openvpn, redistribute the profile — old profiles stop
|
||||||
`docker compose restart openvpn`.
|
working instantly.
|
||||||
|
|
||||||
Everything in this directory except this README is gitignored.
|
Everything in this directory except this README is gitignored.
|
||||||
+38
-17
@@ -1,6 +1,8 @@
|
|||||||
# OpenVPN road-warrior endpoint — RackNerd hub
|
# OpenVPN road-warrior endpoint — RackNerd hub
|
||||||
# Auth chain: client cert (offline CA) + user/pass -> PAM -> RADIUS
|
# Auth chain: AD user/pass -> PAM -> RADIUS
|
||||||
# -> Duo Auth Proxy (127.0.0.1:1812) -> LDAPS to the DCs.
|
# -> Duo Auth Proxy (127.0.0.1:1812) -> LDAPS to the DCs.
|
||||||
|
# No individual client certs: identity is LDAP + Duo only. tls-crypt
|
||||||
|
# (shared key in the profile) still gates who can attempt a handshake.
|
||||||
|
|
||||||
port 1194
|
port 1194
|
||||||
proto udp4
|
proto udp4
|
||||||
@@ -8,44 +10,53 @@ dev tun0
|
|||||||
topology subnet
|
topology subnet
|
||||||
|
|
||||||
server 172.31.8.0 255.255.255.0
|
server 172.31.8.0 255.255.255.0
|
||||||
ifconfig-pool-persist /var/lib/openvpn/ipp.txt
|
|
||||||
|
# Identity = AD username (username-as-common-name), and one user WILL
|
||||||
|
# connect from several devices (laptop + phone). duplicate-cn allows the
|
||||||
|
# concurrent sessions and gives each its own pool IP. Sticky per-user IPs
|
||||||
|
# (ifconfig-pool-persist) are incompatible with that — do not re-enable.
|
||||||
|
duplicate-cn
|
||||||
|
|
||||||
# Split tunnel: only the AD sites + transfer net ride the VPN.
|
# Split tunnel: only the AD sites + transfer net ride the VPN.
|
||||||
push "route 192.168.20.0 255.255.255.0"
|
push "route 192.168.20.0 255.255.255.0"
|
||||||
push "route 192.168.7.0 255.255.255.0"
|
push "route 192.168.7.0 255.255.255.0"
|
||||||
push "route 172.31.255.0 255.255.255.0"
|
push "route 172.31.255.0 255.255.255.0"
|
||||||
|
|
||||||
# All client DNS goes to Technitium on this box (block-outside-dns stops
|
# ALL client DNS goes to Technitium. Deliberately NO "dhcp-option DOMAIN"
|
||||||
# Windows from leaking AD lookups to the local LAN resolver).
|
# or DOMAIN-SEARCH here: in OpenVPN3 clients (Connect on macOS/iOS/
|
||||||
|
# Android/Windows) those options MEAN split-DNS — pushing them scopes the
|
||||||
|
# VPN DNS to just those domains. With only DNS pushed, 172.31.8.1 becomes
|
||||||
|
# the default resolver for every query. ADAPTER_DOMAIN_SUFFIX sets the
|
||||||
|
# Windows connection-specific suffix (short names like "ahdc01" still
|
||||||
|
# resolve) without triggering split-DNS. block-outside-dns additionally
|
||||||
|
# stops Windows from leaking queries to the local LAN resolver.
|
||||||
push "dhcp-option DNS 172.31.8.1"
|
push "dhcp-option DNS 172.31.8.1"
|
||||||
push "dhcp-option DOMAIN ad.atlashorizon.net"
|
push "dhcp-option ADAPTER_DOMAIN_SUFFIX ad.atlashorizon.net"
|
||||||
push "dhcp-option DOMAIN-SEARCH ad.atlashorizon.net"
|
|
||||||
push "dhcp-option DOMAIN-SEARCH ad.calliecorner.net"
|
|
||||||
push "dhcp-option DOMAIN-SEARCH ad.oswaldtheotter.net"
|
|
||||||
push "dhcp-option DOMAIN-SEARCH ad.trochalakis.net"
|
|
||||||
push "block-outside-dns"
|
push "block-outside-dns"
|
||||||
|
|
||||||
# PKI — issued from the offline CA, see pki/README.md
|
# PKI — server certificate only (offline CA, see pki/README.md);
|
||||||
|
# clients present no certificate.
|
||||||
ca /etc/openvpn/pki/ca.crt
|
ca /etc/openvpn/pki/ca.crt
|
||||||
cert /etc/openvpn/pki/server.crt
|
cert /etc/openvpn/pki/server.crt
|
||||||
key /etc/openvpn/pki/server.key
|
key /etc/openvpn/pki/server.key
|
||||||
dh none
|
dh none
|
||||||
tls-crypt /etc/openvpn/pki/ta.key
|
tls-crypt /etc/openvpn/pki/ta.key
|
||||||
# Enable once you publish a CRL from the offline CA (revoked laptops etc.):
|
|
||||||
;crl-verify /etc/openvpn/pki/crl.pem
|
|
||||||
|
|
||||||
remote-cert-tls client
|
|
||||||
tls-version-min 1.2
|
tls-version-min 1.2
|
||||||
data-ciphers AES-256-GCM:CHACHA20-POLY1305
|
data-ciphers AES-256-GCM:CHACHA20-POLY1305
|
||||||
auth SHA256
|
auth SHA256
|
||||||
|
|
||||||
# User auth via Duo (password only = push; "password,123456" = passcode)
|
# User auth via Duo (password only = push; "password,123456" = passcode)
|
||||||
plugin /usr/lib/openvpn/openvpn-plugin-auth-pam.so openvpn
|
plugin /usr/lib/openvpn/openvpn-plugin-auth-pam.so openvpn
|
||||||
verify-client-cert require
|
verify-client-cert none
|
||||||
username-as-common-name
|
username-as-common-name
|
||||||
hand-window 120 # allow time for the Duo push round-trip
|
hand-window 120 # allow time for the Duo push round-trip
|
||||||
reneg-sec 0 # no mid-session TLS renegotiation (would re-prompt Duo)
|
# Hourly rekey + 12h auth token: renegotiations are satisfied silently by
|
||||||
auth-gen-token 43200 # reconnects within 12h reuse a token instead of a new push
|
# the pushed token (no mid-session Duo prompts); a fresh password + push
|
||||||
|
# is only required once the token expires. (auth-gen-token requires a
|
||||||
|
# non-zero reneg-sec — do not set reneg-sec 0 here.)
|
||||||
|
reneg-sec 3600
|
||||||
|
auth-gen-token 43200
|
||||||
|
|
||||||
keepalive 10 60
|
keepalive 10 60
|
||||||
explicit-exit-notify 1
|
explicit-exit-notify 1
|
||||||
@@ -60,6 +71,16 @@ group nogroup
|
|||||||
persist-key
|
persist-key
|
||||||
persist-tun
|
persist-tun
|
||||||
|
|
||||||
|
# Register each client as <username>.$VPN_DNS_ZONE (A + PTR) in Technitium
|
||||||
|
# on connect, clean up on disconnect. Invoked via sh so the mounted script
|
||||||
|
# needs no exec bit. NOTE: openvpn runs hooks with a clean environment, so
|
||||||
|
# the script sources /etc/openvpn/learn-address.env (written by the
|
||||||
|
# entrypoint from the container env) rather than reading env vars directly.
|
||||||
|
script-security 2
|
||||||
|
learn-address "/bin/sh /etc/openvpn/learn-address.sh"
|
||||||
|
|
||||||
status /var/lib/openvpn/status.log 30
|
status /var/lib/openvpn/status.log 30
|
||||||
management 127.0.0.1 7505
|
# Unix socket (root-only) instead of a passwordless TCP port:
|
||||||
|
# docker compose exec openvpn sh -c "socat - UNIX:/var/lib/openvpn/mgmt.sock"
|
||||||
|
management /var/lib/openvpn/mgmt.sock unix
|
||||||
verb 3
|
verb 3
|
||||||
@@ -77,4 +77,42 @@ ensure_fwd_zone ad.oswaldtheotter.net "$OSWALD_DC_IP"
|
|||||||
ensure_fwd_zone 20.168.192.in-addr.arpa "$TROCH_DC1_IP" "$TROCH_DC2_IP"
|
ensure_fwd_zone 20.168.192.in-addr.arpa "$TROCH_DC1_IP" "$TROCH_DC2_IP"
|
||||||
ensure_fwd_zone 7.168.192.in-addr.arpa "$OSWALD_DC_IP"
|
ensure_fwd_zone 7.168.192.in-addr.arpa "$OSWALD_DC_IP"
|
||||||
|
|
||||||
|
# Primary zones for VPN client registration (openvpn learn-address hook
|
||||||
|
# writes <username>.$VPN_DNS_ZONE A records + PTRs for 172.31.8.0/24)
|
||||||
|
ensure_primary_zone() {
|
||||||
|
zone=$1
|
||||||
|
if zone_exists "$zone"; then
|
||||||
|
echo "zone $zone already exists — leaving as-is"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "creating primary zone $zone"
|
||||||
|
curl -sf -H "$AUTH" -G "$API/zones/create" \
|
||||||
|
--data-urlencode "zone=$zone" \
|
||||||
|
--data-urlencode "type=Primary" | api_ok
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "${VPN_DNS_ZONE:-}" ]; then
|
||||||
|
ensure_primary_zone "$VPN_DNS_ZONE"
|
||||||
|
ensure_primary_zone "8.31.172.in-addr.arpa" # OpenVPN clients
|
||||||
|
ensure_primary_zone "9.31.172.in-addr.arpa" # travel routers
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Static A + PTR for travel routers (VPN_TRAVEL_PEERS="name:ip,name:ip").
|
||||||
|
# overwrite=true keeps this idempotent across re-runs.
|
||||||
|
if [ -n "${VPN_TRAVEL_PEERS:-}" ] && [ -n "${VPN_DNS_ZONE:-}" ]; then
|
||||||
|
echo "$VPN_TRAVEL_PEERS" | tr ',' '\n' | while IFS=: read -r name ip; do
|
||||||
|
[ -n "$name" ] && [ -n "$ip" ] || continue
|
||||||
|
echo "registering travel router $name -> $ip"
|
||||||
|
curl -sf -H "$AUTH" -G "$API/zones/records/add" \
|
||||||
|
--data-urlencode "domain=$name.$VPN_DNS_ZONE" \
|
||||||
|
--data-urlencode "zone=$VPN_DNS_ZONE" \
|
||||||
|
--data-urlencode "type=A" \
|
||||||
|
--data-urlencode "ttl=3600" \
|
||||||
|
--data-urlencode "ipAddress=$ip" \
|
||||||
|
--data-urlencode "overwrite=true" \
|
||||||
|
--data-urlencode "ptr=true" \
|
||||||
|
--data-urlencode "createPtrZone=true" | api_ok
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Technitium AD forwarder zones OK."
|
echo "Technitium AD forwarder zones OK."
|
||||||
Reference in new issue
Block a user