diff --git a/README.md b/README.md index 6c619ad..77ec6c3 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,10 @@ that deploy a router on Azure or GCP. ## Configuration `/etc/vpn-router/vpn-router.json` is JSON, mode 0600, and is created on first install. It -is managed with `ucf`, so it is yours to edit: a later `dpkg-reconfigure` applies what -changed and asks before touching anything you altered by hand. A complete example is +is managed with `ucf`, so it is yours to edit: a later `dpkg-reconfigure` applies the answers +given and asks before touching anything you altered by hand, and an upgrade leaves the file as +it is and asks nothing. Settings the file leaves out take their defaults when it is read, so a +file written by cloud-init or by hand needs only the settings that differ. A complete example is installed at `/usr/share/doc/vpn-router/vpn-router.json.example`. The file is an object of sections, each an object of settings. A section or setting that diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index df97455..45609d5 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,16 @@ +vpn-router (0.5.8-1) unstable; urgency=medium + + fix: an upgrade keeps the configuration and asks nothing. + + * postinst passes on only the debconf answers that were given. The + defaults of questions never asked, such as platform none, no longer + replace settings of vpn-router.json. + * Without an answer that changes it, the configuration ucf is given is + vpn-router.json byte for byte, so an upgrade has no conflict to ask + about; settings the file leaves out keep their defaults when it is read. + + -- Slawomir Koszewski Sat, 24 Oct 2026 20:00:00 +0200 + vpn-router (0.5.7-1) unstable; urgency=medium feature: users have a first name, a last name and an e-mail address. diff --git a/debian-package/debian/postinst b/debian-package/debian/postinst index 37d3aaf..ed241ba 100755 --- a/debian-package/debian/postinst +++ b/debian-package/debian/postinst @@ -2,28 +2,39 @@ set -e . /usr/share/debconf/confmodule +# The answer to a question in ANSWER, or "" when the question has not been seen; the +# default debconf holds for an unseen question is not an answer. +answer() { + ANSWER="" + db_fget "$1" seen + if [ "$RET" = "true" ]; then + db_get "$1" + ANSWER="$RET" + fi +} + case "$1" in configure) # --- Read the installer's answers --- - db_get vpn-router/platform; VPN_ROUTER_PLATFORM="$RET" - db_get vpn-router/mode; VPN_ROUTER_MODE="$RET" - db_get vpn-router/int_addr; VPN_ROUTER_INT_ADDR="$RET" - db_get vpn-router/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$RET" - db_get vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$RET" - db_get vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$RET" - db_get vpn-router/local_id_mode; VPN_ROUTER_LOCAL_ID_MODE="$RET" - db_get vpn-router/local_cidrs; VPN_ROUTER_LOCAL_CIDRS="$RET" - db_get vpn-router/int_gateway_ip; VPN_ROUTER_INT_GATEWAY_IP="$RET" - db_get vpn-router/remote_addrs; VPN_ROUTER_REMOTE_ADDRS="$RET" - db_get vpn-router/remote_id; VPN_ROUTER_REMOTE_ID="$RET" - db_get vpn-router/remote_cidrs; VPN_ROUTER_REMOTE_CIDRS="$RET" - db_get vpn-router/psk; VPN_ROUTER_PSK="$RET" - db_get vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$RET" - db_get vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$RET" - db_get vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$RET" - db_get vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$RET" - db_get vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$RET" - db_get vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$RET" + answer vpn-router/platform; VPN_ROUTER_PLATFORM="$ANSWER" + answer vpn-router/mode; VPN_ROUTER_MODE="$ANSWER" + answer vpn-router/int_addr; VPN_ROUTER_INT_ADDR="$ANSWER" + answer vpn-router/external_interface; VPN_ROUTER_EXTERNAL_INTERFACE="$ANSWER" + answer vpn-router/internal_interface; VPN_ROUTER_INTERNAL_INTERFACE="$ANSWER" + answer vpn-router/local_fqdn; VPN_ROUTER_LOCAL_FQDN="$ANSWER" + answer vpn-router/local_id_mode; VPN_ROUTER_LOCAL_ID_MODE="$ANSWER" + answer vpn-router/local_cidrs; VPN_ROUTER_LOCAL_CIDRS="$ANSWER" + answer vpn-router/int_gateway_ip; VPN_ROUTER_INT_GATEWAY_IP="$ANSWER" + answer vpn-router/remote_addrs; VPN_ROUTER_REMOTE_ADDRS="$ANSWER" + answer vpn-router/remote_id; VPN_ROUTER_REMOTE_ID="$ANSWER" + answer vpn-router/remote_cidrs; VPN_ROUTER_REMOTE_CIDRS="$ANSWER" + answer vpn-router/psk; VPN_ROUTER_PSK="$ANSWER" + answer vpn-router/p2s_enabled; VPN_ROUTER_P2S_ENABLED="$ANSWER" + answer vpn-router/p2s_address_pool; VPN_ROUTER_P2S_ADDRESS_POOL="$ANSWER" + answer vpn-router/p2s_ca_name; VPN_ROUTER_P2S_CA_NAME="$ANSWER" + answer vpn-router/wg_enabled; VPN_ROUTER_WG_ENABLED="$ANSWER" + answer vpn-router/wg_address; VPN_ROUTER_WG_ADDRESS="$ANSWER" + answer vpn-router/wg_listen_port; VPN_ROUTER_WG_LISTEN_PORT="$ANSWER" export VPN_ROUTER_PLATFORM VPN_ROUTER_MODE VPN_ROUTER_INT_ADDR \ VPN_ROUTER_EXTERNAL_INTERFACE \ diff --git a/debian-package/src/usr/lib/vpn-router/generate-config b/debian-package/src/usr/lib/vpn-router/generate-config index 2d444fb..4282f49 100755 --- a/debian-package/src/usr/lib/vpn-router/generate-config +++ b/debian-package/src/usr/lib/vpn-router/generate-config @@ -6,18 +6,15 @@ Called from postinst, which hands the result to ucf(1). ucf compares it with this script never writes to /etc and never has to worry about destroying an administrator's edits. -The candidate is built in three layers, each overriding the one before: - - 1. the shipped defaults - 2. the current contents of /etc/vpn-router/vpn-router.json, if it exists - 3. any debconf answer that was actually given - -Layer 2 is what makes reconfiguration safe: a setting the administrator edited -by hand, and that debconf has nothing to say about, is carried through -unchanged, so ucf only sees a diff where something really changed. An empty -answer means "leave alone", which is why clearing the pre-shared key from the -debconf database after installation does not blank it on the next -dpkg-reconfigure. +The candidate is the current /etc/vpn-router/vpn-router.json as it is, or the +shipped defaults when there is none, with every debconf answer that was given +written over it. Without an answer that changes it, the candidate is the current +file byte for byte, so +an upgrade or a reconfiguration that changes nothing gives ucf nothing to ask; +defaults the file leaves out are applied by vpnrouter.load_config() when the +file is read. An empty answer means "leave alone", which is why clearing the +pre-shared key from the debconf database after installation does not blank it +on the next dpkg-reconfigure. Answers arrive as VPN_ROUTER_* environment variables and are converted to the type of the setting they fill: comma-separated lists become arrays, "true" and @@ -62,8 +59,21 @@ def main(): return 2 output = sys.argv[1] - cfg = vpnrouter.load_config() + try: + current = vpnrouter.CONFIG_FILE.read_text(encoding='utf-8') + except FileNotFoundError: + current = None + if current is None: + cfg = vpnrouter.load_config() + else: + try: + cfg = json.loads(current) + except ValueError as exc: + vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {exc}') + if not isinstance(cfg, dict): + vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: the top level is not an object') + answered = False for variable, (section, option) in ANSWERS.items(): value = os.environ.get(variable) if not value: @@ -76,11 +86,19 @@ def main(): value = int(value) if value.isdigit() else value elif isinstance(default, list): value = [item.strip() for item in value.split(',') if item.strip()] - cfg[section][option] = value + if not isinstance(cfg.get(section, {}), dict): + vpnrouter.fail(f'cannot parse {vpnrouter.CONFIG_FILE}: {section} is not an object') + if cfg.setdefault(section, {}).get(option) != value: + cfg[section][option] = value + answered = True + # Without a change the candidate is the current file byte for byte, as ucf compares checksums. with open(output, 'w', encoding='utf-8') as handle: - json.dump(cfg, handle, indent=2) - handle.write('\n') + if current is not None and not answered: + handle.write(current) + else: + json.dump(cfg, handle, indent=2) + handle.write('\n') os.chmod(output, 0o600) return 0