diff --git a/README.md b/README.md index 0a42c4e..10349c7 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,24 @@ vpn-router-setup` applies a changed configuration while Docker runs. With `docke `on`, the service fails when Docker is not installed or its daemon is not running; with `off` it removes the rules. +## Editing the configuration + +`vpn-config-edit`, run as root, shows and changes `/etc/vpn-router/vpn-router.json`: + +```sh +vpn-config-edit show +vpn-config-edit set web.status_content standard +vpn-config-edit set web.port 7082 +vpn-config-edit set web.trusted_proxies 127.0.0.1,10.0.0.4 +``` + +`show` prints the file as indented JSON and does not change it. `set` takes a setting as +`section.option` and converts the value to the setting's type: a whole number, `true` or +`false`, a list separated by commas (an empty value is an empty list), or a string; an unknown +setting is refused. It checks the whole configuration as `vpn-router-setup` does, and only then +writes the file, indented, with mode 0600, and reminds that `vpn-router-setup` applies it. A +refused request exits with status 2, any other failure with 1. + ## Applying changes ```sh diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index bdd172c..3905a91 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,16 @@ +vpn-router (0.15.0-1) unstable; urgency=medium + + feature: vpn-config-edit shows and changes the router's configuration. + + * vpn-config-edit show prints /etc/vpn-router/vpn-router.json as + indented JSON. + * vpn-config-edit set SECTION.OPTION VALUE converts the value to the + setting's type, checks the whole configuration as vpn-router-setup + does, writes the file indented with mode 0600, and reminds that + vpn-router-setup applies it. + + -- Slawomir Koszewski Sun, 25 Oct 2026 18:00:00 +0200 + vpn-router (0.14.1-1) unstable; urgency=medium fix: web.status_content is basic by default. diff --git a/debian-package/debian/install b/debian-package/debian/install index 8b2b1c8..9c91392 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -9,6 +9,7 @@ src/usr/lib/vpn-router/openvpn-same-user usr/lib/vpn-router/ src/usr/sbin/vpn-router-user usr/sbin/ src/usr/sbin/vpn-router-group usr/sbin/ src/usr/sbin/vpn-router-token usr/sbin/ +src/usr/sbin/vpn-config-edit usr/sbin/ src/usr/lib/vpn-router/simple-ca usr/lib/vpn-router/ src/usr/lib/vpn-router/configure usr/lib/vpn-router/ src/usr/lib/vpn-router/docker-rules usr/lib/vpn-router/ diff --git a/debian-package/src/usr/sbin/vpn-config-edit b/debian-package/src/usr/sbin/vpn-config-edit new file mode 100755 index 0000000..06a5ccf --- /dev/null +++ b/debian-package/src/usr/sbin/vpn-config-edit @@ -0,0 +1,129 @@ +#!/usr/bin/python3 +"""Show and change the router's configuration, /etc/vpn-router/vpn-router.json. + +show prints the file as indented JSON; the file is not changed. set takes a setting as +section.option, such as web.port, and a value, which it converts to the type the package's +schema (vpnrouter.SCHEMA) gives the setting: a whole number, true or false, a list of strings +separated by commas (an empty value is an empty list), or a string. The configuration with the +new value is checked as vpn-router-setup checks it, and only then written, indented, with mode +0600; vpn-router-setup applies it at its next run, which set reminds of. A failure is a message +on standard error; the exit status is 2 for an invalid request and 1 for any other failure. +-h or --help shows the options. + + vpn-config-edit show + vpn-config-edit set SECTION.OPTION VALUE +""" + +import argparse +import json +import os +import sys +import tempfile + +sys.path.insert(0, '/usr/lib/vpn-router') + +import vpnrouter + + +class Failure(Exception): + """A request that cannot be done; status is 2 invalid, 1 other.""" + + def __init__(self, message, status=1): + super().__init__(message) + self.status = status + + +class Parser(argparse.ArgumentParser): + """An argument parser whose errors are Failures with status 2.""" + + def error(self, message): + raise Failure(f'{message}; see {self.prog} --help', 2) + + +def read(): + """The settings in the configuration file, without the defaults; {} when the file does not exist.""" + try: + data = json.loads(vpnrouter.CONFIG_FILE.read_text(encoding='utf-8')) + except FileNotFoundError: + return {} + except ValueError as exc: + raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: {exc}') + if not isinstance(data, dict) or not all(isinstance(options, dict) for options in data.values()): + raise Failure(f'cannot parse {vpnrouter.CONFIG_FILE}: it is not an object of sections') + return data + + +def converted(key, text): + """The value of text for the setting key, section.option, of the type the schema gives it.""" + section, _, option = key.partition('.') + if section not in vpnrouter.SCHEMA or option not in vpnrouter.SCHEMA[section]: + raise Failure(f'{key}: unknown setting; settings are section.option, such as web.port', 2) + kind = type(vpnrouter.SCHEMA[section][option]) + if kind is bool: + if text not in ('true', 'false'): + raise Failure(f'{key} takes true or false', 2) + return text == 'true' + if kind is int: + try: + return int(text) + except ValueError: + raise Failure(f'{key} takes a whole number', 2) + if kind is list: + return [item.strip() for item in text.split(',') if item.strip()] + return text + + +def write(data): + """Write the settings, indented, with mode 0600, replacing the file in one step.""" + directory = vpnrouter.CONFIG_FILE.parent + descriptor, name = tempfile.mkstemp(dir=directory, prefix='.vpn-router.json.') + try: + with os.fdopen(descriptor, 'w', encoding='utf-8') as handle: + handle.write(json.dumps(data, indent=2) + '\n') + os.chmod(name, 0o600) + os.replace(name, vpnrouter.CONFIG_FILE) + except OSError: + os.unlink(name) + raise + + +def main(argv): + try: + parser = Parser(prog='vpn-config-edit', description='Show and change the router configuration.') + commands = parser.add_subparsers(dest='command', required=True, parser_class=Parser) + commands.add_parser('show', help='print the configuration as indented JSON') + setter = commands.add_parser('set', help='set a setting and write the configuration') + setter.add_argument('key', metavar='SECTION.OPTION', help='the setting, such as web.port') + setter.add_argument('value', metavar='VALUE', help='its value; a list separated by commas') + options = parser.parse_args(argv) + + data = read() + if options.command == 'show': + print(json.dumps(data, indent=2)) + return 0 + + section, _, option = options.key.partition('.') + value = converted(options.key, options.value) + data.setdefault(section, {})[option] = value + # The configuration as vpn-router-setup reads it: the defaults with the file's settings. + candidate = {name: dict(defaults) for name, defaults in vpnrouter.SCHEMA.items()} + for name, settings in data.items(): + candidate.setdefault(name, {}).update(settings) + try: + vpnrouter.validate(candidate) + except vpnrouter.ConfigError as exc: + raise Failure(str(exc), 2) + write(data) + except Failure as failure: + print(f'vpn-config-edit: {failure}', file=sys.stderr) + return failure.status + except OSError as exc: + print(f'vpn-config-edit: {exc}', file=sys.stderr) + return 1 + print(f'{options.key} set to {json.dumps(value)} in {vpnrouter.CONFIG_FILE}.') + print('Apply it with: systemctl restart vpn-router-setup') + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:]))