From 81b7f4cac6c0e4f5d86244834829060ef0a5ede8 Mon Sep 17 00:00:00 2001 From: Slawomir Koszewski Date: Mon, 26 Oct 2026 11:35:46 +0100 Subject: [PATCH] feature: bash completes the vpn-* commands, their options and the names they take. --- ADR.md | 22 +++++ README.md | 17 ++++ debian-package/debian/changelog | 13 +++ debian-package/debian/control | 1 + debian-package/debian/install | 1 + .../completions/vpn-config-edit.bash | 59 +++++++++++ .../completions/vpn-router-backup.bash | 41 ++++++++ .../completions/vpn-router-ca.bash | 98 +++++++++++++++++++ .../completions/vpn-router-caddy.bash | 24 +++++ .../completions/vpn-router-group.bash | 74 ++++++++++++++ .../completions/vpn-router-token.bash | 25 +++++ .../completions/vpn-router-user.bash | 84 ++++++++++++++++ 12 files changed, 459 insertions(+) create mode 100644 debian-package/src/usr/share/bash-completion/completions/vpn-config-edit.bash create mode 100644 debian-package/src/usr/share/bash-completion/completions/vpn-router-backup.bash create mode 100644 debian-package/src/usr/share/bash-completion/completions/vpn-router-ca.bash create mode 100644 debian-package/src/usr/share/bash-completion/completions/vpn-router-caddy.bash create mode 100644 debian-package/src/usr/share/bash-completion/completions/vpn-router-group.bash create mode 100644 debian-package/src/usr/share/bash-completion/completions/vpn-router-token.bash create mode 100644 debian-package/src/usr/share/bash-completion/completions/vpn-router-user.bash diff --git a/ADR.md b/ADR.md index 72221fe..f1c0847 100644 --- a/ADR.md +++ b/ADR.md @@ -1043,3 +1043,25 @@ HTTPS, a public name and the web service. Clients are managed like service accou it renames it in the clients. The signing key does not rotate; replacing it invalidates every ID token issued. Wrong client secrets lock the client like any account. The implicit and hybrid flows, dynamic client registration and public clients without a secret are not offered. + +## ADR-38: Bash completions are files per command in bash-completion's completions directory + +Status: Accepted + +Context: The `vpn-*` commands have many subcommands, options and names (users, groups, issuing +CAs, templates, settings) that are hard to remember. bash-completion's README recommends its +`completionsdir`, `/usr/share/bash-completion/completions` on Ubuntu, whose files it loads on the +first Tab of their command, and calls the eagerly loaded `/etc/bash_completion.d` not +recommended; it prefers the name `.bash`, which noble's 2.11 and resolute's 2.16 both +look up. + +Decision: The package installs `.bash` for each `vpn-*` command into +`/usr/share/bash-completion/completions` and recommends `bash-completion`. The completions read +the names they offer from the commands themselves (`--json list`) or the package's Python +modules (the schema, the configuration, the PKI's templates and reasons), not from copies, and +send errors to `/dev/null`, so a user who may not read them gets the fixed words only. + +Consequences: Completions follow the code without being edited for each new setting or name. A +Tab that offers names runs a command or Python, which takes a fraction of a second. The files +are not conffiles; an administrator overrides one in `/etc/bash_completion.d` or the user's own +completions directory. diff --git a/README.md b/README.md index b9d54d9..be2f4f8 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,23 @@ indented, with mode 0600, and reminds that `vpn-router-setup` applies it. The st writes the file the same way, and a lock keeps the two from overwriting each other's change. A refused request exits with status 2, any other failure with 1. +## Bash completion + +Bash completes the commands, options and arguments of `vpn-router-user`, `vpn-router-group`, +`vpn-router-token`, `vpn-config-edit`, `vpn-router-backup`, `vpn-router-ca` and +`vpn-router-caddy` with the Tab key. The package installs a file per command in +`/usr/share/bash-completion/completions`, which the `bash-completion` package, recommended by +`vpn-router`, loads on the first Tab after the command. For root, who can read them, the +completions also offer the users and groups of the directory, the settings and site-to-site +connections of `vpn-router.json` with `true` and `false` for a boolean setting, and the issuing CAs and +certificate templates; for other users only the fixed words. + +```sh +sudo -i +vpn-config-edit set oidc. +vpn-router-user passwd +``` + ## Applying changes ```sh diff --git a/debian-package/debian/changelog b/debian-package/debian/changelog index da0462a..7af6205 100644 --- a/debian-package/debian/changelog +++ b/debian-package/debian/changelog @@ -1,3 +1,16 @@ +vpn-router (0.28.0-1) unstable; urgency=medium + + feature: bash completes the vpn-* commands, their options and the names they take. + + * Bash completes the commands, options and arguments of vpn-router-user, + vpn-router-group, vpn-router-token, vpn-config-edit, vpn-router-backup, + vpn-router-ca and vpn-router-caddy, from + /usr/share/bash-completion/completions; for root also the users, + groups, issuing CAs, templates, settings and site-to-site connections. + * The package recommends bash-completion. + + -- Slawomir Koszewski Mon, 26 Oct 2026 16:00:00 +0100 + vpn-router (0.27.0-1) unstable; urgency=medium feature: the web service is an OpenID Provider, which signs the directory's users in to other systems, the clients being service accounts with redirect URIs. diff --git a/debian-package/debian/control b/debian-package/debian/control index bfbaf1e..163223c 100644 --- a/debian-package/debian/control +++ b/debian-package/debian/control @@ -39,6 +39,7 @@ Depends: ${misc:Depends}, iproute2, systemd-resolved, zstd +Recommends: bash-completion Suggests: caddy, rclone, proxmox-backup-client-static diff --git a/debian-package/debian/install b/debian-package/debian/install index 878046a..966b04a 100644 --- a/debian-package/debian/install +++ b/debian-package/debian/install @@ -31,5 +31,6 @@ src/usr/lib/vpn-router/generate-config usr/lib/vpn-router/ src/usr/lib/vpn-router/vpnrouter_platforms/*.py usr/lib/vpn-router/vpnrouter_platforms/ src/usr/share/vpn-router/templates/* usr/share/vpn-router/templates/ src/usr/share/vpn-router/sql/* usr/share/vpn-router/sql/ +src/usr/share/bash-completion/completions/*.bash usr/share/bash-completion/completions/ src/usr/lib/systemd/system/caddy.service.d/vpn-router.conf usr/lib/systemd/system/caddy.service.d/ src/usr/share/doc/vpn-router/vpn-router.json.example usr/share/doc/vpn-router/ diff --git a/debian-package/src/usr/share/bash-completion/completions/vpn-config-edit.bash b/debian-package/src/usr/share/bash-completion/completions/vpn-config-edit.bash new file mode 100644 index 0000000..a6c3765 --- /dev/null +++ b/debian-package/src/usr/share/bash-completion/completions/vpn-config-edit.bash @@ -0,0 +1,59 @@ +# bash completion for vpn-config-edit -*- shell-script -*- + +# Python of the package that answers from its schema (vpnrouter.SCHEMA, vpnrouter.CONNECTION) and, read as root, the +# site-to-site connections of the configuration: settings prints SECTION.OPTION of every setting and +# site_to_site.NAME.OPTION of every connection, connections site_to_site.NAME, and values the values of a boolean +# setting, $1. +_vpn_config_edit_python() +{ + python3 -c ' +import sys +sys.path.insert(0, "/usr/lib/vpn-router") +import vpnrouter +try: + connections = vpnrouter.read_settings().get("site_to_site", {}) +except (OSError, vpnrouter.ConfigError): + connections = {} +if sys.argv[1] == "settings": + print("\n".join(f"{section}.{option}" for section, options in vpnrouter.SCHEMA.items() for option in options)) + print("\n".join(f"site_to_site.{name}.{option}" for name in connections for option in vpnrouter.CONNECTION)) +elif sys.argv[1] == "connections": + print("\n".join(f"site_to_site.{name}" for name in connections)) +else: + section, _, option = sys.argv[2].rpartition(".") + defaults = vpnrouter.CONNECTION if section.startswith("site_to_site.") else vpnrouter.SCHEMA.get(section, {}) + if isinstance(defaults.get(option), bool): + print("true\nfalse") +' "$@" 2>/dev/null +} + +_vpn_config_edit() +{ + local cur prev words cword split + _init_completion -s || return + + # The command is the first word that is not an option; the arguments are the words after it. + local i command='' args=() + for ((i = 1; i < cword; i++)); do + if [[ -z $command ]]; then + [[ ${words[i]} != -* ]] && command=${words[i]} + elif [[ ${words[i]} != -* ]]; then + args+=("${words[i]}") + fi + done + + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W '--help' -- "$cur")) + return + fi + + case $command,${#args[@]} in + ,*) COMPREPLY=($(compgen -W 'show set remove' -- "$cur")) ;; + set,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python settings)" -- "$cur")) ;; + set,1) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python values "${args[0]}")" -- "$cur")) ;; + remove,0) COMPREPLY=($(compgen -W "$(_vpn_config_edit_python connections)" -- "$cur")) ;; + esac +} && + complete -F _vpn_config_edit vpn-config-edit + +# ex: filetype=sh diff --git a/debian-package/src/usr/share/bash-completion/completions/vpn-router-backup.bash b/debian-package/src/usr/share/bash-completion/completions/vpn-router-backup.bash new file mode 100644 index 0000000..0a9f7cf --- /dev/null +++ b/debian-package/src/usr/share/bash-completion/completions/vpn-router-backup.bash @@ -0,0 +1,41 @@ +# bash completion for vpn-router-backup -*- shell-script -*- + +_vpn_router_backup() +{ + local cur prev words cword split + _init_completion -s || return + + # The command is the first word that is not an option; the arguments are the words after it. + local i command='' args=0 pbs=false + for ((i = 1; i < cword; i++)); do + if [[ -z $command ]]; then + [[ ${words[i]} != -* ]] && command=${words[i]} + elif [[ ${words[i]} == --pbs ]]; then + pbs=true + elif [[ ${words[i]} != -* ]]; then + ((args++)) + fi + done + + if [[ -z $command ]]; then + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W '--help' -- "$cur")) + else + COMPREPLY=($(compgen -W 'run list restore' -- "$cur")) + fi + return + fi + + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W "--help$([[ $command == restore ]] && echo ' --pbs --yes')" -- "$cur")) + return + fi + + # restore takes a local archive, or with --pbs a snapshot name, which vpn-router-backup list shows. + if [[ $command == restore ]] && ((args == 0)) && ! $pbs; then + _filedir + fi +} && + complete -F _vpn_router_backup vpn-router-backup + +# ex: filetype=sh diff --git a/debian-package/src/usr/share/bash-completion/completions/vpn-router-ca.bash b/debian-package/src/usr/share/bash-completion/completions/vpn-router-ca.bash new file mode 100644 index 0000000..1fe3635 --- /dev/null +++ b/debian-package/src/usr/share/bash-completion/completions/vpn-router-ca.bash @@ -0,0 +1,98 @@ +# bash completion for vpn-router-ca -*- shell-script -*- + +# Python of the package that prints the revocation reasons (reasons) or, from the configuration read as root, the +# issuing CAs (cas) or the certificate templates (templates), which other users do not get. +_vpn_router_ca_python() +{ + python3 -c ' +import sys +sys.path.insert(0, "/usr/lib/vpn-router") +import vpnrouter +import vpnrouter_pki +if sys.argv[1] == "reasons": + print("\n".join(vpnrouter_pki.REASONS)) +else: + cfg = vpnrouter.load_config() + print("\n".join(vpnrouter.issuing_cas(cfg) if sys.argv[1] == "cas" else vpnrouter.pki_templates(cfg))) +' "$@" 2>/dev/null +} + +_vpn_router_ca() +{ + local cur prev words cword split + _init_completion -s || return + + # The command and, for ca and root, the action are the first words that are not options; the arguments are the + # words after them but option values. + local i command='' action='' args=0 + for ((i = 1; i < cword; i++)); do + case ${words[i]} in + --ca | --template | --csr | --subject | --san | --pfx | --reason | --out) ((i++)) ;; + -*) ;; + *) + if [[ -z $command ]]; then + command=${words[i]} + elif [[ $command == @(ca|root) && -z $action ]]; then + action=${words[i]} + else + ((args++)) + fi + ;; + esac + done + + case $prev in + --ca) + COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur")) + return + ;; + --template) + COMPREPLY=($(compgen -W "$(_vpn_router_ca_python templates)" -- "$cur")) + return + ;; + --reason) + COMPREPLY=($(compgen -W "$(_vpn_router_ca_python reasons)" -- "$cur")) + return + ;; + --csr | --pfx | --out) + _filedir + return + ;; + --subject | --san) + return + ;; + esac + $split && return + + if [[ -z $command ]]; then + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W '--json --help' -- "$cur")) + else + COMPREPLY=($(compgen -W 'status list issue revoke maintain ca root' -- "$cur")) + fi + return + fi + + if [[ $cur == -* ]]; then + local options='--help' + case $command$action in + list) options+=' --ca' ;; + issue) options+=' --ca --template --csr --subject --san --pfx' ;; + revoke) options+=' --reason' ;; + rootexport-key) options+=' --out' ;; + rootremove-key) options+=' --yes' ;; + esac + COMPREPLY=($(compgen -W "$options" -- "$cur")) + return + fi + + case $command,$action,$args in + ca,,*) COMPREPLY=($(compgen -W 'csr import' -- "$cur")) ;; + root,,*) COMPREPLY=($(compgen -W 'export-key remove-key import-key import-crl' -- "$cur")) ;; + ca,*,0) COMPREPLY=($(compgen -W "$(_vpn_router_ca_python cas)" -- "$cur")) ;; + ca,import,1 | root,import-key,0 | root,import-crl,0) _filedir ;; + esac +} && + complete -F _vpn_router_ca vpn-router-ca + +# ex: filetype=sh diff --git a/debian-package/src/usr/share/bash-completion/completions/vpn-router-caddy.bash b/debian-package/src/usr/share/bash-completion/completions/vpn-router-caddy.bash new file mode 100644 index 0000000..67b95fe --- /dev/null +++ b/debian-package/src/usr/share/bash-completion/completions/vpn-router-caddy.bash @@ -0,0 +1,24 @@ +# bash completion for vpn-router-caddy -*- shell-script -*- + +_vpn_router_caddy() +{ + local cur prev words cword + _init_completion || return + + # After the command only --help is left. + local word + for word in "${words[@]:1:cword-1}"; do + if [[ $word != -* ]]; then + [[ $cur == -* ]] && COMPREPLY=($(compgen -W '--help' -- "$cur")) + return + fi + done + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W '--json --help' -- "$cur")) + else + COMPREPLY=($(compgen -W 'status apply use-upstream' -- "$cur")) + fi +} && + complete -F _vpn_router_caddy vpn-router-caddy + +# ex: filetype=sh diff --git a/debian-package/src/usr/share/bash-completion/completions/vpn-router-group.bash b/debian-package/src/usr/share/bash-completion/completions/vpn-router-group.bash new file mode 100644 index 0000000..8f4171f --- /dev/null +++ b/debian-package/src/usr/share/bash-completion/completions/vpn-router-group.bash @@ -0,0 +1,74 @@ +# bash completion for vpn-router-group -*- shell-script -*- + +# The names of the users and of the groups of the router's directory, read as root; nothing for other users. +_vpn_router_group_users() +{ + vpn-router-user --json list 2>/dev/null | + python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null +} + +_vpn_router_group_names() +{ + vpn-router-group --json list 2>/dev/null | + python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null +} + +_vpn_router_group() +{ + local cur prev words cword split + _init_completion -s || return + + # The command is the first word that is not an option; the arguments are the words after it but option values. + local i command='' args=0 + for ((i = 1; i < cword; i++)); do + if [[ -z $command ]]; then + [[ ${words[i]} != -* ]] && command=${words[i]} + continue + fi + case ${words[i]} in + --member | --description | --name) ((i++)) ;; + -*) ;; + *) ((args++)) ;; + esac + done + + if [[ -z $command ]]; then + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W '--json --help' -- "$cur")) + else + COMPREPLY=($(compgen -W 'list add update remove member-add member-remove' -- "$cur")) + fi + return + fi + + case $prev in + --member) + COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur")) + return + ;; + --description | --name) + return + ;; + esac + $split && return + + if [[ $cur == -* ]]; then + local options='--help' + case $command in + add) options+=' --member --description' ;; + update) options+=' --name --description' ;; + esac + COMPREPLY=($(compgen -W "$options" -- "$cur")) + return + fi + + # The group, which add takes new, then the user of member-add and member-remove. + if ((args == 0)) && [[ $command != @(list|add) ]]; then + COMPREPLY=($(compgen -W "$(_vpn_router_group_names)" -- "$cur")) + elif ((args == 1)) && [[ $command == member-@(add|remove) ]]; then + COMPREPLY=($(compgen -W "$(_vpn_router_group_users)" -- "$cur")) + fi +} && + complete -F _vpn_router_group vpn-router-group + +# ex: filetype=sh diff --git a/debian-package/src/usr/share/bash-completion/completions/vpn-router-token.bash b/debian-package/src/usr/share/bash-completion/completions/vpn-router-token.bash new file mode 100644 index 0000000..b1b93df --- /dev/null +++ b/debian-package/src/usr/share/bash-completion/completions/vpn-router-token.bash @@ -0,0 +1,25 @@ +# bash completion for vpn-router-token -*- shell-script -*- + +_vpn_router_token() +{ + local cur prev words cword split + _init_completion -s || return + + case $prev in + --minutes | --hours | --days) + return + ;; + esac + $split && return + + # The lifetime is one of --minutes, --hours and --days. + local options='--json --help' word + for word in "${words[@]:1:cword-1}"; do + [[ $word == --@(minutes|hours|days)* ]] && break + done + [[ $word == --@(minutes|hours|days)* ]] || options+=' --minutes --hours --days' + COMPREPLY=($(compgen -W "$options" -- "$cur")) +} && + complete -F _vpn_router_token vpn-router-token + +# ex: filetype=sh diff --git a/debian-package/src/usr/share/bash-completion/completions/vpn-router-user.bash b/debian-package/src/usr/share/bash-completion/completions/vpn-router-user.bash new file mode 100644 index 0000000..dae3fb6 --- /dev/null +++ b/debian-package/src/usr/share/bash-completion/completions/vpn-router-user.bash @@ -0,0 +1,84 @@ +# bash completion for vpn-router-user -*- shell-script -*- + +# The names of the users and of the groups of the router's directory, read as root; nothing for other users. +_vpn_router_user_names() +{ + vpn-router-user --json list 2>/dev/null | + python3 -c 'import json, sys; print("\n".join(user["name"] for user in json.load(sys.stdin)["users"]))' 2>/dev/null +} + +_vpn_router_user_groups() +{ + vpn-router-group --json list 2>/dev/null | + python3 -c 'import json, sys; print("\n".join(group["name"] for group in json.load(sys.stdin)["groups"]))' 2>/dev/null +} + +_vpn_router_user() +{ + local cur prev words cword split + _init_completion -s || return + + local commands='list add update passwd disable enable unlock remove profile otp-new otp-enrol otp-remove console + passkeys-remove' + + # The command is the first word that is not an option; the arguments are the words after it but option values. + local i command='' args=0 + for ((i = 1; i < cword; i++)); do + if [[ -z $command ]]; then + [[ ${words[i]} != -* ]] && command=${words[i]} + continue + fi + case ${words[i]} in + --first-name | --last-name | --email | --display-name | --description | --redirect-uri | \ + --post-logout-redirect-uri | --allowed-group | --account) ((i++)) ;; + -*) ;; + *) ((args++)) ;; + esac + done + + if [[ -z $command ]]; then + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W '--json --help' -- "$cur")) + else + COMPREPLY=($(compgen -W "$commands" -- "$cur")) + fi + return + fi + + case $prev in + --allowed-group) + COMPREPLY=($(compgen -W "$(_vpn_router_user_groups)" -- "$cur")) + return + ;; + --account) + # The local accounts console --account takes: a uid from 1000 to 19999, not nobody. + COMPREPLY=($(compgen -W "$(getent passwd | awk -F: '$3 >= 1000 && $3 < 20000 && $1 != "nobody" { print $1 }')" -- "$cur")) + return + ;; + --first-name | --last-name | --email | --display-name | --description | --redirect-uri | --post-logout-redirect-uri) + return + ;; + esac + $split && return + + if [[ $cur == -* ]]; then + local options='--help' + case $command in + add) options+=' --first-name --last-name --email --service --display-name --description --redirect-uri + --post-logout-redirect-uri --allowed-group' ;; + update) options+=' --first-name --last-name --email --display-name --description --redirect-uri + --post-logout-redirect-uri --allowed-group' ;; + console) options+=' --posix --account --off' ;; + esac + COMPREPLY=($(compgen -W "$options" -- "$cur")) + return + fi + + # The user name, which add takes new; otp-enrol then takes a code. + if ((args == 0)) && [[ $command != @(list|add) ]]; then + COMPREPLY=($(compgen -W "$(_vpn_router_user_names)" -- "$cur")) + fi +} && + complete -F _vpn_router_user vpn-router-user + +# ex: filetype=sh