MANUAL OPENVPN-OVER-TAILSCALE RELAY KIT This kit carries one existing OpenVPN UDP connection through an authorized Windows office computer. It is not a VPN server, exit node, public proxy or replacement for the original OpenVPN encryption/authentication. Path: OpenVPN -> Mac localhost UDP -> office Tailscale IPv4 UDP -> fixed VPN gateway. The original private deployment was tested on macOS and Windows PowerShell 5.1. This public, configurable adaptation has separate tests. Do not interpret those tests as proof that your network, account, firewall or VPN server will work. The test commands below do not connect to a real VPN or modify a firewall. FILES AND REQUIREMENTS - macOS: Python 3.10 or newer, Tailscale, and an existing OpenVPN UDP client/profile. - Windows: Windows PowerShell 5.1, Tailscale connected on the intended office PC. - Both devices must belong to a tailnet where the Mac can reach the office's configured UDP relay port. Obtain permission to use the office connection. - No router port forwarding, public relay listener or Tailscale exit node is needed. - DEV is required; LIVE is optional. The names are labels, not hardcoded work sites. - relay-config.example.json is deliberately unusable until edited. - mac-relay.py is the generic forwarder; relay_config.py validates its config. - mac-relay-control.py manages manual launchd jobs and reports status/diagnostics. - office-relay.ps1 and relay-config.ps1 implement the restricted Windows relay. - office-firewall.ps1 provides explicit Plan/Add/Remove for an exact inbound rule. - All launchers resolve files relative to their own folder; keep the kit together. 1. CONFIGURE BOTH MACHINES Copy relay-config.example.json to relay-config.json. Use the SAME edited JSON on both machines. Never put passwords, private keys, certificates or session tokens in it. All OpenVPN credentials stay in your original VPN client/profile. Replace: - office_tailscale_ip: the office PC's current Tailscale IPv4. - mac_tailscale_ip: the Mac's current Tailscale IPv4; it must be different. - each gateway_ip: the actual public IPv4 of the authorized OpenVPN UDP gateway. The supplied 192.0.2.x addresses are documentation examples and are rejected. - configured: set true only after replacing those values. Keep the default ports unless you have a specific conflict: office UDP listener Mac localhost UDP listener DEV 11194 11195 LIVE 11196 11197 gateway_port defaults to 1194. It must match the actual server's UDP listener. For one VPN, remove the entire LIVE object from profiles and use DEV launchers. The check helper skips unconfigured LIVE; attempting to start LIVE fails clearly. Read the Tailscale address on Windows with: & "$env:ProgramFiles\Tailscale\tailscale.exe" ip -4 On macOS use Tailscale's interface or: /Applications/Tailscale.app/Contents/MacOS/Tailscale ip -4 Gateway IPs and Tailscale device identities can change. Re-resolve the original VPN hostname when appropriate, verify the intended device/gateway, and update both config copies deliberately. Do not widen listeners or firewall rules to work around an address mismatch. Existing Mac jobs keep their validated runtime snapshot; disconnect the VPN, stop and explicitly start after changing config. 2. REVIEW AND CHECK WINDOWS Review the downloaded scripts before running them. In the extracted kit folder, remove the downloaded-file marker only from the reviewed PowerShell scripts: Get-ChildItem -LiteralPath . -Filter '*.ps1' -File -Recurse | Unblock-File The CMD launchers use -ExecutionPolicy RemoteSigned for that PowerShell process only. They do not set a permanent execution policy, use Bypass or override managed MachinePolicy/UserPolicy. If organizational policy prevents execution, follow your administrator's policy instead of changing it globally. Double-click check-office-relays.cmd. This reads configuration, Tailscale service/ backend state, the expected local IPv4, selected settings and port occupancy. It starts no relay, changes no settings and writes no diagnostic file. Read the findings, not only the exit code: Plan can complete while showing a not-ready interface or an occupied port. Start fails closed in either case. 3. REVIEW AND ADD THE NARROW WINDOWS FIREWALL RULE Connect Tailscale first. In Windows PowerShell, from the kit folder: powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File .\office-firewall.ps1 -Action Plan -Environment DEV After reviewing the displayed scope, run Add from an elevated Windows PowerShell: powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File .\office-firewall.ps1 -Action Add -Environment DEV Repeat for LIVE only if configured and needed. The rule permits inbound UDP only to the configured office Tailscale IPv4/port, from the configured Mac Tailscale IPv4, on the actual Tailscale interface, for the Windows PowerShell executable. The Mac uses an ephemeral source port, so RemotePort is Any. All other listed constraints remain exact. The helper never disables the firewall. An existing identical rule is a no-op. A differently scoped rule with the reserved name is refused, not silently replaced or widened. The rules persist after relay exit. Other broader firewall rules or tailnet grants are not narrowed by adding this rule. Review the effective tailnet/access policy; grants are additive. 4. START THE OFFICE RELAY, THEN THE MAC RELAY Windows: double-click start-office-dev.cmd (or start-office-live.cmd). Leave that console open. It runs until stopped, unless a real error interrupts it. It pauses on return so the reason remains visible. Ctrl+C stops the relay. Start waits briefly for an already-starting Tailscale interface. This is a 30-second polling budget; a stalled native Windows cmdlet can take longer. It does not log in, start a service, select a tailnet or repair Tailscale for you. It requires the exact office IP once in Preferred state and Tailscale service/backend Running. It refuses a selected exit node and an occupied listener. After readiness, Start sets office Tailscale accept-dns=false and accept-routes=false; it prints their prior values. Those two preferences remain false after cleanup. Plan never changes them. Mac: double-click start-dev-relay.command (or start-live-relay.command). If executable permissions were lost during extraction, from the kit folder: chmod +x ./*.command Alternatively: python3 mac-relay-control.py start DEV Mac jobs are manually bootstrapped into the logged-in user's launchd domain. RunAtLoad=true, KeepAlive=false. No files are placed in ~/Library/LaunchAgents. Closing Terminal or the app that launched the command does not own the relay's lifetime. Logout/reboot and actual process/network failures can still stop it; there is no automatic startup or crash restart. Do not run the controller with sudo. The runtime directory is ~/Library/Application Support/VPNRelay, mode 0700. It contains private copies of the generic relay/config module, validated config snapshots, job definitions and mode 0600 logs. The actual Python executable used to run the controller is recorded in ProgramArguments; no Homebrew path is assumed. 5. CONNECT THE EXISTING OPENVPN PROFILE Make a local copy of the correct existing UDP .ovpn profile. Change its remote endpoint to localhost and the selected Mac port: remote 127.0.0.1 11195 For LIVE use 11197. Preserve CA, certificates, keys, tls-crypt/tls-auth, routing, remote-cert-tls and any existing explicit server-identity checks. Do not disable certificate validation or change ciphers to make the relay work. A profile that uses TCP needs a server-supported UDP profile; editing proto alone is insufficient. Do not add auth-nocache as a troubleshooting measure: in supported OpenVPN Connect versions it can prevent credential reuse at renewal/reconnect. If your original profile intentionally includes it, follow your VPN administrator's credential policy. This kit neither saves a VPN password nor changes a VPN profile. Import the separate copy in OpenVPN Connect and authenticate there. A connected indicator alone is not a data-path test. Check the office Tailscale route before and after connecting; it must remain usable outside the work tunnel: route -n get YOUR_OFFICE_TAILSCALE_IPV4 Verify the routes for your work DNS/test destination, explicit UDP and TCP DNS, and verified HTTPS. Use your own work DNS IP; none is shipped with this kit: dig @YOUR_WORK_DNS_IPV4 example.com +notcp +ignore +time=3 +tries=1 dig +tcp @YOUR_WORK_DNS_IPV4 example.com +time=3 +tries=1 curl -q --noproxy '*' --head --max-time 5 https://1.1.1.1/ If those destinations should traverse the work VPN, verify their routes first. Repeat after several minutes. Preserve ordinary system DNS tests separately from explicit DNS-server tests. Do not blindly add a route via net_gateway: the right path depends on both tunnel providers. Prove actual traffic as well as routes. 6. CHECK, STOP, AND USE BOUNDED TEST RUNS Mac read-only diagnosis: python3 mac-relay-control.py diagnose DEV python3 mac-relay-control.py status DEV Or double-click check-dev.command / check-live.command. These inspect job state, socket ownership, last stop/error/counters and, for diagnose, a limited Tailscale status summary. They send no test packets. A local listener or an online office peer does NOT prove that the office relay, gateway or VPN traffic is working. Windows logs are under %LOCALAPPDATA%\VPNRelay\logs. Logs contain timestamps, readiness states, selected endpoint metadata, counts and numeric socket errors; no packet payloads or raw Tailscale status JSON are logged. Receive error 10054 is tolerated only on receive stages, counted by side, with its first occurrence logged. Send 10022 and other unhandled errors remain fatal; they are not ignored. Disconnect OpenVPN before deliberate relay maintenance. On Mac: python3 mac-relay-control.py stop DEV or use stop-dev-relay.command. Stop works even if the config was removed. On Windows use Ctrl+C in the corresponding foreground relay console. Closing it also stops that relay. Neither side automatically restarts the other. Optional bounded runs, after stopping any existing run: python3 mac-relay-control.py start DEV --seconds 600 powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File .\office-relay.ps1 -Environment DEV -Action Start -MaxMinutes 10 Mac --seconds accepts 1..28800; omission means until stopped. The direct Windows script retains a 480-minute default without -RunUntilStopped. Its everyday CMD launchers explicitly pass -RunUntilStopped. Supplying that switch and an explicit -MaxMinutes together is rejected. Bounded timers measure runtime; do not treat the Mac's no-sleep expiry estimate as a guaranteed wall-clock deadline. 7. CLEANUP After disconnecting the VPN and stopping both relays, remove an unused exact rule from an elevated Windows PowerShell: powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File .\office-firewall.ps1 -Action Remove -Environment DEV Repeat for LIVE if applicable. Remove refuses a conflicting rule instead of deleting arbitrary firewall rules. Restore the prior Tailscale DNS/route-acceptance preferences deliberately if you want them back. The kit does not remove devices from your tailnet or delete VPN profiles. Runtime logs/config copies remain for diagnosis; you can delete the kit's runtime directory after both jobs are stopped. DEVELOPER VERIFICATION Portable tests (Python 3.10+): python3 -B -m unittest discover -s tests -v Native Windows PowerShell 5.1 parser and isolated configuration/scope tests: powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File .\tests\validate-powershell.ps1 The Python suite uses temporary files inside tests/ and sockets bound only to loopback. It checks binary forwarding, rejection of an unrelated upstream sender, timer expiry, cleanup, source-route failure and useful diagnostics. A second loopback-source integration case skips on systems without that source address; the source guard still has direct checks. Controller tests use fixtures, not real launchd operations. PowerShell tests parse scripts and use synthetic objects; they do not invoke Tailscale or real firewall operations. SHA256SUMS covers the distributed source files (not itself or the ZIP). It detects content differences against the supplied manifest, not independent authenticity. This kit uses the repository's MIT license; see LICENSE.