{"id":2999,"date":"2025-10-01T21:02:48","date_gmt":"2025-10-01T21:02:48","guid":{"rendered":"https:\/\/webbservern.se\/~jonas\/wordpress\/?p=2999"},"modified":"2025-10-01T21:02:48","modified_gmt":"2025-10-01T21:02:48","slug":"routing-specific-destinations-via-vpn","status":"publish","type":"post","link":"https:\/\/webbservern.se\/~jonas\/wordpress\/?p=2999","title":{"rendered":"Routing specific destinations via vpn"},"content":{"rendered":"\n<p>Recently I stumbled upon a curious case where traffic for a big video content provider should be routed via a VPN. The requirement was that only traffic from a certain executable, for specific domains (used by that content provider) should be routed via the VPN, and the rest of the traffic should not be affected.<\/p>\n\n\n\n<p>I have done similar things before with cgroups earlier and together with chatgpt I came up with a solution which I think is worth taking note of for similar upcoming tasks.<\/p>\n\n\n\n<p>In this example we are using OpenVPN and taking advantage of the &#8220;up&#8221; and &#8220;down&#8221; hooks from OpenVPN:<\/p>\n\n\n\n<p><code>script-security 2<br>up \/etc\/openvpn\/client\/videoprovider-up.sh<br>down \/etc\/openvpn\/client\/videoprovider-down.sh<\/code><\/p>\n\n\n\n<p><code>ls -l \/etc\/openvpn\/client\/<br>-rw-r----- 1 jonas jonas 20 Sep 30 09:41 auth.txt<br>lrwxrwxrwx 1 root root 28 Sep 30 17:42 vpn.conf -> \/etc\/openvpn\/client\/vpn.ovpn<br>-rw-r--r-- 1 jonas jonas 4685 Oct 1 19:50 vpn.ovpn<br>-rwxr-xr-x 1 root root 756 Sep 30 17:32 videoprovider-down.sh<br>-rwxr-xr-x 1 root root 1421 Sep 30 17:37 videoprovider-refresh.sh<br>-rwxr-xr-x 1 root root 126 Sep 30 17:01 videoprovider-up.sh<\/code><\/p>\n\n\n\n<p>For completeness I list the content of the scripts.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><strong><strong>videoprovider<\/strong>-up.sh \n<\/strong>#!\/bin\/bash\n# Start the VideoProvider refresher script in background\nREFRESHER=\"\/etc\/openvpn\/client\/videoprovider-refresh.sh\"\nLOGFILE=\"\/var\/log\/videoprovider-refresh.log\"\n\n# Launch in background, disown so it survives OpenVPN script environment\nnohup \"$REFRESHER\" >> \"$LOGFILE\" 2>&amp;1 &amp;\ndisown\n\n<strong>videoprovider-refresh.sh<\/strong>\n#!\/bin\/bash\n# VideoProvider VPN dynamic route refresher using systemd slice\n\nVPN_IFACE=\"tun0\"\nVIDEOPROVIDER_TABLE=\"videoprovidervpn\"\nKODI_CGROUP=\"\/system.slice\/kodi.service\"\nTABLE_ID=10\nLOCKFILE=\"\/run\/videoprovider-refresh.lock\"\nDOMAINS=(\n    \"videoprovider.com\"\n    \"www.videoprovider.com\"\n    \"api.videoprovider.com\"\n)\nINTERVAL=300\n\n# --- Prevent multiple loops ---\nif &#91; -e \"$LOCKFILE\" ] &amp;&amp; kill -0 \"$(cat \"$LOCKFILE\")\" 2>\/dev\/null; then\n    echo \"Refresher already running (PID $(cat $LOCKFILE))\"\n    exit 0\nfi\n\necho $$ > \"$LOCKFILE\"\ntrap \"rm -f $LOCKFILE\" EXIT\n\n# --- Ensure routing table exists ---\ngrep -q \"$VIDEOPROVIDER_TABLE\" \/etc\/iproute2\/rt_tables || echo \"10 $VIDEOPROVIDER_TABLE\" >> \/etc\/iproute2\/rt_tables\n\n# --- Add iptables mark for Kodi cgroup ---\niptables -t mangle -C OUTPUT -m cgroup --path \"\/sys\/fs\/cgroup$KODI_CGROUP\" -j MARK --set-mark $TABLE_ID 2>\/dev\/null || \\\n    iptables -t mangle -A OUTPUT -m cgroup --path \"\/sys\/fs\/cgroup$KODI_CGROUP\" -j MARK --set-mark $TABLE_ID\n\n# --- Main loop ---\nwhile true; do\n    # Flush old rules\n    ip rule show | grep \"$VIDEOPROVIDER_TABLE\" | while read -r rule; do\n        ip rule del ${rule#0: }\n    done\n\n    ip route flush table \"$VIDEOPROVIDER_TABLE\" 2>\/dev\/null\n    ip route add default dev \"$VPN_IFACE\" table \"$VIDEOPROVIDER_TABLE\"\n\n    # Resolve VideoProvider domains and add rules\n    for domain in \"${DOMAINS&#91;@]}\"; do\n        for ip in $(getent ahosts \"$domain\" | awk '{print $1}' | sort -u); do\n            ip rule add to \"$ip\" table \"$VIDEOPROVIDER_TABLE\" 2>\/dev\/null || true\n        done\n    done\n\n    # Apply fwmark\n    ip rule add fwmark $TABLE_ID table \"$VIDEOPROVIDER_TABLE\" 2>\/dev\/null || true\n\n    sleep \"$INTERVAL\"\ndone\n\n<strong><strong>videoprovider<\/strong>-down.sh\n<\/strong>#!\/bin\/bash\n# Stop VideoProvider VPN routing and refresher loop\n\nVIDEOPROVIDER_TABLE=\"videoprovidervpn\"\nKODI_CGROUP=\"\/system.slice\/kodi.service\"\nTABLE_ID=10\nLOCKFILE=\"\/run\/videoprovider-refresh.lock\"\n\n# --- Kill refresher if running ---\nif &#91; -e \"$LOCKFILE\" ]; then\n    PID=$(cat \"$LOCKFILE\")\n    if kill -0 \"$PID\" 2>\/dev\/null; then\n        kill \"$PID\"\n        sleep 1\n        kill -9 \"$PID\" 2>\/dev\/null || true\n    fi\n    rm -f \"$LOCKFILE\"\nfi\n\n# --- Flush ip rules ---\nfor prio in $(ip rule show | awk -v t=\"$VIDEOPROVIDER_TABLE\" '$0 ~ t {print $1}'); do\n    ip rule del pref \"$prio\"\ndone\n\n# --- Flush routing table ---\nip route flush table \"$VIDEOPROVIDER_TABLE\" 2>\/dev\/null\n\n# --- Remove iptables rule ---\niptables -t mangle -D OUTPUT -m cgroup --path \"\/sys\/fs\/cgroup$KODI_CGROUP\" -j MARK --set-mark $TABLE_ID 2>\/dev\/null || true\n \n<\/code><\/pre>\n","protected":false},"excerpt":{"rendered":"<p>Recently I stumbled upon a curious case where traffic for a big video content provider should be routed via a VPN. The requirement was that only traffic from a certain executable, for specific domains (used by that content provider) should &hellip; <a href=\"https:\/\/webbservern.se\/~jonas\/wordpress\/?p=2999\">Continue reading <span class=\"meta-nav\">&rarr;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[6,16],"tags":[847,846,429],"class_list":["post-2999","post","type-post","status-publish","format-standard","hentry","category-datorer","category-linux","tag-cgroup","tag-kodi","tag-openvpn"],"_links":{"self":[{"href":"https:\/\/webbservern.se\/~jonas\/wordpress\/index.php?rest_route=\/wp\/v2\/posts\/2999","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/webbservern.se\/~jonas\/wordpress\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/webbservern.se\/~jonas\/wordpress\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/webbservern.se\/~jonas\/wordpress\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/webbservern.se\/~jonas\/wordpress\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=2999"}],"version-history":[{"count":2,"href":"https:\/\/webbservern.se\/~jonas\/wordpress\/index.php?rest_route=\/wp\/v2\/posts\/2999\/revisions"}],"predecessor-version":[{"id":3001,"href":"https:\/\/webbservern.se\/~jonas\/wordpress\/index.php?rest_route=\/wp\/v2\/posts\/2999\/revisions\/3001"}],"wp:attachment":[{"href":"https:\/\/webbservern.se\/~jonas\/wordpress\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=2999"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/webbservern.se\/~jonas\/wordpress\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=2999"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/webbservern.se\/~jonas\/wordpress\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=2999"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}