Last active
May 8, 2023 05:37
-
-
Save kylemanna/d43db65193467efcdea172907ca8360a to your computer and use it in GitHub Desktop.
Tailscale Per Host Default Router Disabler
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" Tailscale Per Host Default Router Disabler | |
Tailscale allows a single tailnet wide "Override local DNS" setting that works | |
great for mobile and random cloud machines I have. However, my more elaborate | |
setups require the ability to turn this off and use local DNS as the default | |
route. | |
Unfortuantely with Tailscale as of v1.40.0 there's only two options: | |
1. Tailnet wide "local DNS override" (Turns out tailscale's internal resolver | |
is great DoH client actually with NextDNS). | |
2. Per Tailscale client `--accept-dns=false` option to fully disable the | |
default route and the MagicDNS routes. | |
This HACK listens via D-Bus for Tailscale to configure itself as a default | |
route and then immediately command systemd-resolved to set that to false. | |
The result: you get the secondary tailscale DNS benefits (split dns, | |
Magic DNS, etc) without forcing all your traffic this way. | |
To test, enable the default route manually then inspect | |
$ sudo resolvectl default-route tailscale0 true | |
$ resolvectl status tailscale0 | |
Usage via command line: | |
sudo tailscale-default-unroute.py | |
Usage via systemd: | |
1. Install, enable, and run a service file before tailscaled.service starts. | |
""" | |
import subprocess | |
import json | |
import socket | |
def is_tailscale_interface(ifindex: int): | |
name = socket.if_indextoname(ifindex) | |
return name.startswith('tailscale') if name else None | |
def handle_set_link_default_route(d: dict): | |
# Expected argument: | |
# {"type":"method_call","endian":"l","flags":0,"version":1,"cookie":2,"timestamp-realtime":1683503555104733,"sender":":1.547008","destination":"org.freedesktop.resolve1","path":"/org/freedesktop/resolve1","interface":"org.freedesktop.resolve1.Manager","member":"SetLinkDefaultRoute","payload":{"type":"ib","data":[106,true]}} | |
ifindex, default_route = d['payload']['data'] | |
print(f"SetLinkDefaultRoute method called with arguments: interface_index={ifindex}, default_route={default_route}") | |
if default_route and is_tailscale_interface(ifindex): | |
# Call SetLinkDefaultRoute with False argument | |
subprocess.check_call(['busctl', 'call', 'org.freedesktop.resolve1', | |
'/org/freedesktop/resolve1', | |
'org.freedesktop.resolve1.Manager', | |
'SetLinkDefaultRoute', | |
'ib', str(ifindex), 'false']) | |
print(f"Disabled default route for interface {ifindex}") | |
def handle_set_link_domains(d: dict): | |
# Expected argument: | |
# {"type":"method_call","endian":"l","flags":0,"version":1,"cookie":2,"timestamp-realtime":1683522394899032,"sender":":1.739","destination":"org.freedesktop.resolve1","path":"/org/freedesktop/resolve1","interface":"org.freedesktop.resolve1.Manager","member":"SetLinkDomains","payload":{"type":"ia(sb)","data":[8,[["something.ts.net",false]]]}} | |
ifindex, domains = d['payload']['data'] | |
print(f"SetLinkDomains method called with arguments: interface_index={ifindex}, domains={domains}") | |
if not is_tailscale_interface(ifindex): | |
# Nothing to do here | |
return | |
domains_clean = [] | |
update = False | |
for i in domains: | |
if i[0] == '.' and i[1] == True: | |
print('Found default route search domain, dropping'); | |
update = True | |
else: | |
domains_clean.append(i) | |
if update: | |
# Call SetLinkDomains with cleaned domains | |
cmd = ['busctl', 'call', 'org.freedesktop.resolve1', | |
'/org/freedesktop/resolve1', | |
'org.freedesktop.resolve1.Manager', | |
'SetLinkDomains', | |
'ia(sb)', str(ifindex), str(len(domains_clean))] | |
for i in domains_clean: | |
cmd.append(i[0]) | |
cmd.append(str(i[1]).lower()) | |
#print(f"Debug: {cmd=}") | |
subprocess.check_call(cmd) | |
def main(): | |
# Match doesn't seem to filter to the exact method_call=SetLinkDefaultRoute | |
command = ["busctl", "monitor", "-q", "org.freedesktop.resolve1", "--match", | |
"type='method_call',sender='org.freedesktop.resolve1',path='/org/freedesktop/resolve1',interface='org.freedesktop.resolve1.Manager',member='SetLinkDefaultRoute'", | |
"--json=short"] | |
# Start the dbus-monitor subprocess and capture the output | |
process = subprocess.Popen(command, stdout=subprocess.PIPE) | |
if not process.stdout: | |
raise RuntimeError('stdout is broken') | |
# Continuously read the output and extract any SetLinkDefaultRoute method call arguments | |
for line in iter(process.stdout.readline, b''): | |
d = json.loads(line) | |
member = d.get('member') | |
if member == 'SetLinkDefaultRoute': | |
handle_set_link_default_route(d) | |
elif member == 'SetLinkDomains': | |
handle_set_link_domains(d) | |
if __name__ == '__main__': | |
main() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[Unit] | |
Description=Tailscale Per Host Default Router Disabler | |
After=systemd-resolved.service | |
Before=tailscaled.service | |
[Service] | |
Type=simple | |
ExecStart=/usr/bin/python3 -u /usr/local/bin/tailscale-default-unroute.py | |
SyslogIdentifier=%N | |
Restart=on-failure | |
ProtectSystem=strict | |
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW | |
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW | |
[Install] | |
WantedBy=multi-user.target |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment