t-eth-er 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ *.egg-info
2
+ dist
3
+ __pycache__
4
+ token
5
+ sudoers
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: t-eth-er
3
+ Version: 1.2.0
4
+ Summary: Ethernet tethering for Linux - NAT a single device through an interface
5
+ License: MIT
6
+ Keywords: ethernet,iptables,nat,networking,tethering
7
+ Requires-Python: >=3.8
8
+ Description-Content-Type: text/markdown
9
+
10
+ # tETHer - ethernet tethering
11
+ Tether one device to another over an ethernet interface on linux (rather than USB)
12
+
13
+ Tethering proxies one device through another devices internet connections. A common use case is to connect to a mobile phone over USB and then use its internet connection. tETHer does the same thing but via an ethernet connection such as an rj-45 cable.
14
+
15
+ This also allows forwarding
16
+
17
+ ## Motivation
18
+ I had a camera which only supported wired connection. I did not want to run a long wire to a router and wireless bridges were moderately expensive so I decided to get this working with an old raspberry pi. I don't really like "infrastructure" because I forget how it works. I prefer tools which minimise the amount of infrastructure so I coded this.
19
+
20
+ ## Alterantives and prior work
21
+ Use a wireless bridge. Hand code the networking yourself using wireless.
22
+
23
+ This is very similar to the idea of setting up an *access point*, but it has some additional features for port forwarding which are made easier if you are providing access or a single device.
24
+
25
+ ## Installation
26
+ tETHer requires `nmap` `dnsmasq` and Linux. On a linux machine install them: `sudo apt install nmap dnsmasq` pipx.
27
+
28
+ You can then install with pipx: `pipx install tETHer`
29
+
30
+ ## Usage
31
+ Set up dhcp and forwarding on eth0 so that if you plug in a device which uses DHCP it will connect to you and then via you to the rest of the network - including the internet.
32
+
33
+ `eth-tether eth0`
34
+
35
+ If you want to forward ports you can capture all the ports that the tethering machine has
36
+
37
+ `eth-tether eth0 --scan ports`
38
+
39
+ Then on subsequent files you can use the generated ports file
40
+
41
+ `eth-tether eth0 --ports ports`
42
+
43
+ To avoid requiring too many permissions, tETHer uses sudo to run privileged commands. You can allow your use to only run these commands or you can run the entire commadn with sudo. To get a sudoers file alowing these commands run `eth-tether eth0 --sudoers`
44
+
45
+
@@ -0,0 +1,36 @@
1
+ # tETHer - ethernet tethering
2
+ Tether one device to another over an ethernet interface on linux (rather than USB)
3
+
4
+ Tethering proxies one device through another devices internet connections. A common use case is to connect to a mobile phone over USB and then use its internet connection. tETHer does the same thing but via an ethernet connection such as an rj-45 cable.
5
+
6
+ This also allows forwarding
7
+
8
+ ## Motivation
9
+ I had a camera which only supported wired connection. I did not want to run a long wire to a router and wireless bridges were moderately expensive so I decided to get this working with an old raspberry pi. I don't really like "infrastructure" because I forget how it works. I prefer tools which minimise the amount of infrastructure so I coded this.
10
+
11
+ ## Alterantives and prior work
12
+ Use a wireless bridge. Hand code the networking yourself using wireless.
13
+
14
+ This is very similar to the idea of setting up an *access point*, but it has some additional features for port forwarding which are made easier if you are providing access or a single device.
15
+
16
+ ## Installation
17
+ tETHer requires `nmap` `dnsmasq` and Linux. On a linux machine install them: `sudo apt install nmap dnsmasq` pipx.
18
+
19
+ You can then install with pipx: `pipx install tETHer`
20
+
21
+ ## Usage
22
+ Set up dhcp and forwarding on eth0 so that if you plug in a device which uses DHCP it will connect to you and then via you to the rest of the network - including the internet.
23
+
24
+ `eth-tether eth0`
25
+
26
+ If you want to forward ports you can capture all the ports that the tethering machine has
27
+
28
+ `eth-tether eth0 --scan ports`
29
+
30
+ Then on subsequent files you can use the generated ports file
31
+
32
+ `eth-tether eth0 --ports ports`
33
+
34
+ To avoid requiring too many permissions, tETHer uses sudo to run privileged commands. You can allow your use to only run these commands or you can run the entire commadn with sudo. To get a sudoers file alowing these commands run `eth-tether eth0 --sudoers`
35
+
36
+
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = [ "hatchling",]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "t-eth-er"
7
+ version = "1.2.0"
8
+ description = "Ethernet tethering for Linux - NAT a single device through an interface"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ keywords = [ "networking", "nat", "tethering", "ethernet", "iptables",]
12
+
13
+ [project.license]
14
+ text = "MIT"
15
+
16
+ [project.scripts]
17
+ eth-tether = "tETHer.main:main"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = [ "tETHer",]
t_eth_er-1.2.0/release ADDED
@@ -0,0 +1,47 @@
1
+ #!/bin/bash
2
+ set -o errexit
3
+ set -o nounset
4
+ set -o pipefail
5
+
6
+ # SSH ControlMaster setup
7
+ CONTROL_PATH="/tmp/ssh-release-%r@%h:%p"
8
+
9
+ echo "🔐 Establishing SSH connection to GitHub..."
10
+ ssh -o ControlMaster=yes -o ControlPath="$CONTROL_PATH" -o ControlPersist=300 -fN git@github.com
11
+
12
+ # Use ControlMaster for git
13
+ export GIT_SSH_COMMAND="ssh -o ControlPath=$CONTROL_PATH"
14
+
15
+ # Fail if there are any modified files
16
+ if ! git diff-index --quiet HEAD --; then
17
+ echo "Error: You have uncommitted changes to tracked files"
18
+ git status --short
19
+ ssh -o ControlPath="$CONTROL_PATH" -O exit git@github.com 2>/dev/null || true
20
+ exit 1
21
+ fi
22
+
23
+ git push
24
+
25
+ if [ -f pyproject.toml ]; then
26
+ VERSION=$(cat pyproject.toml | toml2json | jq -rc '.project.version')
27
+ else
28
+ VERSION=$(python3 setup.py --version)
29
+ fi
30
+
31
+ git tag "$VERSION"
32
+ git push --tags
33
+
34
+ rm -rf dist *.egg-info build
35
+
36
+ if [ -f pyproject.toml ]; then
37
+ python3 -m build
38
+ else
39
+ python3 setup.py sdist
40
+ fi
41
+
42
+ twine upload -p $(cat token) dist/*
43
+
44
+ # Close ControlMaster
45
+ ssh -o ControlPath="$CONTROL_PATH" -O exit git@github.com 2>/dev/null || true
46
+
47
+ echo "✅ Released version $VERSION"
File without changes
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ tETHer - Ethernet tethering for Linux.
4
+
5
+ NAT a single device through an interface with optional port forwarding.
6
+
7
+ Usage:
8
+ eth-tether <interface> --via <upstream> [--name <label>]
9
+ [--ports <file>] [--scan <file>] [--scan-delay <seconds>]
10
+ eth-tether --sudoers
11
+
12
+ Examples:
13
+ eth-tether eth0 --via wlan0
14
+ eth-tether eth0 --via wlan0 --name camera --scan ~/camera-ports.txt
15
+ eth-tether eth0 --via wlan0 --name camera --ports ~/camera-ports.txt
16
+ eth-tether --sudoers | sudo tee /etc/sudoers.d/eth-tether
17
+
18
+ Port file format (one per line):
19
+ 80:80
20
+ 554:554
21
+ # comments are ignored
22
+ """
23
+
24
+ import argparse
25
+ import atexit
26
+ import os
27
+ import signal
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ import time
32
+
33
+ INTERNAL_IP = "10.42.0.1"
34
+ DHCP_RANGE_START = "10.42.0.100"
35
+ DHCP_RANGE_END = "10.42.0.200"
36
+ LEASE_FILE = "/tmp/tether-leases"
37
+ SCAN_DELAY_DEFAULT = 10
38
+
39
+ cleanup_actions = []
40
+
41
+ PRIVILEGED_COMMANDS = ["ip", "iptables", "sysctl", "dnsmasq", "pkill", "nmap", "resolvectl"]
42
+
43
+
44
+ def get_default_route_iface():
45
+ """Return the interface used by the default route, or None."""
46
+ result = run_capture("ip route show default")
47
+ for line in result.splitlines():
48
+ parts = line.split()
49
+ if 'dev' in parts:
50
+ return parts[parts.index('dev') + 1]
51
+ return None
52
+
53
+
54
+ def print_sudoers(iface, via):
55
+ user = os.environ.get("USER", "YOUR_USER")
56
+
57
+ def which(cmd):
58
+ return run_capture(f"which {cmd}") or f"/usr/bin/{cmd}"
59
+
60
+ ip = which("ip")
61
+ ipt = which("iptables")
62
+ sysctl = which("sysctl")
63
+ dnsmasq = which("dnsmasq")
64
+ pkill = which("pkill")
65
+ nmap = which("nmap")
66
+ resolvectl = which("resolvectl")
67
+
68
+ cmds = [
69
+ f"{ip} addr flush dev {iface}",
70
+ f"{ip} addr add {INTERNAL_IP}/24 dev {iface}",
71
+ f"{ip} link set {iface} up",
72
+ f"{ip} link set {iface} down",
73
+ f"{sysctl} -w net.ipv4.ip_forward=1",
74
+ f"{ipt} -t nat -A POSTROUTING -o {via} -j MASQUERADE",
75
+ f"{ipt} -t nat -D POSTROUTING -o {via} -j MASQUERADE",
76
+ f"{ipt} -A FORWARD -i {iface} -o {via} -j ACCEPT",
77
+ f"{ipt} -D FORWARD -i {iface} -o {via} -j ACCEPT",
78
+ r"{ipt} -A FORWARD -i {via} -o {iface} -m state --state RELATED\,ESTABLISHED -j ACCEPT".format(ipt=ipt, via=via, iface=iface),
79
+ r"{ipt} -D FORWARD -i {via} -o {iface} -m state --state RELATED\,ESTABLISHED -j ACCEPT".format(ipt=ipt, via=via, iface=iface),
80
+ f"{pkill} -F /tmp/tether-dnsmasq-{iface}.pid",
81
+ f"{pkill} -HUP -F /tmp/tether-dnsmasq-{iface}.pid",
82
+ r"{dnsmasq} ^--conf-file=/tmp/[^ ]+\.conf$".format(dnsmasq=dnsmasq),
83
+ r"{ipt} ^-t nat -A PREROUTING -i {via} -p tcp --dport [0-9]+ -j DNAT --to-destination 10\.42\.[0-9]+\.[0-9]+:[0-9]+$".format(ipt=ipt, via=via),
84
+ r"{ipt} ^-t nat -D PREROUTING -i {via} -p tcp --dport [0-9]+ -j DNAT --to-destination 10\.42\.[0-9]+\.[0-9]+:[0-9]+$".format(ipt=ipt, via=via),
85
+ r"{ipt} ^-A FORWARD -p tcp -d 10\.42\.[0-9]+\.[0-9]+ --dport [0-9]+ -j ACCEPT$".format(ipt=ipt),
86
+ r"{ipt} ^-D FORWARD -p tcp -d 10\.42\.[0-9]+\.[0-9]+ --dport [0-9]+ -j ACCEPT$".format(ipt=ipt),
87
+ r"{nmap} ^-p- --open -T4 --host-timeout 60s -oG - 10\.42\.[0-9]+\.[0-9]+$".format(nmap=nmap),
88
+ f"{resolvectl} dns {iface} 10.42.0.1",
89
+ f"{resolvectl} domain {iface} tether",
90
+ f"{resolvectl} revert {iface}",
91
+ f"/bin/rm -f {LEASE_FILE}",
92
+ f"/bin/rm -f /tmp/tether-dnsmasq-{iface}.pid",
93
+ ]
94
+
95
+ print("# Generated by eth-tether --sudoers")
96
+ print(f"# Install with: eth-tether --sudoers {iface} {via} | sudo tee /etc/sudoers.d/eth-tether")
97
+ print()
98
+ for cmd in cmds:
99
+ print(f"{user} ALL=(ALL) NOPASSWD: {cmd}")
100
+
101
+
102
+ debug_mode = False
103
+ has_resolvectl = None
104
+
105
+ def check_resolvectl():
106
+ global has_resolvectl
107
+ if has_resolvectl is None:
108
+ has_resolvectl = bool(run_capture("which resolvectl"))
109
+ if not has_resolvectl:
110
+ print("[tETHer] resolvectl not found, skipping DNS registration")
111
+ return has_resolvectl
112
+
113
+
114
+ def run(cmd, check=True):
115
+ subprocess.run(cmd, shell=True, check=check)
116
+
117
+
118
+ def run_capture(cmd):
119
+ result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
120
+ return result.stdout.strip()
121
+
122
+
123
+ def sudo(cmd, check=True):
124
+ full = f"sudo {cmd}"
125
+ if debug_mode:
126
+ print(f"[tETHer] $ {full}")
127
+ subprocess.run(full, shell=True, check=check)
128
+
129
+
130
+ def sudo_capture(cmd):
131
+ return run_capture(f"sudo {cmd}")
132
+
133
+
134
+ def cleanup():
135
+ print("\n[tETHer] Cleaning up...")
136
+ for action in reversed(cleanup_actions):
137
+ try:
138
+ action()
139
+ except Exception as e:
140
+ print(f"[tETHer] Warning during cleanup: {e}")
141
+ print("[tETHer] Done.")
142
+
143
+
144
+ def setup_interface(iface):
145
+ print(f"[tETHer] Bringing up {iface} with IP {INTERNAL_IP}...")
146
+ sudo(f"ip addr flush dev {iface}")
147
+ sudo(f"ip addr add {INTERNAL_IP}/24 dev {iface}")
148
+ sudo(f"ip link set {iface} up")
149
+ cleanup_actions.append(lambda: sudo(f"ip addr flush dev {iface}", check=False))
150
+ cleanup_actions.append(lambda: sudo(f"ip link set {iface} down", check=False))
151
+
152
+
153
+ def setup_nat(iface, via):
154
+ print(f"[tETHer] Setting up NAT from {iface} via {via}...")
155
+ sudo("sysctl -w net.ipv4.ip_forward=1")
156
+ sudo(f"iptables -t nat -A POSTROUTING -o {via} -j MASQUERADE")
157
+ sudo(f"iptables -A FORWARD -i {iface} -o {via} -j ACCEPT")
158
+ sudo(f"iptables -A FORWARD -i {via} -o {iface} -m state --state RELATED,ESTABLISHED -j ACCEPT")
159
+ cleanup_actions.append(lambda: sudo(f"iptables -t nat -D POSTROUTING -o {via} -j MASQUERADE", check=False))
160
+ cleanup_actions.append(lambda: sudo(f"iptables -D FORWARD -i {iface} -o {via} -j ACCEPT", check=False))
161
+ cleanup_actions.append(lambda: sudo(f"iptables -D FORWARD -i {via} -o {iface} -m state --state RELATED,ESTABLISHED -j ACCEPT", check=False))
162
+
163
+
164
+ def kill_dnsmasq(iface):
165
+ """Kill tETHer dnsmasq for this interface using pidfile."""
166
+ pidfile = f"/tmp/tether-dnsmasq-{iface}.pid"
167
+ if not os.path.exists(pidfile):
168
+ return
169
+ sudo(f"pkill -F {pidfile}", check=False)
170
+ # Wait for process to actually exit
171
+ for _ in range(20):
172
+ time.sleep(0.2)
173
+ result = subprocess.run(f"pkill -0 -F {pidfile}", shell=True, capture_output=True)
174
+ if result.returncode != 0:
175
+ break
176
+
177
+
178
+ def start_dnsmasq(iface, conf_path=None):
179
+ print(f"[tETHer] Starting dnsmasq on {iface}...")
180
+ pidfile = f"/tmp/tether-dnsmasq-{iface}.pid"
181
+
182
+ if conf_path is None:
183
+ conf = tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False)
184
+ conf.write(f"""interface={iface}
185
+ bind-interfaces
186
+ dhcp-range={DHCP_RANGE_START},{DHCP_RANGE_END},12h
187
+ dhcp-leasefile={LEASE_FILE}
188
+ pid-file={pidfile}
189
+ log-dhcp
190
+ log-queries
191
+ filter-AAAA
192
+ """)
193
+ conf.flush()
194
+ conf.close()
195
+ conf_path = conf.name
196
+ cleanup_actions.append(lambda: os.unlink(conf_path) if os.path.exists(conf_path) else None)
197
+ cleanup_actions.append(lambda: os.unlink(LEASE_FILE) if os.path.exists(LEASE_FILE) else None)
198
+
199
+ if debug_mode:
200
+ print(f"[tETHer] dnsmasq conf ({conf_path}):")
201
+ with open(conf_path) as f:
202
+ for line in f:
203
+ print(f" {line}", end="")
204
+
205
+ kill_dnsmasq(iface)
206
+ sudo(f"dnsmasq --conf-file={conf_path}")
207
+
208
+ for _ in range(20):
209
+ if os.path.exists(pidfile):
210
+ break
211
+ time.sleep(0.2)
212
+ else:
213
+ print(f"[tETHer] Warning: dnsmasq pidfile not found after startup")
214
+
215
+ cleanup_actions.append(lambda: kill_dnsmasq(iface))
216
+ return conf_path
217
+
218
+
219
+ def parse_leases():
220
+ leases = []
221
+ if not os.path.exists(LEASE_FILE):
222
+ return leases
223
+ with open(LEASE_FILE) as f:
224
+ for line in f:
225
+ parts = line.strip().split()
226
+ if len(parts) >= 3:
227
+ leases.append({'mac': parts[1], 'ip': parts[2]})
228
+ return leases
229
+
230
+
231
+ def wait_for_device(name):
232
+ print(f"[tETHer] Waiting for '{name}' to connect...")
233
+ while True:
234
+ leases = parse_leases()
235
+ if leases:
236
+ lease = leases[0]
237
+ print(f"[tETHer] '{name}' connected: {lease['ip']} ({lease['mac']})")
238
+ return lease['ip'], lease['mac']
239
+ time.sleep(2)
240
+
241
+
242
+ def watch_for_rogues(expected_mac, name):
243
+ leases = parse_leases()
244
+ for lease in leases:
245
+ if lease['mac'] != expected_mac:
246
+ print(f"[tETHer] WARNING: Unexpected device connected: {lease['mac']} (expected '{name}' {expected_mac})")
247
+
248
+
249
+ def load_ports_file(path):
250
+ """Load port mappings from a file. Returns list of (ext_port, int_port) tuples."""
251
+ ports = []
252
+ with open(path) as f:
253
+ for line in f:
254
+ line = line.strip()
255
+ if not line or line.startswith('#'):
256
+ continue
257
+ if ':' not in line:
258
+ print(f"[tETHer] Warning: skipping invalid port line: {line!r}")
259
+ continue
260
+ ext, int_ = line.split(':', 1)
261
+ ports.append((ext.strip(), int_.strip()))
262
+ return ports
263
+
264
+
265
+ def save_ports_file(path, ports):
266
+ """Save port mappings to a file."""
267
+ with open(path, 'w') as f:
268
+ f.write("# tETHer port forwarding config\n")
269
+ f.write("# format: external_port:internal_port\n")
270
+ for ext, int_ in ports:
271
+ f.write(f"{ext}:{int_}\n")
272
+ print(f"[tETHer] Saved port config to {path}")
273
+
274
+
275
+ def scan_ports(device_ip, delay):
276
+ """Scan device for open TCP ports. Returns list of (port, port) tuples."""
277
+ if delay > 0:
278
+ print(f"[tETHer] Waiting {delay}s for services to come up...")
279
+ time.sleep(delay)
280
+
281
+ print(f"[tETHer] Scanning {device_ip} for open ports...")
282
+ result = sudo_capture(f"nmap -p- --open -T4 --host-timeout 60s -oG - {device_ip}")
283
+
284
+ ports = []
285
+ for line in result.splitlines():
286
+ if 'Ports:' in line:
287
+ parts = line.split('Ports:')[1].strip().split(',')
288
+ for part in parts:
289
+ part = part.strip()
290
+ if '/open/tcp' in part:
291
+ port = part.split('/')[0].strip()
292
+ ports.append((port, port))
293
+
294
+ if ports:
295
+ print(f"[tETHer] Found open ports: {', '.join(p[0] for p in ports)}")
296
+ else:
297
+ print(f"[tETHer] No open ports found on {device_ip}")
298
+
299
+ return ports
300
+
301
+
302
+ def register_name(iface, name, device_ip, dnsmasq_conf):
303
+ """Register a local DNS name for the device via dnsmasq + systemd-resolved."""
304
+ print(f"[tETHer] Registering '{name}' -> {device_ip}...")
305
+ with open(dnsmasq_conf, 'a') as f:
306
+ f.write(f"address=/{name}.tether/{device_ip}\n")
307
+ if debug_mode:
308
+ print(f"[tETHer] dnsmasq conf after name registration:")
309
+ with open(dnsmasq_conf) as f:
310
+ for line in f:
311
+ print(f" {line}", end="")
312
+ start_dnsmasq(iface, conf_path=dnsmasq_conf)
313
+ if check_resolvectl():
314
+ sudo(f"resolvectl dns {iface} 10.42.0.1", check=False)
315
+ sudo(f"resolvectl domain {iface} tether", check=False)
316
+ cleanup_actions.append(lambda: sudo(f"resolvectl revert {iface}", check=False))
317
+
318
+
319
+ def force_cleanup(iface, via):
320
+ """Best-effort cleanup of all tETHer state for a given interface."""
321
+ print(f"[tETHer] Forcing cleanup of {iface}...")
322
+ kill_dnsmasq(iface)
323
+ sudo(f"iptables -t nat -D POSTROUTING -o {via} -j MASQUERADE", check=False)
324
+ sudo(f"iptables -D FORWARD -i {iface} -o {via} -j ACCEPT", check=False)
325
+ sudo(f"iptables -D FORWARD -i {via} -o {iface} -m state --state RELATED,ESTABLISHED -j ACCEPT", check=False)
326
+ sudo(f"ip addr flush dev {iface}", check=False)
327
+ sudo(f"ip link set {iface} down", check=False)
328
+ if check_resolvectl():
329
+ sudo(f"resolvectl revert {iface}", check=False)
330
+ for tmp in [LEASE_FILE, f"/tmp/tether-dnsmasq-{iface}.pid"]:
331
+ sudo(f"/bin/rm -f {tmp}", check=False)
332
+ print(f"[tETHer] Cleanup done.")
333
+
334
+
335
+ def setup_port_forwards(via, device_ip, ports):
336
+ for ext_port, int_port in ports:
337
+ print(f"[tETHer] Port forward: {via}:{ext_port} -> {device_ip}:{int_port}")
338
+ sudo(f"iptables -t nat -A PREROUTING -i {via} -p tcp --dport {ext_port} -j DNAT --to-destination {device_ip}:{int_port}")
339
+ sudo(f"iptables -A FORWARD -p tcp -d {device_ip} --dport {int_port} -j ACCEPT")
340
+ cleanup_actions.append(lambda ep=ext_port, ip=device_ip, p=int_port: (
341
+ sudo(f"iptables -t nat -D PREROUTING -i {via} -p tcp --dport {ep} -j DNAT --to-destination {ip}:{p}", check=False),
342
+ sudo(f"iptables -D FORWARD -p tcp -d {ip} --dport {p} -j ACCEPT", check=False),
343
+ ))
344
+
345
+
346
+ def main():
347
+ parser = argparse.ArgumentParser(
348
+ description="NAT tether a single device through an ethernet interface.",
349
+ formatter_class=argparse.RawDescriptionHelpFormatter,
350
+ epilog=__doc__
351
+ )
352
+ parser.add_argument("interface", nargs="?", help="Internal interface (e.g. eth0)")
353
+ parser.add_argument("--via", help="Upstream interface (e.g. wlan0)")
354
+ parser.add_argument("--name", default=None, help="Label for the expected device (default: interface name)")
355
+ parser.add_argument("--ports", metavar="FILE", help="Load port forwards from file")
356
+ parser.add_argument("--scan", metavar="FILE",
357
+ help="Scan device for open ports and save to file (skips scan if file exists)")
358
+ parser.add_argument("--scan-delay", type=int, default=SCAN_DELAY_DEFAULT, metavar="SECONDS",
359
+ help=f"Seconds to wait before scanning (default: {SCAN_DELAY_DEFAULT})")
360
+ parser.add_argument("--sudoers", action="store_true", help="Print sudoers rules and exit")
361
+ parser.add_argument("--cleanup", action="store_true", help="Force cleanup of tETHer state and exit")
362
+ parser.add_argument("--debug", action="store_true", help="Print commands as they are run")
363
+ args = parser.parse_args()
364
+
365
+ global debug_mode
366
+ debug_mode = args.debug
367
+
368
+ if not args.interface:
369
+ parser.error("interface is required")
370
+
371
+ if not args.name:
372
+ args.name = args.interface
373
+
374
+ if not args.via:
375
+ args.via = get_default_route_iface()
376
+ if not args.via:
377
+ parser.error("could not detect default route interface, please specify --via")
378
+ if not args.sudoers:
379
+ print(f"[tETHer] Using default route interface: {args.via}")
380
+
381
+ if args.sudoers:
382
+ print_sudoers(args.interface, args.via)
383
+ return
384
+
385
+ if args.cleanup:
386
+ force_cleanup(args.interface, args.via)
387
+ return
388
+
389
+ if args.ports and args.scan:
390
+ parser.error("--ports and --scan are mutually exclusive")
391
+
392
+ atexit.register(cleanup)
393
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
394
+ signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
395
+
396
+ setup_interface(args.interface)
397
+ setup_nat(args.interface, args.via)
398
+ dnsmasq_conf = start_dnsmasq(args.interface)
399
+
400
+ device_ip, device_mac = wait_for_device(args.name)
401
+ register_name(args.interface, args.name, device_ip, dnsmasq_conf)
402
+
403
+ ports = []
404
+
405
+ if args.ports:
406
+ print(f"[tETHer] Loading ports from {args.ports}...")
407
+ ports = load_ports_file(args.ports)
408
+
409
+ elif args.scan:
410
+ scan_file = os.path.expanduser(args.scan)
411
+ if os.path.exists(scan_file):
412
+ print(f"[tETHer] Loading existing scan from {scan_file}...")
413
+ ports = load_ports_file(scan_file)
414
+ else:
415
+ ports = scan_ports(device_ip, args.scan_delay)
416
+ if ports:
417
+ save_ports_file(scan_file, ports)
418
+
419
+ if ports:
420
+ setup_port_forwards(args.via, device_ip, ports)
421
+ print(f"[tETHer] Ready. '{args.name}' reachable on {args.via} via {len(ports)} port(s).")
422
+ else:
423
+ print(f"[tETHer] Ready. No port forwards configured.")
424
+
425
+ print("[tETHer] Running. Press Ctrl+C to stop and clean up.")
426
+ while True:
427
+ watch_for_rogues(device_mac, args.name)
428
+ time.sleep(5)
429
+
430
+
431
+ if __name__ == "__main__":
432
+ main()