Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ That is `PassMan-Server-Setup.exe`'s job. Double-click it (it asks for
administrator rights) and it shows where the server listens now, then:

```
1) Change network or port
1) Change network, port or allowed addresses
2) Replace the token
3) Show the token
4) Close
Expand All @@ -130,6 +130,13 @@ For a script, skip the questions: `… setup --host 0.0.0.0 --port 9443`.
by itself. What is left is the new address in Settings → Sync on every PC,
in every synced vault.

**A PC on another network** — behind a router, or on a VPN this PC is not
part of — is outside the firewall rule, which covers the networks this PC
is on. The setup's third question asks for such addresses (`172.17.17.2`,
or a network such as `10.20.0.0/24`; comma-separated, `-` for none), or from
an administrator terminal: `… setup --keep --allow-from 172.17.17.2`. They
join the same rule, and an upgrade keeps them.

**If the token leaks**, choose **Replace the token**. It asks for
confirmation, writes a new random token, restarts the server and prints the
new value. From then on the old token is refused everywhere. On each PC,
Expand Down
134 changes: 113 additions & 21 deletions server/passman_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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")
Expand All @@ -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: "
Expand All @@ -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://<this machine's address>:{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


Expand Down Expand Up @@ -460,14 +530,27 @@ 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):
say(f"Note: something is already listening on port {port}. If it is "
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}.")
Expand All @@ -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))
Expand All @@ -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)


Expand Down Expand Up @@ -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"))

Expand Down Expand Up @@ -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")
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion server/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
from the app's and from the Linux server's.
"""

__version__ = "1.0.0"
__version__ = "1.1.0"
Loading
Loading