Skip to content

Instantly share code, notes, and snippets.

@kylemanna
Last active May 8, 2023 05:37
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kylemanna/d43db65193467efcdea172907ca8360a to your computer and use it in GitHub Desktop.
Save kylemanna/d43db65193467efcdea172907ca8360a to your computer and use it in GitHub Desktop.
Tailscale Per Host Default Router Disabler
#!/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()
[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