diff --git a/README.md b/README.md index 191d8ec..61f7bb1 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,25 @@ What is left is the new address in Settings → Sync on every PC, in every synced vault. For a script, skip the questions: `… passman_setup.py setup --host 0.0.0.0 ---port 9443`. +--port 9443`, or `… setup --keep --port 9443` to change only the port. + +## Letting in a PC from another network + +The firewall rule covers the networks this machine is on. A PC elsewhere — +behind a router, on a VPN the server is not part of — needs its own rule. +The setup's third question asks for those addresses, or give them directly: + +```bash +sudo python3 /opt/passman/server/passman_setup.py setup --keep --allow-from 172.17.17.2 +``` + +Name one address or a network (`10.20.0.0/24`); repeat `--allow-from` or +separate them with commas for more, and `--allow-from none` removes them. +They are saved in `sync.env` as `PASSMAN_ALLOW_FROM`, so a later port change +moves their rules too. Only the firewall changes: the service is not +restarted. A rule you had already typed by hand for the same address and +port is taken over (ufw skips the duplicate); one for another port stays +until you delete it: `sudo ufw status numbered`, then `sudo ufw delete N`. ## If the token leaks diff --git a/server/passman_setup.py b/server/passman_setup.py index 8f78572..5e637fb 100644 --- a/server/passman_setup.py +++ b/server/passman_setup.py @@ -12,7 +12,9 @@ `setup` asks which address to listen on (listing this machine's networks) and which port, writes them to sync.env, creates the token if there is none -yet, and opens the port in the firewall for the networks it serves: +yet, and opens the port in the firewall for the networks it serves -- plus +any other addresses you name, such as a PC on a network this machine is not +on (`--allow-from 172.17.17.2`): - Linux: sync.env is /etc/passman/sync.env, which the systemd unit loads. The firewall rule is added when ufw is on; ufw is never switched on from @@ -57,6 +59,12 @@ ALL_NETWORKS = "0.0.0.0" LOCALHOST = "127.0.0.1" +#: sync.env key for the extra addresses let through the firewall. Only the +#: setup reads it; the server ignores it. +ALLOW_KEY = "PASSMAN_ALLOW_FROM" +#: What clears the list, typed at the question or given to --allow-from. +NONE_WORDS = ("-", "none") + def default_platform(): return "windows" if os.name == "nt" else "linux" @@ -163,11 +171,45 @@ def read_env(path): return values -def render_env(host, port): - return ("# PassMan sync server -- written by passman_setup.py.\n" +def render_env(host, port, allow=()): + text = ("# PassMan sync server -- written by passman_setup.py.\n" "# Change it by running the setup again.\n" f"PASSMAN_HOST={host}\n" f"PASSMAN_PORT={port}\n") + if allow: + text += (f"# Also let through the firewall (the server ignores " + f"this).\n{ALLOW_KEY}={','.join(allow)}\n") + return text + + +def check_allow(text): + """ + Extra addresses to let in, from "172.17.17.2, 10.20.0.0/24": a list of + addresses and networks, or a message. One address stays one address + (no /32), so it matches a rule typed by hand. Everyone (/0) is refused: + that is what the firewall is there to stop. + """ + text = (text or "").strip() + if text.lower() in NONE_WORDS or not text: + return [], None + found = [] + for part in text.replace(" ", ",").split(","): + if not part: + continue + try: + if "/" in part: + net = ipaddress.IPv4Network(part, strict=False) + item = str(net.network_address if net.prefixlen == 32 + else net) + else: + item = str(ipaddress.IPv4Address(part)) + except ValueError: + return None, f"{part!r} is not an IPv4 address or network." + if item.endswith("/0"): + return None, f"{part} would let everyone in; name the networks." + if item not in found: + found.append(item) + return found, None def check_port(text): @@ -240,6 +282,22 @@ def choose_port(ask, say, current): say(problem) +def choose_allow(ask, say, current): + """Extra addresses; Enter keeps the current ones, - clears them.""" + say("Let in any other addresses? For a PC on a network this machine is " + "not on, e.g. 172.17.17.2 or 10.20.0.0/24; comma-separated, - for " + "none.") + shown = ",".join(current) or "none" + while True: + answer = ask(f"Also let in [{shown}]: ").strip() + if not answer: + return list(current) + allow, problem = check_allow(answer) + if allow is not None: + return allow + say(problem) + + def _own_file(path): """Give the file to the service user, where there is one (Linux).""" try: @@ -308,7 +366,12 @@ def ufw_rule(network, port): def firewall_linux(run, say, new_networks, port, old_networks, old_port): - """Allow the served networks through ufw -- but only if ufw is on.""" + """ + Allow the served networks and the extra addresses through ufw -- but + only if ufw is on. Rules the setup made for the old values go; rules + made by hand are left alone, unless they are for an address that the + setup was once given as an extra. + """ if not ufw_active(run): if new_networks: say("ufw is not active, so no firewall rule was added. To limit " @@ -382,12 +445,12 @@ def restart_task(run): return ok(result) -def firewall_windows(run, host, port, addresses): +def firewall_windows(run, host, port, addresses, allow=()): """ Replace PassMan's inbound rule and say what it allows. Every network means LocalSubnet -- the networks this PC is on, whichever they are - today -- and one network means just that range. This machine only needs - no rule at all. + today -- and one network means just that range; the extra addresses + are added to either. This machine only needs no rule at all. """ remove = (f"Remove-NetFirewallRule -Group {ps_quote(FIREWALL_GROUP)} " "-ErrorAction SilentlyContinue") @@ -398,12 +461,15 @@ def firewall_windows(run, host, port, addresses): remote, label = "LocalSubnet", "the networks this PC is on" else: remote = label = str(networks_for(host, addresses)[0][1]) + remotes = [remote, *allow] + if allow: + label += " and " + ", ".join(allow) result = run(powershell( f"{remove}; New-NetFirewallRule -DisplayName " f"{ps_quote(f'{TASK_NAME} (TCP {port})')} " f"-Group {ps_quote(FIREWALL_GROUP)} -Direction Inbound -Action Allow " f"-Protocol TCP -LocalPort {int(port)} -RemoteAddress " - f"{ps_quote(remote)} -Profile Any | Out-Null")) + f"{','.join(ps_quote(r) for r in remotes)} -Profile Any | Out-Null")) if ok(result): return f"Firewall: allowed {label} to TCP port {port}." return (f"Firewall: could not add the rule: " @@ -421,18 +487,22 @@ def windows_folders(run, config_dir): # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- -def next_steps(host, port, addresses): +def next_steps(host, port, addresses, allow=()): """What to type on the PCs.""" lines = [] targets = networks_for(host, addresses) if host == LOCALHOST: lines.append("Listening on this machine only: no PC can connect.") - elif host == ALL_NETWORKS and not targets: + return lines + if host == ALL_NETWORKS and not targets: lines.append(f"PCs: enter http://:{port} in " f"Settings -> Sync.") for address, network in targets: lines.append(f"PCs on {network}: enter http://{address}:{port} in " f"Settings -> Sync.") + if allow: + lines.append(f"Also let in: {', '.join(allow)} -- they use whichever " + f"of the addresses above reaches this machine.") return lines @@ -460,6 +530,19 @@ def cmd_setup(args, ask=input, say=print, run=_run): port = choose_port(ask, say, current.get("PASSMAN_PORT", str(DEFAULT_PORT))) + # The extra addresses: --allow-from sets them; with the other questions + # answered by flags they stay as saved; otherwise they are asked too. + old_allow, _ = check_allow(current.get(ALLOW_KEY)) + old_allow = old_allow or [] + if args.allow_from is not None: + allow, problem = check_allow(",".join(args.allow_from)) + if problem: + sys.exit(problem) + elif keep or args.host or args.port or host == LOCALHOST: + allow = old_allow + else: + allow = choose_allow(ask, say, old_allow) + changed = (current.get("PASSMAN_HOST"), current.get("PASSMAN_PORT")) \ != (host, str(port)) if changed and port_in_use(host if host != ALL_NETWORKS else "", port): @@ -467,7 +550,7 @@ def cmd_setup(args, ask=input, say=print, run=_run): f"not this server, pick another port.") os.makedirs(args.config_dir, exist_ok=True) - write_atomic(env_path, render_env(host, port).encode()) + write_atomic(env_path, render_env(host, port, allow).encode()) if not windows: os.chmod(env_path, 0o644) say(f"Wrote {env_path}: listening on {host}:{port}.") @@ -482,7 +565,7 @@ def cmd_setup(args, ask=input, say=print, run=_run): if windows: windows_folders(run, args.config_dir) - say(firewall_windows(run, host, port, addresses)) + say(firewall_windows(run, host, port, addresses, allow)) program, prefix = server_command() result = register_task(run, program, task_arguments( args.config_dir, host, port, prefix)) @@ -493,15 +576,19 @@ def cmd_setup(args, ask=input, say=print, run=_run): "starts." if restart_task(run) else f"Created the task, but it did not start. Open Task Scheduler, " f"find '{TASK_NAME}' and check its last result.") - elif changed: + else: old_host = current.get("PASSMAN_HOST") old_port = current.get("PASSMAN_PORT") - old_networks = [n for _, n in networks_for(old_host, addresses)] \ - if old_host and old_port else [] - firewall_linux(run, say, [n for _, n in networks_for(host, addresses)], - port, old_networks, int(old_port or 0)) - say(restart_service(run)) - for line in next_steps(host, port, addresses): + old_sources = [*(n for _, n in networks_for(old_host, addresses)), + *old_allow] if old_host and old_port else [] + new_sources = [] if host == LOCALHOST else \ + [*(n for _, n in networks_for(host, addresses)), *allow] + if changed or allow != old_allow: + firewall_linux(run, say, new_sources, port, old_sources, + int(old_port or 0)) + if changed: + say(restart_service(run)) + for line in next_steps(host, port, addresses, allow): say(line) @@ -552,7 +639,7 @@ def cmd_remove(args, ask=input, say=print, run=_run): #: What the menu offers, in order: (label, command). -MENU = (("Change network or port", "setup"), +MENU = (("Change network, port or allowed addresses", "setup"), ("Replace the token", "new-token"), ("Show the token", "show-token")) @@ -644,6 +731,11 @@ def parse_args(argv): setup.add_argument("--keep", action="store_true", help="reuse the saved address and port without " "asking (an upgrade)") + setup.add_argument("--allow-from", action="append", metavar="ADDRESS", + help="also let this address or network through the " + "firewall, e.g. a PC on another network; " + "repeat or comma-separate for more, 'none' " + "clears them; skips the question") new = sub.add_parser("new-token", help="replace the token") new.add_argument("--yes", action="store_true", help="do not ask for confirmation") @@ -652,7 +744,7 @@ def parse_args(argv): sub.add_parser("menu", help="choose one of the above from a list") # No command means setup, with every question asked. parser.set_defaults(command="setup", host=None, port=None, keep=False, - yes=False) + allow_from=None, yes=False) args = parser.parse_args(argv) args.default_dir = args.config_dir is None if args.config_dir is None: diff --git a/server/version.py b/server/version.py index ad16830..ad86f24 100644 --- a/server/version.py +++ b/server/version.py @@ -7,4 +7,4 @@ in PassMan-Server-Windows, which has its own version. """ -__version__ = "1.3.0" +__version__ = "1.4.0" diff --git a/tests/test_server_setup.py b/tests/test_server_setup.py index aadc099..8c064f9 100644 --- a/tests/test_server_setup.py +++ b/tests/test_server_setup.py @@ -166,7 +166,7 @@ def test_without_ip_there_is_simply_nothing_to_list(): def test_setup_writes_the_chosen_address_and_port(tmp_path): - out, _ = run_setup(tmp_path, answers=["2", "9443"]) + out, _ = run_setup(tmp_path, answers=["2", "9443", ""]) env = passman_setup.read_env(tmp_path / "sync.env") assert env == {"PASSMAN_HOST": "192.168.1.50", "PASSMAN_PORT": "9443"} assert "http://192.168.1.50:9443" in out @@ -174,7 +174,7 @@ def test_setup_writes_the_chosen_address_and_port(tmp_path): def test_the_defaults_are_every_network_and_8787(tmp_path): - out, _ = run_setup(tmp_path, answers=["", ""]) + out, _ = run_setup(tmp_path, answers=["", "", ""]) env = passman_setup.read_env(tmp_path / "sync.env") assert env == {"PASSMAN_HOST": "0.0.0.0", "PASSMAN_PORT": "8787"} # Every network: one address and one firewall rule per network. @@ -190,20 +190,20 @@ def test_what_setup_writes_is_what_the_server_reads(tmp_path): def test_running_setup_again_offers_the_current_values(tmp_path): run_setup(tmp_path, argv=["setup", "--host", "10.8.0.1", "--port", "9000"]) - run_setup(tmp_path, answers=["", ""]) + run_setup(tmp_path, answers=["", "", ""]) assert passman_setup.read_env(tmp_path / "sync.env") == \ {"PASSMAN_HOST": "10.8.0.1", "PASSMAN_PORT": "9000"} @pytest.mark.parametrize("bad", ["80", "70000", "web"]) def test_a_bad_port_is_asked_again(tmp_path, bad): - out, _ = run_setup(tmp_path, answers=["1", bad, "9000"]) + out, _ = run_setup(tmp_path, answers=["1", bad, "9000", ""]) assert passman_setup.read_env(tmp_path / "sync.env")["PASSMAN_PORT"] == \ "9000" def test_an_address_this_machine_does_not_have_is_refused(tmp_path): - out, _ = run_setup(tmp_path, answers=["192.168.7.7", "3", ""]) + out, _ = run_setup(tmp_path, answers=["192.168.7.7", "3", "", ""]) assert "does not have 192.168.7.7" in out assert passman_setup.read_env(tmp_path / "sync.env")["PASSMAN_HOST"] == \ "10.8.0.1" @@ -217,29 +217,29 @@ def test_a_bad_flag_stops_without_writing(tmp_path): def test_the_first_setup_creates_a_token_and_a_later_one_keeps_it(tmp_path): - run_setup(tmp_path, answers=["", ""]) + run_setup(tmp_path, answers=["", "", ""]) token = (tmp_path / "token").read_text().strip() assert len(token) >= 32 - run_setup(tmp_path, answers=["", "9000"]) + run_setup(tmp_path, answers=["", "9000", ""]) assert (tmp_path / "token").read_text().strip() == token def test_a_change_restarts_a_running_service(tmp_path): - run_setup(tmp_path, answers=["", ""]) - _, system = run_setup(tmp_path, answers=["", "9000"], + run_setup(tmp_path, answers=["", "", ""]) + _, system = run_setup(tmp_path, answers=["", "9000", ""], system=FakeSystem(active=True)) assert system.restarted def test_no_change_means_no_restart(tmp_path): - run_setup(tmp_path, answers=["", ""]) - _, system = run_setup(tmp_path, answers=["", ""], + run_setup(tmp_path, answers=["", "", ""]) + _, system = run_setup(tmp_path, answers=["", "", ""], system=FakeSystem(active=True)) assert not system.restarted def test_a_stopped_service_is_not_started(tmp_path): - _, system = run_setup(tmp_path, answers=["", "9000"], + _, system = run_setup(tmp_path, answers=["", "9000", ""], system=FakeSystem(active=False)) assert not system.restarted @@ -247,7 +247,7 @@ def test_a_stopped_service_is_not_started(tmp_path): # --- new-token ------------------------------------------------------------- def test_new_token_replaces_the_token_restarts_and_prints_it(tmp_path): - run_setup(tmp_path, answers=["", ""]) + run_setup(tmp_path, answers=["", "", ""]) old = (tmp_path / "token").read_text().strip() out, system = run_setup(tmp_path, answers=["y"], argv=["new-token"], system=FakeSystem(active=True)) @@ -258,7 +258,7 @@ def test_new_token_replaces_the_token_restarts_and_prints_it(tmp_path): def test_new_token_asks_first_and_no_keeps_the_old_one(tmp_path): - run_setup(tmp_path, answers=["", ""]) + run_setup(tmp_path, answers=["", "", ""]) old = (tmp_path / "token").read_text() out, system = run_setup(tmp_path, answers=[""], argv=["new-token"], system=FakeSystem(active=True)) @@ -299,7 +299,7 @@ def test_keep_reuses_the_saved_values_without_asking(tmp_path): def test_keep_with_nothing_saved_asks(tmp_path): - run_setup(tmp_path, answers=["", ""], argv=["setup", "--keep"]) + run_setup(tmp_path, answers=["", "", ""], argv=["setup", "--keep"]) assert passman_setup.read_env(tmp_path / "sync.env")["PASSMAN_PORT"] == \ "8787" @@ -320,7 +320,7 @@ def test_pause_shows_an_error_before_waiting(tmp_path): # --- Linux firewall -------------------------------------------------------- def test_with_ufw_on_setup_adds_the_rule_itself(tmp_path): - out, system = run_setup(tmp_path, answers=["2", "9443"], + out, system = run_setup(tmp_path, answers=["2", "9443", ""], system=FakeSystem(ufw=True)) assert system.ran("ufw allow from 192.168.1.0/24 to any port 9443 " "proto tcp") @@ -328,33 +328,167 @@ def test_with_ufw_on_setup_adds_the_rule_itself(tmp_path): def test_every_network_gets_one_rule_each(tmp_path): - _, system = run_setup(tmp_path, answers=["", ""], + _, system = run_setup(tmp_path, answers=["", "", ""], system=FakeSystem(ufw=True)) assert len(system.ran("ufw allow from")) == 2 def test_a_new_port_replaces_the_old_rule(tmp_path): - run_setup(tmp_path, answers=["2", ""], system=FakeSystem(ufw=True)) - _, system = run_setup(tmp_path, answers=["", "9443"], + run_setup(tmp_path, answers=["2", "", ""], system=FakeSystem(ufw=True)) + _, system = run_setup(tmp_path, answers=["", "9443", ""], system=FakeSystem(ufw=True)) assert system.ran("ufw delete allow from 192.168.1.0/24 to any port 8787") assert system.ran("ufw allow from 192.168.1.0/24 to any port 9443") def test_with_ufw_off_nothing_is_switched_on(tmp_path): - out, system = run_setup(tmp_path, answers=["", ""], + out, system = run_setup(tmp_path, answers=["", "", ""], system=FakeSystem(ufw=False)) assert not system.ran("ufw allow") and not system.ran("ufw enable") assert "sudo ufw allow from 192.168.1.0/24" in out def test_no_change_touches_no_firewall_rule(tmp_path): - run_setup(tmp_path, answers=["", ""], system=FakeSystem(ufw=True)) - _, system = run_setup(tmp_path, answers=["", ""], + run_setup(tmp_path, answers=["", "", ""], system=FakeSystem(ufw=True)) + _, system = run_setup(tmp_path, answers=["", "", ""], system=FakeSystem(ufw=True)) assert not system.ran("ufw allow") and not system.ran("ufw delete") +# --- extra addresses (--allow-from) ----------------------------------------- + +def allowed(tmp_path): + return passman_setup.read_env(tmp_path / "sync.env").get( + passman_setup.ALLOW_KEY) + + +@pytest.mark.parametrize("text, expected", [ + ("172.17.17.2", ["172.17.17.2"]), + (" 172.17.17.2 , 10.20.0.0/24 ", ["172.17.17.2", "10.20.0.0/24"]), + ("10.20.0.7/24", ["10.20.0.0/24"]), + ("172.17.17.2/32", ["172.17.17.2"]), # matches a rule typed by hand + ("1.2.3.4,1.2.3.4", ["1.2.3.4"]), + ("-", []), ("none", []), ("", []), +]) +def test_allow_from_accepts_addresses_and_networks(text, expected): + assert passman_setup.check_allow(text) == (expected, None) + + +@pytest.mark.parametrize("bad", ["0.0.0.0/0", "everyone", "fe80::1", + "10.0.0.300", "1.2.3.4/33"]) +def test_allow_from_refuses_everyone_and_nonsense(bad): + allow, problem = passman_setup.check_allow(bad) + assert allow is None and problem + + +def test_the_question_saves_extra_addresses(tmp_path): + out, _ = run_setup(tmp_path, answers=["", "", "172.17.17.2"]) + assert allowed(tmp_path) == "172.17.17.2" + assert "Also let in: 172.17.17.2" in out + # Host and port are still what the server reads. + env = passman_setup.read_env(tmp_path / "sync.env") + assert listen(env) == ("0.0.0.0", 8787) + + +def test_a_bad_extra_address_is_asked_again(tmp_path): + out, _ = run_setup(tmp_path, answers=["", "", "0.0.0.0/0", "10.20.0.0/24"]) + assert "everyone" in out + assert allowed(tmp_path) == "10.20.0.0/24" + + +def test_enter_keeps_the_extra_addresses_and_dash_clears_them(tmp_path): + run_setup(tmp_path, answers=["", "", "172.17.17.2"]) + run_setup(tmp_path, answers=["", "", ""]) + assert allowed(tmp_path) == "172.17.17.2" + run_setup(tmp_path, answers=["", "", "-"]) + assert allowed(tmp_path) is None + + +def test_with_ufw_on_the_extra_address_gets_its_rule(tmp_path): + out, system = run_setup(tmp_path, answers=["2", "9443", "172.17.17.2"], + system=FakeSystem(ufw=True)) + assert system.ran("ufw allow from 192.168.1.0/24 to any port 9443 " + "proto tcp") + assert system.ran("ufw allow from 172.17.17.2 to any port 9443 proto tcp") + + +def test_allow_from_alone_adds_the_rule_without_a_restart(tmp_path): + """The case it was made for: the service is set up and running, a PC on + another network needs in. Only the firewall changes.""" + run_setup(tmp_path, argv=["setup", "--host", "0.0.0.0", "--port", "9443"]) + out, system = run_setup( + tmp_path, argv=["setup", "--keep", "--allow-from", "172.17.17.2"], + system=FakeSystem(ufw=True, active=True)) + assert system.ran("ufw allow from 172.17.17.2 to any port 9443 proto tcp") + assert not system.ran("ufw delete") + assert not system.restarted + assert allowed(tmp_path) == "172.17.17.2" + + +def test_allow_from_repeats_and_none_clears(tmp_path): + run_setup(tmp_path, argv=["setup", "--host", "0.0.0.0", "--port", "9443", + "--allow-from", "172.17.17.2", + "--allow-from", "10.20.0.0/24"]) + assert allowed(tmp_path) == "172.17.17.2,10.20.0.0/24" + _, system = run_setup(tmp_path, argv=["setup", "--keep", "--allow-from", + "none"], + system=FakeSystem(ufw=True)) + assert allowed(tmp_path) is None + assert system.ran("ufw delete allow from 172.17.17.2 to any port 9443") + assert system.ran("ufw delete allow from 10.20.0.0/24 to any port 9443") + + +def test_a_bad_allow_from_stops_without_writing(tmp_path): + with pytest.raises(SystemExit) as stop: + run_setup(tmp_path, argv=["setup", "--host", "0.0.0.0", "--port", + "9443", "--allow-from", "0.0.0.0/0"]) + assert "everyone" in str(stop.value) + assert not (tmp_path / "sync.env").exists() + + +def test_flags_and_keep_leave_the_extra_addresses_as_they_are(tmp_path): + run_setup(tmp_path, answers=["", "", "172.17.17.2"]) + run_setup(tmp_path, argv=["setup", "--keep", "--port", "9000"]) + assert allowed(tmp_path) == "172.17.17.2" + run_setup(tmp_path, argv=["setup", "--keep"]) + assert allowed(tmp_path) == "172.17.17.2" + + +def test_a_new_port_moves_the_extra_rule_too(tmp_path): + run_setup(tmp_path, answers=["2", "", "172.17.17.2"], + system=FakeSystem(ufw=True)) + _, system = run_setup(tmp_path, argv=["setup", "--keep", "--port", "9443"], + system=FakeSystem(ufw=True)) + assert system.ran("ufw delete allow from 172.17.17.2 to any port 8787") + assert system.ran("ufw allow from 172.17.17.2 to any port 9443") + + +def test_this_machine_only_is_not_asked_and_opens_nothing(tmp_path): + _, system = run_setup(tmp_path, answers=["4", ""], + system=FakeSystem(ufw=True)) + assert not system.ran("ufw allow") + + +def test_with_ufw_off_the_extra_rule_is_printed(tmp_path): + out, system = run_setup(tmp_path, answers=["", "", "172.17.17.2"]) + assert "sudo ufw allow from 172.17.17.2 to any port 8787" in out + assert not system.ran("ufw allow") + + +def test_windows_extra_addresses_join_the_one_rule(tmp_path): + out, system = win_setup(tmp_path, answers=["", "9443", "172.17.17.2"]) + [rule] = system.ran("New-NetFirewallRule") + assert "-RemoteAddress 'LocalSubnet','172.17.17.2'" in rule + assert "and 172.17.17.2" in out + + +def test_windows_upgrade_keeps_the_extra_addresses(tmp_path): + win_setup(tmp_path, answers=["", "", "172.17.17.2"]) + _, system = win_setup(tmp_path, argv=["setup", "--keep"]) + [rule] = system.ran("New-NetFirewallRule") + assert "'172.17.17.2'" in rule + + # --- Windows --------------------------------------------------------------- def win_setup(tmp_path, answers=(), argv=(), system=None): @@ -376,7 +510,7 @@ def test_windows_one_network_comes_back_as_an_object_not_a_list(): def test_windows_setup_creates_the_startup_task(tmp_path): - out, system = win_setup(tmp_path, answers=["", "9443"]) + out, system = win_setup(tmp_path, answers=["", "9443", ""]) [task] = system.ran("Register-ScheduledTask") assert "LOCAL SERVICE" in task and "-AtStartup" in task assert "[TimeSpan]::Zero" in task # no 72-hour time limit @@ -387,7 +521,7 @@ def test_windows_setup_creates_the_startup_task(tmp_path): def test_windows_setup_opens_the_firewall_for_local_networks(tmp_path): - out, system = win_setup(tmp_path, answers=["", "9443"]) + out, system = win_setup(tmp_path, answers=["", "9443", ""]) [rule] = system.ran("New-NetFirewallRule") assert "-LocalPort 9443" in rule and "'LocalSubnet'" in rule assert "-Direction Inbound" in rule @@ -396,7 +530,7 @@ def test_windows_setup_opens_the_firewall_for_local_networks(tmp_path): def test_windows_one_network_allows_just_that_range(tmp_path): - _, system = win_setup(tmp_path, answers=["2", ""]) + _, system = win_setup(tmp_path, answers=["2", "", ""]) [rule] = system.ran("New-NetFirewallRule") assert "'192.168.1.0/24'" in rule @@ -408,7 +542,7 @@ def test_windows_this_machine_only_needs_no_rule(tmp_path): def test_windows_token_is_locked_down_and_shown_once(tmp_path): - out, system = win_setup(tmp_path, answers=["", ""]) + out, system = win_setup(tmp_path, answers=["", "", ""]) token = (tmp_path / "token").read_text().strip() assert token in out [acl] = system.ran("icacls " + str(tmp_path / "token")) @@ -418,7 +552,7 @@ def test_windows_token_is_locked_down_and_shown_once(tmp_path): def test_windows_rerunning_setup_replaces_the_task(tmp_path): - win_setup(tmp_path, answers=["", ""]) + win_setup(tmp_path, answers=["", "", ""]) _, system = win_setup(tmp_path, argv=["setup", "--keep"]) assert system.ran("Register-ScheduledTask") and \ "-Force" in system.ran("Register-ScheduledTask")[0] @@ -426,7 +560,7 @@ def test_windows_rerunning_setup_replaces_the_task(tmp_path): def test_windows_a_task_that_cannot_be_created_stops_setup(tmp_path): with pytest.raises(SystemExit) as stop: - win_setup(tmp_path, answers=["", ""], + win_setup(tmp_path, answers=["", "", ""], system=FakeSystem(fail=("Register-ScheduledTask",))) assert "scheduled task" in str(stop.value) @@ -438,7 +572,7 @@ def test_windows_new_token_restarts_the_task(tmp_path): def test_windows_remove_deletes_task_and_rule_but_keeps_the_data(tmp_path): - win_setup(tmp_path, answers=["", ""]) + win_setup(tmp_path, answers=["", "", ""]) out, system = win_setup(tmp_path, argv=["remove"]) [cmd] = system.calls text = " ".join(cmd) @@ -529,7 +663,7 @@ def test_the_menu_offers_network_port_and_the_token(tmp_path): run_setup(tmp_path, argv=["setup", "--host", "10.8.0.1", "--port", "9000"]) out, _ = run_setup(tmp_path, answers=["4"], argv=["menu"]) assert "Listening on 10.8.0.1:9000" in out - assert "1) Change network or port" in out + assert "1) Change network, port or allowed addresses" in out assert "2) Replace the token" in out assert "3) Show the token" in out assert "4) Close" in out @@ -545,7 +679,7 @@ def test_the_menu_replaces_the_token(tmp_path): def test_the_menu_changes_the_port_then_comes_back(tmp_path): run_setup(tmp_path, argv=["setup", "--host", "10.8.0.1", "--port", "9000"]) - out, _ = run_setup(tmp_path, answers=["1", "", "9443", "3", "4"], + out, _ = run_setup(tmp_path, answers=["1", "", "9443", "", "3", "4"], argv=["menu"]) assert passman_setup.read_env(tmp_path / "sync.env")["PASSMAN_PORT"] == \ "9443"