diff --git a/confconsole.py b/confconsole.py index a6353b2..f532327 100755 --- a/confconsole.py +++ b/confconsole.py @@ -331,6 +331,9 @@ def _validip(ifname: str) -> bool: ip = ifutil.get_ipconf(ifname)[0] if ip and not ip.startswith("169"): return True + ip6 = ifutil.get_ipv6conf(ifname)[0] + if ip6: + return True return False defifname = conf.Conf().default_nic @@ -437,7 +440,11 @@ def _get_ifconftext(self, ifname: str) -> str: text = f"IP Address: {addr}\n" text += f"Netmask: {netmask}\n" text += f"Default Gateway: {gateway}\n" - text += f"Name Server(s): {' '.join(nameservers)}\n\n" + text += f"Name Server(s): {' '.join(nameservers)}\n" + ipv6_addr, ipv6_prefix = ifutil.get_ipv6conf(ifname) + if ipv6_addr: + text += f"IPv6 Address: {ipv6_addr}/{ipv6_prefix}\n" + text += "\n" ifmethod = ifutil.get_ifmethod(ifname) if ifmethod: @@ -503,15 +510,26 @@ def usage(self) -> str: hostname = netinfo.get_hostname().upper() + ipv6_addr, ipv6_prefix = ifutil.get_ipv6conf(ifname) + ip6_display = f"{ipv6_addr}/{ipv6_prefix}" if ipv6_addr else "not configured" + try: with open(conf.path("services.txt")) as fob: t = fob.read().rstrip() - text = Template(t).substitute(appname=self.appname, - hostname=hostname, - ipaddr=ip_addr) + text = Template(t).safe_substitute( + appname=self.appname, + hostname=hostname, + ipaddr=ip_addr, + ) except conf.ConfconsoleConfError: t = "" - text = Template(t).substitute(ipaddr=ip_addr) + text = Template(t).safe_substitute(ipaddr=ip_addr) + + ipv6_addr, _ipv6_prefix = ifutil.get_ipv6conf(ifname) + if ipv6_addr: + text += f"\n" + text += f"\nIPv6 Web: http://[{ipv6_addr}]" + text += f"\nIPv6 SSH: 'root@[{ipv6_addr}]'" text += f"\n\n{tklbam_status}\n\n" text += "\n" * (self.height - len(text.splitlines()) - 7) diff --git a/debian/.debhelper/generated/confconsole/dh_installchangelogs.dch.trimmed b/debian/.debhelper/generated/confconsole/dh_installchangelogs.dch.trimmed new file mode 100644 index 0000000..09d5eab --- /dev/null +++ b/debian/.debhelper/generated/confconsole/dh_installchangelogs.dch.trimmed @@ -0,0 +1,5 @@ +confconsole (2.2.1) stable; urgency=medium + + * fix: recognize IPv6 global address as valid network + + -- PopSolutions Tue, 24 Mar 2026 19:03:04 +0000 diff --git a/debian/.debhelper/generated/confconsole/installed-by-dh_install b/debian/.debhelper/generated/confconsole/installed-by-dh_install new file mode 100644 index 0000000..18ba31a --- /dev/null +++ b/debian/.debhelper/generated/confconsole/installed-by-dh_install @@ -0,0 +1,12 @@ +./plugins.d/ +./conf.py +./confconsole.py +./ifutil.py +./ipaddr.py +./plugin.py +./conf/confconsole.conf +./conf/services.txt +./share/autostart +./share/letsencrypt +./add-water/add-water.service +./turnkey-lexicon diff --git a/debian/.debhelper/generated/confconsole/installed-by-dh_installdocs b/debian/.debhelper/generated/confconsole/installed-by-dh_installdocs new file mode 100644 index 0000000..43ffa84 --- /dev/null +++ b/debian/.debhelper/generated/confconsole/installed-by-dh_installdocs @@ -0,0 +1,17 @@ +./docs/images +./docs/Lets_encrypt#advanced.rst +./docs/Lets_encrypt.rst +./docs/Mail_relay.rst +./docs/Networking.rst +./docs/Plugins.rst +./docs/Proxy_settings.rst +./docs/README +./docs/Region_config.rst +./docs/RelNotes-0.9.1.txt +./docs/RelNotes-0.9.2.txt +./docs/RelNotes-0.9.3.txt +./docs/RelNotes-0.9.4.txt +./docs/RelNotes-0.9.txt +./docs/RelNotes-1.0.0.txt +./docs/RelNotes-2.1.0.txt +./docs/System_settings.rst diff --git a/debian/confconsole.debhelper.log b/debian/confconsole.debhelper.log new file mode 100644 index 0000000..c856529 --- /dev/null +++ b/debian/confconsole.debhelper.log @@ -0,0 +1 @@ +dh_installinit diff --git a/debian/confconsole.postinst.debhelper b/debian/confconsole.postinst.debhelper new file mode 100644 index 0000000..62e9285 --- /dev/null +++ b/debian/confconsole.postinst.debhelper @@ -0,0 +1,23 @@ + +# Automatically added by dh_python3 +if command -v py3compile >/dev/null 2>&1; then + py3compile -p confconsole /usr/lib/confconsole +fi +if command -v pypy3compile >/dev/null 2>&1; then + pypy3compile -p confconsole /usr/lib/confconsole || true +fi + +# End automatically added section +# Automatically added by dh_systemd_start/13.24.2 +if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then + if [ -d /run/systemd/system ]; then + systemctl --system daemon-reload >/dev/null || true + if [ -n "$2" ]; then + _dh_action=restart + else + _dh_action=start + fi + deb-systemd-invoke $_dh_action 'add-water.service' >/dev/null || true + fi +fi +# End automatically added section diff --git a/debian/confconsole.postrm.debhelper b/debian/confconsole.postrm.debhelper new file mode 100644 index 0000000..b8c4b2d --- /dev/null +++ b/debian/confconsole.postrm.debhelper @@ -0,0 +1,5 @@ +# Automatically added by dh_systemd_start/13.24.2 +if [ "$1" = remove ] && [ -d /run/systemd/system ] ; then + systemctl --system daemon-reload >/dev/null || true +fi +# End automatically added section diff --git a/debian/confconsole.prerm.debhelper b/debian/confconsole.prerm.debhelper new file mode 100644 index 0000000..664bdde --- /dev/null +++ b/debian/confconsole.prerm.debhelper @@ -0,0 +1,15 @@ +# Automatically added by dh_systemd_start/13.24.2 +if [ -z "$DPKG_ROOT" ] && [ "$1" = remove ] && [ -d /run/systemd/system ] ; then + deb-systemd-invoke stop 'add-water.service' >/dev/null || true +fi +# End automatically added section + +# Automatically added by dh_python3 +if command -v py3clean >/dev/null 2>&1; then + py3clean -p confconsole +else + dpkg -L confconsole | sed -En -e '/^(.*)\/(.+)\.py$/s,,rm "\1/__pycache__/\2".*,e' + find /usr/lib/python3/dist-packages/ -type d -name __pycache__ -empty -print0 | xargs --null --no-run-if-empty rmdir +fi + +# End automatically added section diff --git a/debian/confconsole.substvars b/debian/confconsole.substvars new file mode 100644 index 0000000..13de292 --- /dev/null +++ b/debian/confconsole.substvars @@ -0,0 +1,3 @@ +python3:Depends=python3:any +misc:Depends= +misc:Pre-Depends= diff --git a/debian/confconsole/DEBIAN/conffiles b/debian/confconsole/DEBIAN/conffiles new file mode 100644 index 0000000..1291cc8 --- /dev/null +++ b/debian/confconsole/DEBIAN/conffiles @@ -0,0 +1,3 @@ +/etc/confconsole/confconsole.conf +/etc/confconsole/services.txt +/etc/logrotate.d/confconsole diff --git a/debian/confconsole/DEBIAN/control b/debian/confconsole/DEBIAN/control new file mode 100644 index 0000000..54e9267 --- /dev/null +++ b/debian/confconsole/DEBIAN/control @@ -0,0 +1,11 @@ +Package: confconsole +Version: 2.2.1 +Architecture: all +Maintainer: Stefan Davis +Installed-Size: 469 +Depends: python3:any, python3-dialog (>= 3.4), turnkey-netinfo, turnkey-conffile, libsasl2-modules, python3-requests +Recommends: authbind, dehydrated, kbd, python3-bottle, resolvconf +Suggests: di-live +Section: misc +Priority: optional +Description: TurnKey GNU/Linux Configuration Console diff --git a/debian/confconsole/DEBIAN/md5sums b/debian/confconsole/DEBIAN/md5sums new file mode 100644 index 0000000..3dcceb8 --- /dev/null +++ b/debian/confconsole/DEBIAN/md5sums @@ -0,0 +1,65 @@ +a2a435b858602013cd250023ab2bf88e lib/systemd/system/add-water.service +e6c6f46b8154ddc64149592d64ffc2f8 usr/bin/turnkey-lexicon +be10d9f9a800e467a207888a5144fbeb usr/lib/confconsole/conf.py +97d17f2a4fc63a6a72330f25afd753f0 usr/lib/confconsole/confconsole.py +9a8bf227c87211531a1674870c7d3364 usr/lib/confconsole/ifutil.py +9c6653b9b082e15ea6834273c3a93347 usr/lib/confconsole/ipaddr.py +91378615d94b4f0b96e6caf866e40d11 usr/lib/confconsole/plugin.py +d103bb218ddb8f08b9b41ad34ef10af7 usr/lib/confconsole/plugins.d/Lets_Encrypt/add-water-client +a0e4d15a34ed847cfda2fe5335a5ea8c usr/lib/confconsole/plugins.d/Lets_Encrypt/add-water-srv +3f1b243623b6f4738eabc9cf94cf3366 usr/lib/confconsole/plugins.d/Lets_Encrypt/cert_auto_renew.py +5d073db60c03d50fb8cddc323cd03f36 usr/lib/confconsole/plugins.d/Lets_Encrypt/dehydrated-wrapper +5f6f2ddf556f1506a30e0b73314d8100 usr/lib/confconsole/plugins.d/Lets_Encrypt/description +df807162181c62ca071b97e0f3af1e45 usr/lib/confconsole/plugins.d/Lets_Encrypt/dns_01.py +29c1998bfc1bedd46207426ce7fc0cf6 usr/lib/confconsole/plugins.d/Lets_Encrypt/get_certificate.py +fb6a43e3048894d4d465ae8200c1f2f5 usr/lib/confconsole/plugins.d/Mail_Relaying/description +45083377baf7650b395e461bdaa787df usr/lib/confconsole/plugins.d/Mail_Relaying/mail_relay.py +9ff1045794ba0ff0dfd05eececdfd455 usr/lib/confconsole/plugins.d/Mail_Relaying/mail_relay.sh +259f6685a6a000e2269be5a3ec501d02 usr/lib/confconsole/plugins.d/Proxy_Settings/apt.py +e6515a40dc270d8a711727159896f908 usr/lib/confconsole/plugins.d/Proxy_Settings/description +892bd2f92647c1680a5908bbcd221f6e usr/lib/confconsole/plugins.d/Region_Config/description +dd88963e7aba81f49bef3483973896b1 usr/lib/confconsole/plugins.d/Region_Config/keyboard.py +c296bf3cde865f46d98481d63da79517 usr/lib/confconsole/plugins.d/Region_Config/locales.py +dbe696c1ecbc4411c9feac5d0297ec21 usr/lib/confconsole/plugins.d/Region_Config/tzdata.py +74971f785aa0c5a972183f210fcb0462 usr/lib/confconsole/plugins.d/System_Settings/Confconsole_auto_start.py +54b9292db446a05a34465f29d778ab5c usr/lib/confconsole/plugins.d/System_Settings/Secupdates_adv_conf.py +eb22c06d5868f885431ea8582dda08e4 usr/lib/confconsole/plugins.d/System_Settings/Security_Update.py +a295e21705e8a59a9d3506b9a9590f80 usr/lib/confconsole/plugins.d/System_Settings/description +46e67e027b3a236889c0efa062a8b7a0 usr/lib/confconsole/plugins.d/System_Settings/hostname.py +c4b5d3e418a4790689a9ae454bb74749 usr/lib/confconsole/plugins.d/example.py +eb6c611dbaf5afa300d49755b571152a usr/share/confconsole/autostart/confconsole-auto +e031d1f60700e951cf311dc1bb6cf427 usr/share/confconsole/letsencrypt/dehydrated-confconsole.config +dc694e701c15d9c83ed270a7c37c61b3 usr/share/confconsole/letsencrypt/dehydrated-confconsole.cron +80bd968880faf4404eb6cc28bf359593 usr/share/confconsole/letsencrypt/dehydrated-confconsole.domains +ac9137e06d48bdc0ca9a789dc87d06fc usr/share/confconsole/letsencrypt/dehydrated-confconsole.hook-dns-01.sh +57c480ac97601f2b510473bc3aa16ac0 usr/share/confconsole/letsencrypt/dehydrated-confconsole.hook-http-01.sh +a3209b4db7d08de9feca8793c9af1a48 usr/share/confconsole/letsencrypt/index.html +a355092c3bd43623982be816d7791982 usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_cloudflare.yml +96c52a96c85c620b6e2d8ac7ea55e117 usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_example.yml +5b2e8105b7aeae706762aeee82ee7336 usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_route53.yml +e257660aa2cc7b1eaeb6aedf7432ac59 usr/share/doc/confconsole/Lets_encrypt.rst.gz +a3669376af9ba16caa311032d48b7b47 usr/share/doc/confconsole/Mail_relay.rst +32fe69fb884b36498f642170299bdf6e usr/share/doc/confconsole/Networking.rst +97798f774fdb8e41cefa9adda61e9300 usr/share/doc/confconsole/Plugins.rst.gz +36f800426b5187434462f5b06ec7d301 usr/share/doc/confconsole/Proxy_settings.rst +f8388f428723005621023628ac3031f2 usr/share/doc/confconsole/README.gz +c9ea765c96c4bc58062bc7072e0f1524 usr/share/doc/confconsole/Region_config.rst +29a4ba329f4d42eb7b4ffec7949e0c7f usr/share/doc/confconsole/RelNotes-0.9.1.txt +f3f950743532991ade629a836c7380ca usr/share/doc/confconsole/RelNotes-0.9.2.txt +e5a06032b5dc076c13b2823654722d51 usr/share/doc/confconsole/RelNotes-0.9.3.txt +078f02d816e128030c5d8841b02de06b usr/share/doc/confconsole/RelNotes-0.9.4.txt +1fcfab7291c76dd596cc0ac89312a875 usr/share/doc/confconsole/RelNotes-0.9.txt +1450fbe51e3f2ffbe66f679f495d0bbf usr/share/doc/confconsole/RelNotes-1.0.0.txt +4bd4491332687ca48a1d93ace94c6937 usr/share/doc/confconsole/RelNotes-2.1.0.txt +e83bc3e078770ff0233089f407b2cf69 usr/share/doc/confconsole/System_settings.rst +3598cc212de59acaeeb4b4aee07f9974 usr/share/doc/confconsole/changelog.gz +8f8660a6a383b3bcfb47cbe7910af6ed usr/share/doc/confconsole/copyright +72bbf571b07b95bf530cd6941d3b0fcb usr/share/doc/confconsole/images/00_confconsole_core_main.png +24915b1bcb36902de7ee4e345eea920d usr/share/doc/confconsole/images/01_confconsole_core_advanced.png +ff887beec0e146d0ac556d487e16e4f4 usr/share/doc/confconsole/images/02_confconsole_core_networking.png +bc81127718a40bb72247e5005994f825 usr/share/doc/confconsole/images/03_confconsole_lets_encrypt.png +11134981a46654655ec4696c44fba49c usr/share/doc/confconsole/images/04_confconsole_mail_relay.png +02b840ee6a2be899616da5d55cccee03 usr/share/doc/confconsole/images/05_confconsole_proxy_settings.png +bbc3a0b0f1c3f0af2f64cc5e810a3daf usr/share/doc/confconsole/images/06_confconsole_region_config.png +1b11d3ad61cf27538419a8181a0c2859 usr/share/doc/confconsole/images/07_confconsole_system_settings.png +98e0b696b2c13aefd5601d305b414297 usr/share/python3/runtime.d/confconsole.rtupdate diff --git a/debian/confconsole/DEBIAN/postinst b/debian/confconsole/DEBIAN/postinst new file mode 100755 index 0000000..341971e --- /dev/null +++ b/debian/confconsole/DEBIAN/postinst @@ -0,0 +1,25 @@ +#!/bin/sh +set -e + +# Automatically added by dh_python3 +if command -v py3compile >/dev/null 2>&1; then + py3compile -p confconsole /usr/lib/confconsole +fi +if command -v pypy3compile >/dev/null 2>&1; then + pypy3compile -p confconsole /usr/lib/confconsole || true +fi + +# End automatically added section +# Automatically added by dh_systemd_start/13.24.2 +if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then + if [ -d /run/systemd/system ]; then + systemctl --system daemon-reload >/dev/null || true + if [ -n "$2" ]; then + _dh_action=restart + else + _dh_action=start + fi + deb-systemd-invoke $_dh_action 'add-water.service' >/dev/null || true + fi +fi +# End automatically added section diff --git a/debian/confconsole/DEBIAN/postrm b/debian/confconsole/DEBIAN/postrm new file mode 100755 index 0000000..eebf305 --- /dev/null +++ b/debian/confconsole/DEBIAN/postrm @@ -0,0 +1,7 @@ +#!/bin/sh +set -e +# Automatically added by dh_systemd_start/13.24.2 +if [ "$1" = remove ] && [ -d /run/systemd/system ] ; then + systemctl --system daemon-reload >/dev/null || true +fi +# End automatically added section diff --git a/debian/confconsole/DEBIAN/prerm b/debian/confconsole/DEBIAN/prerm new file mode 100755 index 0000000..1df73ea --- /dev/null +++ b/debian/confconsole/DEBIAN/prerm @@ -0,0 +1,17 @@ +#!/bin/sh +set -e +# Automatically added by dh_systemd_start/13.24.2 +if [ -z "$DPKG_ROOT" ] && [ "$1" = remove ] && [ -d /run/systemd/system ] ; then + deb-systemd-invoke stop 'add-water.service' >/dev/null || true +fi +# End automatically added section + +# Automatically added by dh_python3 +if command -v py3clean >/dev/null 2>&1; then + py3clean -p confconsole +else + dpkg -L confconsole | sed -En -e '/^(.*)\/(.+)\.py$/s,,rm "\1/__pycache__/\2".*,e' + find /usr/lib/python3/dist-packages/ -type d -name __pycache__ -empty -print0 | xargs --null --no-run-if-empty rmdir +fi + +# End automatically added section diff --git a/debian/confconsole/etc/confconsole/confconsole.conf b/debian/confconsole/etc/confconsole/confconsole.conf new file mode 100644 index 0000000..e0b242b --- /dev/null +++ b/debian/confconsole/etc/confconsole/confconsole.conf @@ -0,0 +1,20 @@ +# Confconsole config file +# +# Commented lines are ignored and default values used. +# If value declared multiple times, the last one will be applied. + +# default network interface to display in usage +#default_nic eth0 + +# disable Networking config in Advanced menu +#networking false + +# command to get public ipaddress to display in usage +#publicip_cmd curl -s https://api.ipify.org +#publicip_cmd ec2metadata --public-ipv4 + +# autostart on login - one of true|once|false +#autostart once + +# enable copy/paste +#copy_paste true diff --git a/debian/confconsole/etc/confconsole/services.txt b/debian/confconsole/etc/confconsole/services.txt new file mode 100644 index 0000000..badb882 --- /dev/null +++ b/debian/confconsole/etc/confconsole/services.txt @@ -0,0 +1,5 @@ +Web: http://$ipaddr + https://$ipaddr +Web shell: https://$ipaddr:12320 +Webmin: https://$ipaddr:12321 +SSH/SFTP: root@$ipaddr (port 22) diff --git a/debian/confconsole/etc/logrotate.d/confconsole b/debian/confconsole/etc/logrotate.d/confconsole new file mode 100644 index 0000000..f185efd --- /dev/null +++ b/debian/confconsole/etc/logrotate.d/confconsole @@ -0,0 +1,11 @@ +/var/log/confconsole/*.log { + monthly + missingok + rotate 6 + compress + delaycompress + notifempty + create 640 root root + +} + diff --git a/debian/confconsole/lib/systemd/system/add-water.service b/debian/confconsole/lib/systemd/system/add-water.service new file mode 100644 index 0000000..f4731bc --- /dev/null +++ b/debian/confconsole/lib/systemd/system/add-water.service @@ -0,0 +1,7 @@ +[Unit] +Description=Add Water +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /usr/lib/confconsole/plugins.d/Lets_Encrypt/add-water-srv -l /var/log/confconsole/letsencrypt.log diff --git a/debian/confconsole/usr/bin/confconsole b/debian/confconsole/usr/bin/confconsole new file mode 120000 index 0000000..edecbae --- /dev/null +++ b/debian/confconsole/usr/bin/confconsole @@ -0,0 +1 @@ +../lib/confconsole/confconsole.py \ No newline at end of file diff --git a/debian/confconsole/usr/bin/turnkey-lexicon b/debian/confconsole/usr/bin/turnkey-lexicon new file mode 100755 index 0000000..e6fd9f0 --- /dev/null +++ b/debian/confconsole/usr/bin/turnkey-lexicon @@ -0,0 +1,109 @@ +#!/bin/bash -e + +# fallback defaults - adjust as desired +VENV_FALLBACK=/usr/local/src/venv + +export VENV_BASE="${VENV_BASE:-$VENV_FALLBACK}" + +[[ -z "$DEBUG" ]] || set -x + +no_venv() { + cat <> Let's Encrypt >> Get Certificate >> DNS-01 + +If you encounter problems, please report to TurnKey, either via our forums: + + https://www.turnkeylinux.org/forum/support + +Or open an issue on our tracker: + + https://github.com/turnkeylinux/tracker/issues +EOF + exit 1 +} + +usage() { + cat <] [] + +Lexicon is a tool to manipulate DNS records on various DNS providers in a +standardized way. + +TurnKey Confconsole leverages lexicon to support Let's Encrypt +DNS challenges, via Dehydrated. This script ensures that lexicon is run +within it's required virtual environment. + +This wrapper script runs lexicon from a predetermined virtual environment, +with and/or . + +This wrapper script is shipped as part of confconsole and expects lexicon to +already be installed via pip to a venv, located at $VENV_FALLBACK/lexicon. +If lexicon is not already installed into the venv, then this script will +fail. + +To install/setup lexicon in venv as this wrapper expects, please run: + + Confconsole >> Advanced >> Let's Encrypt >> Get Certificate >> DNS-01 + +Once lexicon is installed as expected, this wrapper can be run independantly +of Confconsole. + +Args:: +------ + + Note that if you pass more that one argument/option to $(basename "$0"), all + args are passed directly to lexicon. + + -h|--help Display this help and exit - nothing passed to lexicon + -h|--help PROVIDER Display lexicon help for PROVIDER - all args passed to lexicon + -l|--lexicon-help Show lexicon -h|--help - -h|--help passed to lexicon + +Env vars:: +---------- + + VENV_BASE Base dir to find lexicon venv dir. + Default: $VENV_FALLBACK + DEBUG Set to enable verbose output - useful for debugging +EOF + exit 1 +} + +lexicon_bin() { + source "$VENV_BASE/lexicon/bin/activate" + "$VENV_BASE/lexicon/bin/lexicon" $(printf '%q ' "$@") +} + +if [[ "$(id -u)" -ne 0 ]]; then + echo "FATAL: $(basename "$0") must be run as root, please re-run with sudo" +fi + +if [[ ! -e "$VENV_BASE" ]]; then + no_venv "VENV_BASE ($VENV_BASE) does not exist" +elif [[ ! -d "$VENV_BASE" ]]; then + no_venv "VENV_BASE ($VENV_BASE) exists but is a file - please remove first" +elif [[ ! -d "$VENV_BASE/lexicon" ]]; then + no_venv "lexicon venv ($VENV_BASE/lexicon) does not exist (or is not a directory)" +elif [[ ! -f "$VENV_BASE/lexicon/bin/activate" ]] \ + || [[ ! -x "$VENV_BASE/lexicon/bin/lexicon" ]]; then + no_venv "lexicon venv executables missing" +fi +chown -R root:root "$VENV_BASE" + +if [[ "$#" == 1 ]]; then + case $1 in + -h|--help) usage;; + -l|--lexicon-help) lexicon_bin --help;; + *) lexicon_bin "$1";; + esac +else + # note: double quotes around $@ prevents globbing and word splitting of + # individual elements, while still expanding to multiple separate args + lexicon_bin "$@" +fi diff --git a/debian/confconsole/usr/lib/confconsole/conf b/debian/confconsole/usr/lib/confconsole/conf new file mode 120000 index 0000000..43bcbf7 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/conf @@ -0,0 +1 @@ +/etc/confconsole \ No newline at end of file diff --git a/debian/confconsole/usr/lib/confconsole/conf.py b/debian/confconsole/usr/lib/confconsole/conf.py new file mode 100644 index 0000000..509a4f4 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/conf.py @@ -0,0 +1,71 @@ +# Copyright (c) 2008-2019 Alon Swartz +# - all rights reserved +# Copyright (c) 2020 TurnKey GNU/Linux +# - all rights reserved + +import re +import os + + +class ConfconsoleConfError(Exception): + pass + + +def path(filename: str) -> str: + for dir in ("conf", "/etc/confconsole"): + path = os.path.join(dir, filename) + if os.path.exists(path): + return path + + raise ConfconsoleConfError( + f"could not find configuration file: {filename}" + ) + + +class Conf: + default_nic: str | None + publicip_cmd: str | None + networking: bool + copy_paste: bool + conf_file: str + + def _load_conf(self) -> None: + if not self.conf_file or not os.path.exists(self.conf_file): + return + + with open(self.conf_file) as fob: + for line in fob: + line = line.strip() + + if not line or line.startswith("#"): + continue + + op, val = re.split(r"\s+", line, 1) + if op == "default_nic": + self.default_nic = val + elif op == "publicip_cmd": + self.publicip_cmd = val + elif op == "networking" and val in ("true", "false"): + self.networking = True if val == "true" else False + elif op == "autostart": + pass + elif op == "copy_paste" and val.lower() in ("true", "false"): + self.copy_paste = True if val.lower() == "true" else False + else: + raise ConfconsoleConfError( + f"illegal configuration line: {line}" + ) + + def __init__(self) -> None: + self.default_nic = None + self.publicip_cmd = None + self.networking = True + self.copy_paste = True + self.conf_file = path("confconsole.conf") + self._load_conf() + + def set_default_nic(self, ifname: str) -> None: + self.default_nic = ifname + + with open(self.conf_file, "w") as fob: + fob.write(f"default_nic {ifname}\n") diff --git a/debian/confconsole/usr/lib/confconsole/confconsole.py b/debian/confconsole/usr/lib/confconsole/confconsole.py new file mode 100755 index 0000000..276d0af --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/confconsole.py @@ -0,0 +1,919 @@ +#! /usr/bin/python3 +# Copyright (c) 2008 Alon Swartz - all rights reserved +"""TurnKey Configuration Console + +Options: + -h, --help Display this help and exit + --usage Display usage screen without Advanced Menu + --nointeractive Do not display interactive dialog + --plugin= Run plugin directly + +""" + +import os +import sys +import subprocess +from subprocess import CalledProcessError +import getopt +import shlex +from string import Template +from io import StringIO +import traceback + +import dialog +from dialog import DialogError +import netinfo + +import ipaddr +import ifutil +import conf +import plugin + +from typing import NoReturn, Iterable, Any + +USAGE: str = __doc__ if __doc__ else "" +PLUGIN_PATH = os.path.join( + os.path.dirname(os.path.realpath(__file__)), "plugins.d" +) + + +class ConfconsoleError(Exception): + pass + + +def fatal(msg: str) -> NoReturn: + print(f"Error: {msg}", file=sys.stderr) + sys.exit(1) + + +def usage(msg: str | getopt.GetoptError = "") -> NoReturn: + if msg: + print(f"Error: {msg}", file=sys.stderr) + + print(f"Syntax: {sys.argv[0]}", file=sys.stderr) + print(USAGE.strip(), file=sys.stderr) + sys.exit(1) + + +def format_fields( + fields: Iterable[tuple[str, str, int, int]], +) -> list[tuple[str, int, int, str, int, int, int, int]]: + """Takes fields in format (label, field, label_length, field_length) and + outputs fields in format (label, ly, lx, item, iy, ix, field_length, + input_length) + """ + out = [] + for i, (label, field, l_length, f_length) in enumerate(fields): + out.append( + (label, i + 1, 1, field, i + 1, l_length + 1, l_length, f_length) + ) + return out + + +WrapperReturn = str | tuple[str, str] + + +class Console: + def __init__( + self, + title: str | None = None, + width: int = 60, + height: int = 20, + ) -> None: + self.width = width + self.height = height + + self.console = dialog.Dialog(dialog="dialog") + self.console.add_persistent_args(["--no-collapse"]) + self.console.add_persistent_args(["--ok-label", "Select"]) + self.console.add_persistent_args(["--cancel-label", "Back"]) + self.console.add_persistent_args(["--colors"]) + if conf.Conf().copy_paste: + self.console.add_persistent_args(["--no-mouse"]) + if title: + self.console.add_persistent_args(["--backtitle", title]) + + def _handle_exitcode(self, retcode: str) -> bool: + if retcode == "esc": + text = "Do you really want to quit?" + if self.console.yesno(text) == self.console.OK: + sys.exit(0) + return False + return True + + def _wrapper( + self, + dialog: str, + text: str, + *args: Any, + **kws: Any, + ) -> WrapperReturn: + try: + method = getattr(self.console, dialog) + except AttributeError: + raise ConfconsoleError(f"dialog not supported: {dialog}") + + ret: WrapperReturn = "" + + while 1: + try: + ret = method(f"\n{text}", *args, **kws) + except DialogError as e: + if "Can't make new window" in e.message: + self.console.msgbox( + "Terminal too small for UI, resize terminal and" + " press OK", + ok_label="OK", + ) + continue + else: + raise + + if type(ret) is str: + retcode = ret + else: + retcode = ret[0] + + if self._handle_exitcode(retcode): + break + + return ret + + def infobox(self, text: str) -> str: + v = self._wrapper("infobox", text) + assert isinstance(v, str) + return v + + def yesno(self, text: str, autosize: bool = False) -> str: + if autosize: + text += "\n " + height, width = 0, 0 + else: + height, width = 10, 30 + v = self._wrapper("yesno", text, height, width) + assert isinstance(v, str) + return v + + def msgbox( + self, + title: str, + text: str, + button_label: str = "ok", + autosize: bool = False, + ) -> str: + if autosize: + text += "\n " + height, width = 0, 0 + else: + height, width = self.height, self.width + + v = self._wrapper( + "msgbox", text, height, width, title=title, ok_label=button_label + ) + assert isinstance(v, str) + return v + + def inputbox( + self, + title: str, + text: str, + init: str = "", + ok_label: str = "OK", + cancel_label: str = "Cancel", + ) -> tuple[str, str]: + no_cancel = True if cancel_label == "" else False + v = self._wrapper( + "inputbox", + text, + self.height, + self.width, + title=title, + init=init, + ok_label=ok_label, + cancel_label=cancel_label, + no_cancel=no_cancel, + ) + assert isinstance(v, tuple) + return v + + def menu( + self, + title: str, + text: str, + choices: list[tuple[str, str]], + no_cancel: bool = False, + ) -> tuple[str, str]: + v = self._wrapper( + "menu", + text, + self.height, + self.width, + menu_height=len(choices) + 1, + title=title, + choices=choices, + no_cancel=no_cancel, + ) + assert isinstance(v, tuple) + return v + + def form( + self, + title: str, + text: str, + fields: list[tuple[str, int, int, str, int, int, int, int]], + ok_label: str = "Apply", + cancel_label: str = "Cancel", + autosize: bool = False, + ) -> tuple[str, str]: + if autosize: + text += "\n " + height, width = 0, 0 + else: + height, width = self.height, self.width + v = self._wrapper( + "form", + text, + fields, + height=height, + width=width, + form_height=len(fields) + 1, + title=title, + ok_label=ok_label, + cancel_label=cancel_label, + ) + assert isinstance(v, tuple) + return v + + +class Installer: + def __init__(self, path: str) -> None: + self.path: str = path + self.available: bool = self._is_available() + + def _is_available(self) -> bool: + if not os.path.exists(self.path): + return False + + with open("/proc/cmdline") as fob: + return "boot=live" in fob.readline().split() + + def execute(self) -> None: + if not self.available: + raise ConfconsoleError("installer is not available to be executed") + + subprocess.run([self.path]) + + +class TurnkeyConsole: + OK = "ok" + CANCEL = 1 + + def __init__( + self, + pluginManager: plugin.PluginManager, + eventManager: plugin.EventManager, + advanced_enabled: bool = True, + ) -> None: + title = "TurnKey GNU/Linux Configuration Console" + self.width = 60 + self.height = 20 + + self.console = Console(title, self.width, self.height) + + # sometimes it would be nice to have the appname be something other + # than the hostname. Allow developers to create file containing the + # appname in /etc/appname + try: + with open("/etc/appname", 'r') as fob: + self.appname = fob.read().rstrip() + except FileNotFoundError: + self.appname = f"TurnKey Linux {netinfo.get_hostname().upper()}" + + self.installer = Installer(path="/usr/bin/di-live") + + self.advanced_enabled = advanced_enabled + + self.eventManager = eventManager + self.pluginManager = pluginManager + self.pluginManager.updateGlobals({"console": self.console}) + + @staticmethod + def _get_filtered_ifnames() -> list[str]: + ifnames = [] + for ifname in netinfo.get_ifnames(): + if ifname.startswith( + ("lo", "tap", "br", "natbr", "tun", "vmnet", "veth", "wmaster") + ): + continue + ifnames.append(ifname) + + # handle bridged LXC where br0 is the default outward-facing interface + defifname = conf.Conf().default_nic + if defifname and defifname.startswith("br"): + ifnames.append(defifname) + bridgedif = ( + subprocess.run( + ["brctl", "show", defifname], + capture_output=True, + text=True, + ) + .stdout.split("\n")[1] + .split("\t")[-1] + ) + ifnames.remove(bridgedif) + + ifnames.sort() + return ifnames + + @classmethod + def _get_default_nic(cls) -> str | None: + def _validip(ifname: str) -> bool: + ip = ifutil.get_ipconf(ifname)[0] + if ip and not ip.startswith("169"): + return True + ip6 = ifutil.get_ipv6conf(ifname)[0] + if ip6: + return True + return False + + defifname = conf.Conf().default_nic + if defifname and _validip(defifname): + return defifname + + for ifname in cls._get_filtered_ifnames(): + if _validip(ifname): + return ifname + + return None + + @classmethod + def _get_public_ipaddr(cls) -> str | None: + publicip_cmd = conf.Conf().publicip_cmd + if publicip_cmd: + command = subprocess.run( + shlex.split(publicip_cmd), + capture_output=True, + text=True, + ) + if command.returncode == 0: + return command.stdout.strip() + + return None + + def _get_advmenu( + self, + ) -> tuple[ + list[tuple[str, str]], dict[str, plugin.Plugin | plugin.PluginDir] + ]: + items = [] + if conf.Conf().networking: + items.append(("Networking", "Configure appliance networking")) + + if self.installer.available: + items.append(("Install", "Install to hard disk")) + + plugin_map = {} + + for path in self.pluginManager.path_map: + plug = self.pluginManager.path_map[path] + if os.path.dirname(path) == PLUGIN_PATH: + if isinstance(plug, plugin.Plugin) and hasattr( + plug.module, "run" + ): + items.append( + ( + plug.module_name.capitalize(), + str(plug.module.__doc__), + ) + ) + elif isinstance(plug, plugin.PluginDir): + items.append( + (plug.module_name.capitalize(), plug.description) + ) + plugin_map[plug.module_name.capitalize()] = plug + + items.append(("Reboot", "Reboot the appliance")) + items.append(("Shutdown", "Shutdown the appliance")) + items.append(("Quit", "Quit the configuration console")) + + return items, plugin_map + + def _get_netmenu(self) -> list[tuple[str, str]]: + menu = [] + for ifname in self._get_filtered_ifnames(): + addr = ifutil.get_ipconf(ifname)[0] + ifmethod = ifutil.get_ifmethod(ifname) + + if addr: + desc = addr + if ifmethod: + desc += f" ({ifmethod})" + + if ifname == self._get_default_nic(): + desc += " [*]" + else: + desc = "not configured" + + menu.append((ifname, desc)) + + return menu + + def _get_ifconfmenu(self, ifname: str) -> list[tuple[str, str]]: + menu = [] + menu.append(("DHCP", "Configure networking automatically")) + menu.append(("StaticIP", "Configure networking manually")) + + if ( + not ifname == self._get_default_nic() + and len(self._get_filtered_ifnames()) > 1 + and ifutil.get_ipconf(ifname)[0] is not None + ): + menu.append(("Default", "Show this adapter's IP address in Usage")) + + return menu + + def _get_ifconftext(self, ifname: str) -> str: + addr, netmask, gateway, nameservers = ifutil.get_ipconf(ifname) + if addr is None: + return "Network adapter is not configured\n" + + text = f"IP Address: {addr}\n" + text += f"Netmask: {netmask}\n" + text += f"Default Gateway: {gateway}\n" + text += f"Name Server(s): {' '.join(nameservers)}\n" + ipv6_addr, ipv6_prefix = ifutil.get_ipv6conf(ifname) + if ipv6_addr: + text += f"IPv6 Address: {ipv6_addr}/{ipv6_prefix}\n" + text += "\n" + + ifmethod = ifutil.get_ifmethod(ifname) + if ifmethod: + text += f"Networking configuration method: {ifmethod}\n" + + if len(self._get_filtered_ifnames()) > 1: + text += "Is this adapter's IP address displayed in Usage: " + if ifname == self._get_default_nic(): + text += "yes\n" + else: + text += "no\n" + + return text + + def usage(self) -> str: + if self.advanced_enabled: + default_button_label = "Advanced Menu" + default_return_value = "advanced" + else: + default_button_label = "Quit" + default_return_value = "quit" + + # if no interfaces at all - display error and go to advanced + if len(self._get_filtered_ifnames()) == 0: + error = "No network adapters detected" + if not self.advanced_enabled: + fatal(error) + + self.console.msgbox("Error", error) + return "advanced" + + # if interfaces but no default - display error and go to networking + ifname = self._get_default_nic() + if not ifname: + error = "Networking is not yet configured" + if not self.advanced_enabled: + fatal(error) + + self.console.msgbox("Error", error) + return "networking" + + # tklbam integration + tklbamstatus_cmd = subprocess.run( + ["which", "tklbam-status"], + capture_output=True, + text=True, + ).stdout.strip() + if tklbamstatus_cmd: + tklbam_status = subprocess.run( + [tklbamstatus_cmd, "--short"], + capture_output=True, + text=True, + ).stdout + else: + tklbam_status = ( + "TKLBAM not found - please check that it's installed." + ) + + # display usage + ip_addr = self._get_public_ipaddr() + if not ip_addr: + ip_addr = ifutil.get_ipconf(ifname)[0] + + hostname = netinfo.get_hostname().upper() + + try: + with open(conf.path("services.txt")) as fob: + t = fob.read().rstrip() + text = Template(t).substitute(appname=self.appname, + hostname=hostname, + ipaddr=ip_addr) + except conf.ConfconsoleConfError: + t = "" + text = Template(t).substitute(ipaddr=ip_addr) + + ipv6_addr, ipv6_prefix = ifutil.get_ipv6conf(ifname) + if ipv6_addr: + text += f"\nIPv6: {ipv6_addr}/{ipv6_prefix}\n" + text += f"\n\n{tklbam_status}\n\n" + text += "\n" * (self.height - len(text.splitlines()) - 7) + text += " TurnKey Backups and Cloud Deployment\n" + text += " https://hub.turnkeylinux.org" + + retcode = self.console.msgbox( + f"{hostname} appliance services", + text, + button_label=default_button_label, + ) + + if retcode is not self.OK: + self.running = False + + return default_return_value + + def advanced(self) -> str: + # dont display cancel button when no interfaces at all + no_cancel = False + if len(self._get_filtered_ifnames()) == 0: + no_cancel = True + + items, plugin_map = self._get_advmenu() + + retcode, choice = self.console.menu( + "Advanced Menu", + self.appname + " Advanced Menu\n", + items, + no_cancel=no_cancel, + ) + + if retcode is not self.OK: + return "usage" + + if choice in plugin_map: + return plugin_map[choice].path + + return "_adv_" + choice.lower() + + def networking(self) -> str: + ifnames = self._get_filtered_ifnames() + + # if no interfaces at all - display error and go to advanced + if len(ifnames) == 0: + self.console.msgbox("Error", "No network adapters detected") + return "advanced" + + # if only 1 interface, dont display menu - just configure it + if len(ifnames) == 1: + self.ifname = ifnames[0] + return "ifconf" + + # display networking + text = "Choose network adapter to configure\n" + if self._get_default_nic(): + text += "[*] This adapter's IP address is displayed in Usage" + + retcode, self.ifname = self.console.menu( + "Networking configuration", text, self._get_netmenu() + ) + + if retcode is not self.OK: + return "advanced" + + return "ifconf" + + def ifconf(self) -> str: + retcode, choice = self.console.menu( + f"{self.ifname} configuration", + self._get_ifconftext(self.ifname), + self._get_ifconfmenu(self.ifname), + ) + + if retcode is not self.OK: + # if multiple interfaces go back to networking + if len(self._get_filtered_ifnames()) > 1: + return "networking" + + return "advanced" + + return "_ifconf_" + choice.lower() + + def _ifconf_staticip(self) -> str: + def _validate( + addr: str, netmask: str, gateway: str, nameservers: list[str] + ) -> list[str]: + """Validate Static IP form parameters. Returns an empty array on + success, an array of strings describing errors otherwise""" + + errors = [] + if not addr: + errors.append("No IP address provided") + elif not ipaddr.is_legal_ip(addr): + errors.append(f"Invalid IP address: {addr}") + + if not netmask: + errors.append("No netmask provided") + elif not ipaddr.is_legal_ip(netmask): + errors.append(f"Invalid netmask: {netmask}") + + for nameserver in nameservers: + if nameserver and not ipaddr.is_legal_ip(nameserver): + errors.append(f"Invalid nameserver: {nameserver}") + + if len(nameservers) != len(set(nameservers)): + errors.append("Duplicate nameservers specified") + + if errors: + return errors + + if gateway: + if not ipaddr.is_legal_ip(gateway): + return [f"Invalid gateway: {gateway}"] + else: + iprange = ipaddr.IPRange(addr, netmask) + if gateway not in iprange: + return [ + f"Gateway ({gateway}) not in IP range ({iprange})" + ] + return [] + + warnings = [] + addr = None + netmask = None + gateway = None + nameservers = None + try: + addr, netmask, gateway, nameservers = ifutil.get_ipconf( + self.ifname, True + ) + except CalledProcessError: + warnings.append( + "`route -n` returned non-0 exit code! (unable to get gateway)" + ) + except netinfo.NetInfoError: + warnings.append("failed to find default gateway!") + addr, netmask, gateway, nameservers = ifutil.get_ipconf( + self.ifname, False + ) + + if addr is None: + warnings.append("failed to ascertain current address!") + addr = "" + if netmask is None: + warnings.append("failed to ascertain current netmask!") + netmask = "" + if gateway is None: + gateway = "" + if nameservers is None: + nameservers = [] + + if warnings: + warnings.append("\nWill leave relevant fields blank") + + if warnings: + self.console.msgbox("Warning", "\n".join(warnings)) + + value = [addr, netmask, gateway] + value.extend(nameservers) + + # include minimum 2 nameserver fields and 1 blank one + if len(value) < 4: + value.append("") + + if value[-1]: + value.append("") + + field_width = 30 + field_limit = 15 + + while 1: + pre_fields: list[tuple[str, str, int, int]] = [ + ("IP Address", value[0], field_width, field_limit), + ("Netmask", value[1], field_width, field_limit), + ("Default Gateway", value[2], field_width, field_limit), + ] + + for i in range(len(value[3:])): + pre_fields.append( + ("Name Server", value[3 + i], field_width, field_limit) + ) + + fields: list[tuple[str, int, int, str, int, int, int, int]] = ( + format_fields(pre_fields) + ) + text = f"Static IP configuration ({self.ifname})" + retcode, input = self.console.form( + "Network settings", text, fields + ) + + if retcode is not self.OK: + break + + # remove any whitespaces the user might of included + input = list(map(str.strip, input)) + + # unconfigure the nic if all entries are empty + if not input[0] and not input[1] and not input[2] and not input[3]: + ifutil.unconfigure_if(self.ifname) + break + + addr, netmask, gateway = input[:3] + nameservers = input[3:] + for i in range(nameservers.count("")): + nameservers.remove("") + + err_parts = _validate(addr, netmask, gateway, nameservers) + if err_parts: + err: str = "\n".join(err_parts) + self.console.msgbox("Error", err) + else: + in_ssh = "SSH_CONNECTION" in os.environ + if not in_ssh or ( + in_ssh + and self.console.yesno( + "Warning: Changing ip while an ssh session is active" + " will drop said ssh session!", + autosize=True, + ) + == self.OK + ): + maybe_err: str | None = ifutil.set_static( + self.ifname, addr, netmask, gateway, nameservers + ) + if maybe_err is None: + break + self.console.msgbox("Error", maybe_err) + else: + break + + return "ifconf" + + def _ifconf_dhcp(self) -> str: + in_ssh = "SSH_CONNECTION" in os.environ + if not in_ssh or ( + in_ssh + and self.console.yesno( + "Warning: Changing ip while an ssh session is active will" + " drop said ssh session!", + autosize=True, + ) + == self.OK + ): + self.console.infobox(f"Requesting DHCP for {self.ifname}...") + err = ifutil.set_dhcp(self.ifname) + if err: + self.console.msgbox("Error", err) + + return "ifconf" + + def _ifconf_default(self) -> str: + conf.Conf().set_default_nic(self.ifname) + return "ifconf" + + def _adv_install(self) -> str: + text = "Please note that any changes you may have made to the\n" + text += "live system will *not* be installed to the hard disk.\n\n" + self.console.msgbox("Installer", text) + + self.installer.execute() + return "advanced" + + def _shutdown(self, text: str, opt: str) -> str: + if self.console.yesno(text) == self.OK: + self.running = False + cmd = f"shutdown {opt} now" + fgvt = os.environ.get("FGVT") + if fgvt: + cmd = f"chvt {fgvt}; " + cmd + os.system(cmd) + + return "advanced" + + def _adv_reboot(self) -> str: + return self._shutdown("Reboot the appliance?", "-r") + + def _adv_shutdown(self) -> str: + return self._shutdown("Shutdown the appliance?", "-h") + + def _adv_quit(self) -> str: + if not self.advanced_enabled: + self.running = False + return "usage" + + if ( + self.console.yesno("Do you really want to quit?", autosize=True) + == self.OK + ): + self.running = False + + return "advanced" + + _adv_networking = networking + quit = _adv_quit + + def loop(self, dialog: str | Any | None = "usage") -> None: + self.running = True + prev_dialog = dialog + standalone = dialog != "usage" # no "back" for plugins + + while dialog and self.running: + try: + if not dialog.startswith(PLUGIN_PATH): + try: + method = getattr(self, dialog) + except AttributeError: + raise ConfconsoleError( + f"dialog not supported: {dialog}" + ) + else: + try: + method = self.pluginManager.path_map[dialog].run + except KeyError: + raise ConfconsoleError( + f"could not find plugin dialog: {dialog}" + ) + + new_dialog = method() + if standalone: # XXX This feels dirty + break + prev_dialog = dialog + dialog = new_dialog + + except Exception: # TODO should only catch specific errors + sio = StringIO() + traceback.print_exc(file=sio) + + self.console.msgbox("Caught exception", sio.getvalue()) + dialog = prev_dialog + + +def main() -> None: + interactive = True + advanced_enabled = True + plugin_name = None + + if os.geteuid() != 0: + fatal("confconsole needs root privileges to run") + + try: + l_opts = ["help", "usage", "nointeractive", "plugin="] + opts, _ = getopt.gnu_getopt(sys.argv[1:], "hn", l_opts) + except getopt.GetoptError as e: + usage(e) + + for opt, val in opts: + if opt in ("-h", "--help"): + usage() + elif opt == "--usage": + advanced_enabled = False + elif opt == "--nointeractive": + interactive = False + elif opt == "--plugin": + plugin_name = val + else: + usage() + + em = plugin.EventManager() + pm = plugin.PluginManager( + PLUGIN_PATH, {"eventManager": em, "interactive": interactive} + ) + + if plugin_name: + ps = list( + filter( + lambda x: isinstance(x, plugin.Plugin), + pm.getByName(plugin_name), + ) + ) + + if len(ps) > 1: + fatal(f"plugin name ambiguous, matches all of {ps}") + elif len(ps) == 1: + p = ps[0] + + if interactive: + tc = TurnkeyConsole(pm, em, advanced_enabled) + tc.loop(dialog=p.path) # calls .run() + else: + assert isinstance(p, plugin.Plugin) + p.module.run() + else: + fatal("no such plugin") + else: + tc = TurnkeyConsole(pm, em, advanced_enabled) + tc.loop() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + subprocess.run(["stty", "sane"]) + traceback.print_exc() diff --git a/debian/confconsole/usr/lib/confconsole/ifutil.py b/debian/confconsole/usr/lib/confconsole/ifutil.py new file mode 100644 index 0000000..9537897 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/ifutil.py @@ -0,0 +1,467 @@ +from dataclasses import dataclass, field +import subprocess +from time import sleep +import os +import re + +from netinfo import InterfaceInfo +from netinfo import get_hostname + + +class IfError(Exception): + pass + + +class InvalidIPv4Error(IfError): + pass + + +class ManuallyConfiguredError(IfError): + pass + + +class InterfaceNotFoundError(IfError): + pass + + +class BadIfConfigError(IfError): + pass + + +IPV4_RE = r"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(.*)$" +IPV4_CIDR = r"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})/(\d{1,2})(.*)$" + + +def _preprocess_interface_config(config: str) -> list[str]: + """Process and Validate Networking Interface""" + lines = config.splitlines() + new_lines = [] + hostname = get_hostname() + + for line in lines: + _line = line.strip() + + if _line.startswith(("allow-hotplug", "auto", "iface", "wpa-conf")): + new_lines.append(line) + elif _line.startswith("hostname"): + if hostname: + new_lines.append(f" hostname {hostname}") + else: + continue + elif _line.startswith("post-up"): + new_lines.append(f" {_line}") + elif _line.startswith( + ("address", "netmask", "gateway", "dns-nameserver") + ): + continue + else: + raise BadIfConfigError(f"Unexpected config line: {line}") + if len(new_lines) == 2 and hostname: + new_lines.append(f" hostname {hostname}") + return new_lines + + +@dataclass +class IPv4: + p0: int + p1: int + p2: int + p3: int + + @classmethod + def parse(cls, value: str) -> "IPv4": + matches = re.match(IPV4_RE, value.strip()) + if not matches: + raise InvalidIPv4Error(f"{value!r} is not a valid IPv4") + if matches.group(5): + raise InvalidIPv4Error( + f"{value!r} is not a valid IPv4 (junk after ip segments)" + ) + + ip = cls( + int(matches.group(1)), + int(matches.group(2)), + int(matches.group(3)), + int(matches.group(4)), + ) + + if ip.p0 < 0 or ip.p0 > 255: + raise InvalidIPv4Error( + f"{value!r} is not a valid IPv4 ({ip.p0} not in range 0-255" + ) + if ip.p1 < 0 or ip.p1 > 255: + raise InvalidIPv4Error( + f"{value!r} is not a valid IPv4 ({ip.p1} not in range 0-255" + ) + if ip.p2 < 0 or ip.p2 > 255: + raise InvalidIPv4Error( + f"{value!r} is not a valid IPv4 ({ip.p2} not in range 0-255" + ) + if ip.p3 < 0 or ip.p3 > 255: + raise InvalidIPv4Error( + f"{value!r} is not a valid IPv4 ({ip.p3} not in range 0-255" + ) + return ip + + def __str__(self) -> str: + return f"{self.p0}.{self.p1}.{self.p2}.{self.p3}" + + +class NetworkInterfaces: + HEADER_UNCONFIGURED = "# UNCONFIGURED INTERFACES" + CONF_FILE = "/etc/network/interfaces" + + conf: dict[str, list[str]] = {} + unconfigured: bool = True + + _iface_opts = ["pre-up", "up", "post-up", "pre-down", "down", "post-down"] + + _bridge_opts = [ + "bridge_ports", + "bridge_ageing", + "bridge_bridgeprio", + "bridge_fd", + "bridge_gcinit", + "bridge_hello", + "bridge_hw", + "bridge_maxage", + "bridge_maxwait", + "bridge_pathcost", + "bridge_portprio", + "bridge_stp", + "bridge_waitport", + ] + + def _get_opts_subset(self, ifname: str, opts: list[str]) -> list[str]: + if ifname not in self.conf: + raise InterfaceNotFoundError(f"no existing config for {ifname}") + return [ + line.strip() + for line in self.conf[ifname] + if line.strip().split()[0] in opts + ] + + def get_iface_opts(self, ifname: str) -> list[str]: + return self._get_opts_subset(ifname, self._iface_opts) + + def get_bridge_opts(self, ifname: str) -> list[str]: + return self._get_opts_subset(ifname, self._bridge_opts) + + def duplicate(self) -> "NetworkInterfaces": + interfaces = NetworkInterfaces() + interfaces.unconfigured = self.unconfigured + interfaces.conf = { + key: [i for i in value] for key, value in self.conf.items() + } + return interfaces + + def read(self) -> None: + # clear config + self.conf = {} + self.unconfigured = False + + ifname: str | None = None + + with open(self.CONF_FILE) as fob: + for line in fob: + line = line.rstrip() + + if line == self.HEADER_UNCONFIGURED: + self.unconfigured = True + + if not line or line.startswith("#"): + continue + + if line.startswith("auto") or line.startswith("allow-hotplug"): + ifname = line.split()[1] + self.conf[ifname] = [line] + elif ifname: + self.conf[ifname].append(line) + + def write(self) -> None: + if not self.unconfigured: + raise ManuallyConfiguredError( + f"refusing to write to {self.CONF_FILE}\n" + f"header not found: {self.HEADER_UNCONFIGURED}" + ) + + with open(self.CONF_FILE, "w") as fob: + fob.write(self.HEADER_UNCONFIGURED + "\n") + for iface in self.conf.keys(): + fob.write("\n\n") + fob.write("\n".join(self.conf[iface])) + fob.write("\n") + + def gen_default_if_config(self, ifname: str) -> None: + if ifname.startswith("e"): + self.conf[ifname] = _preprocess_interface_config( + f"auto {ifname}\niface {ifname} inet dhcp" + ) + else: + raise InterfaceNotFoundError(f"no existing config for {ifname}") + + def set_dhcp(self, ifname: str) -> None: + if ifname not in self.conf: + self.gen_default_if_config(ifname) + + ifconf = _preprocess_interface_config("\n".join(self.conf[ifname])) + ifconf[1] = f"iface {ifname} inet dhcp" + self.conf[ifname] = ifconf + + self.write() + + def set_manual(self, ifname: str) -> None: + if ifname not in self.conf: + self.gen_default_if_config(ifname) + + ifconf = _preprocess_interface_config("\n".join(self.conf[ifname])) + ifconf[1] = f"iface {ifname} inet manual" + self.conf[ifname] = ifconf + + self.write() + + def set_static( + self, + ifname: str, + addr: str, + netmask: str, + gateway: str | None = None, + nameservers: list[str] | None = None, + ) -> None: + if ifname not in self.conf: + self.gen_default_if_config(ifname) + + ifconf = _preprocess_interface_config("\n".join(self.conf[ifname])) + ifconf[1] = f"iface {ifname} inet static" + + ifconf.extend([f" address {addr}", f" netmask {netmask}"]) + + if gateway: + ifconf.append(f" gateway {gateway}") + if nameservers: + joined_nameservers = " ".join(nameservers) + ifconf.append(f" dns-nameservers {joined_nameservers}") + + self.conf[ifname] = ifconf + self.write() + + def get_if_conf(self, ifname: str, key: str) -> list[str] | None: + if ifname in self.conf: + for line in self.conf: + line_list = line.strip().split() + if line_list[0] == key: + return line_list[1:] + + def get_nameservers(self, ifname: str) -> list[str] | None: + return self.get_if_conf(ifname, "dns-nameservers") or [] + + def get_address(self, ifname: str) -> str | None: + addr = self.get_if_conf(ifname, "address") + if addr: + return addr[0] + + def get_netmask(self, ifname: str) -> str | None: + addr = self.get_if_conf(ifname, "netmask") + if addr: + return addr[0] + + +def _parse_resolv(path: str) -> list[str]: + nameservers = [] + with open(path) as fob: + for line in fob: + if line.startswith("nameserver"): + nameservers.append(line.strip().split()[1]) + return nameservers + + +def get_nameservers(ifname: str) -> list[str]: + # /etc/network/interfaces + interfaces = NetworkInterfaces() + interfaces.read() + + nameservers = interfaces.get_nameservers(ifname) + if nameservers: + return nameservers + + # resolvconf (dhcp) + path = "/etc/resolvconf/run/interface" + if os.path.exists(path): + for f in os.listdir(path): + if not f.startswith(ifname) or f.endswith(".inet"): + continue + + nameservers = _parse_resolv(os.path.join(path, f)) + if nameservers: + return nameservers + + # /etc/resolv.conf (fallback) + return _parse_resolv("/etc/resolv.conf") + + +def ifup(ifname: str, force: bool = False) -> str: + # force is not the same as --force. Here force will configure regardless of + # errors + + if force: + ifup_args = ["/usr/sbin/ifup", "--force", "--ignore-errors", ifname] + else: + ifup_args = ["/usr/sbin/ifup", "--force", ifname] + + ifup_cmd = subprocess.run(ifup_args, capture_output=True, text=True) + + if not force and ifup_cmd.returncode != 0: + raise BadIfConfigError( + f"failed to bring up interface {ifname!r} error:" + f" {ifup_cmd.stderr!r}" + ) + return ifup_cmd.stderr + + +def ifdown(ifname: str, force: bool = False) -> str: + # force is not the same as --force. Here force will configure regardless of + # errors + + if force: + ifdown_args = [ + "/usr/sbin/ifdown", "--force", "--ignore-errors", ifname + ] + else: + ifdown_args = ["/usr/sbin/ifdown", "--force", ifname] + + ifdown_cmd = subprocess.run(ifdown_args, capture_output=True, text=True) + + if ifdown_cmd.returncode != 0: + raise BadIfConfigError( + f"failed to bring down interface {ifname!r}" + f" error: {ifdown_cmd.stderr!r}" + ) + return ifdown_cmd.stderr + + +def unconfigure_if(ifname: str) -> str | None: + try: + ifdown(ifname) + except Exception as e: + return str(e) + + interfaces = NetworkInterfaces() + interfaces.read() + backup_interfaces = interfaces.duplicate() + interfaces.set_manual(ifname) + + try: + subprocess.check_output(["/usr/sbin/ifconfig", ifname, "0.0.0.0"]) + except subprocess.CalledProcessError as e: + return str(e) + + try: + ifup(ifname) + except Exception as e: + backup_interfaces.write() + ifup(ifname, force=True) + + return str(e) + + +def set_static( + ifname: str, addr: str, netmask: str, gateway: str, nameservers: list[str] +) -> str | None: + try: + addr = str(IPv4.parse(addr)) + netmask = str(IPv4.parse(netmask)) + gateway = str(IPv4.parse(gateway)) + nameservers = [ + str(IPv4.parse(nameserver)) for nameserver in nameservers + ] + + ifdown(ifname, True) + + interfaces = NetworkInterfaces() + interfaces.read() + backup_interfaces = interfaces.duplicate() + + try: + interfaces.set_static(ifname, addr, netmask, gateway, nameservers) + sleep(0.5) + except Exception as e: + backup_interfaces.write() + raise e + finally: + output = ifup(ifname, True) + + net = InterfaceInfo(ifname) + if not net.address: + raise IfError(f"Error obtaining IP address\n\n{output}") + + return None + except Exception as e: # TODO - this is essentially a bare except + return str(e) + + +def set_dhcp(ifname: str) -> str | None: + try: + ifdown(ifname, True) + + interfaces = NetworkInterfaces() + interfaces.read() + backup_interfaces = interfaces.duplicate() + try: + interfaces.set_dhcp(ifname) + except Exception as e: + backup_interfaces.write() + raise e + finally: + output = ifup(ifname, True) + for _retry in range(10): + net = InterfaceInfo(ifname) + if net.address: + break + sleep(1) + if not net.address: + raise IfError(f"Error obtaining IP address\n\n{output}") + return None + except Exception as e: + return str(e) + + +def get_ipconf( + ifname: str, error: bool = False +) -> tuple[str | None, str | None, str | None, list[str]]: + net = InterfaceInfo(ifname) + for _ in range(6): + net = InterfaceInfo(ifname) + if net.address is not None and net.netmask is not None: + gateway = net.get_gateway(error) + return (net.address, net.netmask, gateway, get_nameservers(ifname)) + sleep(0.1) + + # no interfaces up + return (None, None, net.get_gateway(error), get_nameservers(ifname)) + + + +def get_ipv6conf(ifname: str) -> tuple[str | None, str | None]: + """Get IPv6 global address and prefix for an interface.""" + try: + out = subprocess.check_output( + ["ip", "-6", "addr", "show", ifname, "scope", "global"], + text=True, stderr=subprocess.DEVNULL + ) + for line in out.splitlines(): + line = line.strip() + if line.startswith("inet6"): + parts = line.split() + addr_prefix = parts[1] + addr, prefix = addr_prefix.split("/") + return (addr, prefix) + except Exception: + pass + return (None, None) + +def get_ifmethod(ifname: str) -> str | None: + interfaces = NetworkInterfaces() + interfaces.read() + conf_line = interfaces.get_if_conf(ifname, "iface") + if conf_line: + return conf_line[3] diff --git a/debian/confconsole/usr/lib/confconsole/ipaddr.py b/debian/confconsole/usr/lib/confconsole/ipaddr.py new file mode 100644 index 0000000..4999015 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/ipaddr.py @@ -0,0 +1,105 @@ +# Copyright (c) 2009 Liraz Siri - all rights reserved + +import struct +import socket +import math +from typing import Type, Union + + +def is_legal_ip(ip: str) -> bool: + try: + if ( + len([octet for octet in ip.split(".") if 255 >= int(octet) >= 0]) + != 4 + ): + return False + except ValueError: + return False + + try: + _ = socket.inet_aton(ip) + except socket.error: + return False + + return True + + +AnyIP = Union[int, str, "IP"] + + +def _str2int(ip: str) -> int: + bytes = list(map(int, ip.split("."))) + out: int = struct.unpack("!L", struct.pack("BBBB", *bytes))[0] + return out + + +def _int2str(num: int) -> str: + bytes = struct.unpack("BBBB", struct.pack("!L", num)) + return ".".join(list(map(str, bytes))) + + +class Error(Exception): + pass + + +class IP(int): + def __new__(cls: Type["IP"], arg: AnyIP) -> "IP": + if isinstance(arg, IP): + return int.__new__(cls, int(arg)) + + elif isinstance(arg, int): + return int.__new__(cls, arg) + + else: + if not is_legal_ip(arg): + raise Error(f"illegal ip ({arg})") + + return int.__new__(cls, _str2int(arg)) + + def __str__(self) -> str: + return _int2str(self) + + def __repr__(self) -> str: + return f"IP({str(self)})" + + def __add__(self, other: int) -> "IP": + return IP(int.__add__(self, other)) + + def __sub__(self, other: int) -> "IP": + return IP(int.__sub__(self, other)) + + def __and__(self, other: int) -> "IP": + return IP(int.__and__(self, other)) + + def __or__(self, other: int) -> "IP": + return IP(int.__or__(self, other)) + + def __xor__(self, other: int) -> "IP": + return IP(int.__xor__(self, other)) + + +class IPRange: + @classmethod + def from_cidr(cls: Type["IPRange"], arg: str) -> "IPRange": + address, cidr = arg.split("/") + netmask = 2**32 - (2 ** (32 - int(cidr))) + return cls(address, netmask) + + def __init__(self, ip: AnyIP, netmask: AnyIP): + self.ip = IP(ip) + self.netmask = IP(netmask) + self.network = self.ip & self.netmask + self.broadcast = self.network + 2**32 - self.netmask - 1 + self.cidr = int(32 - math.log(2**32 - self.netmask, 2)) + + def __contains__(self, ip: AnyIP) -> bool: + return self.network < IP(ip) < self.broadcast + + def __repr__(self) -> str: + return f"IPRange('{self.ip}', '{self.netmask}')" + + def fmt_cidr(self) -> str: + return f"{self.ip}/{self.cidr}" + + def __str__(self) -> str: + return self.fmt_cidr() diff --git a/debian/confconsole/usr/lib/confconsole/plugin.py b/debian/confconsole/usr/lib/confconsole/plugin.py new file mode 100644 index 0000000..73d5144 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugin.py @@ -0,0 +1,308 @@ +#!/usr/bin/python +import re +import os +import sys +import importlib.util +import importlib.abc +from collections import OrderedDict + +from types import ModuleType +from typing import Callable, Any, Iterable +import typing + + +class PluginError(Exception): + pass + + +class EventError(Exception): + pass + + +class ModuleInterface(ModuleType): + # this is a hack, we pretend all plugin modules are derived of this + module_name: str + + def run(self) -> str | None: ... + + def doOnce(self) -> None: ... + + +class EventManager: + _handlers: dict[str, list[Callable[[], None]]] + _events: set[str] + + """ Object to handle event/handler interaction """ + + def __init__(self) -> None: + self._handlers = {} + self._events = set() + + def add_event(self, event: str) -> Callable[[], None]: + """Adds event and returns callback function to `fire` event""" + self._events.add(event) + if event not in self._handlers: + self._handlers[event] = [] + + def fire() -> None: + self.fire_event(event) + + fire.__doc__ = f" Function to fire the `{event}` event " + return fire + + def add_handler(self, event: str, handler: Callable[[], None]) -> None: + """Adds a handler to an event""" + if event not in self._handlers: + self._events.add(event) + self._handlers[event] = [] + self._handlers[event].append(handler) + + def fire_event(self, event: str) -> None: + """Fire event, calling all handlers in order""" + if event not in self._events: + return # if event hasn't been registered, don't attempt to fire it + + if event not in self._handlers: + return # if event has no handlers, don't attempt to fire it + + for handler in self._handlers[event]: + try: + handler() # handler passed no arguments; can change if needed + except: # TODO don't use bare except! + sys.stderr.write( + "An Exception has occured within an event handler whilst" + " attempting to handle event '{event}'\n{format_exc()}" + ) + + +class Plugin: + """Object that holds various information about a `plugin`""" + + parent: str | None + + def __init__(self, path: str) -> None: + self.path = path + # for weighted ordering + self.real_name = os.path.basename(path) + # for menu entry + self.name = re.sub(r"^[\d]*", "", self.real_name).replace("_", " ") + + self.parent = None + + # used for imp.find_module + self.module_name = os.path.splitext(self.real_name)[0] + + spec = importlib.util.spec_from_file_location( + self.module_name, self.path + ) + assert spec is not None + assert spec.loader is not None + self.module = typing.cast( + ModuleInterface, importlib.util.module_from_spec(spec) + ) + + setattr(self.module, "PLUGIN_PATH", self.path) + + # XXX this assert had previously been commented due to issues + # - it may need to be commented out again after further testing + assert isinstance(spec.loader, importlib.abc.Loader) + spec.loader.exec_module(self.module) + + # after module is found, it's safe to use pretty name + self.module_name = os.path.splitext(self.name)[0] + + def doOnce(self): + if hasattr(self.module, "doOnce"): + self.module.doOnce() + + def updateGlobals(self, newglobals: dict[str, Any]) -> None: + for k in newglobals.keys(): + setattr(self.module, k, newglobals[k]) + + def run(self) -> str | None: + assert hasattr(self.module, "run") + ret: str | None = self.module.run() + assert ret is None or isinstance(ret, str) + + # default behaviour is to go to previous + # menu after exiting if not otherwise specified + if hasattr(self, "parent"): + return ret or self.parent + else: + return ret or "advanced" + + +class PluginDir: + """Object that mimics behaviour of a plugin but acts only as a menu node""" + + parent: str | None + plugins: list["Plugin | PluginDir"] + + def __init__(self, path: str) -> None: + self.path = path + self.real_name = os.path.basename(path) + self.name = re.sub(r"^[\d]*", "", self.real_name).replace("_", " ") + + self.parent = None + + self.module_name = self.name + + self.module_globals: dict[str, Any] = {} + + if os.path.isfile(os.path.join(path, "description")): + with open(os.path.join(path, "description"), "r") as fob: + self.description = fob.read() + else: + self.description = "" + + def updateGlobals(self, newglobals: dict[str, Any]) -> None: + self.module_globals.update(newglobals) + + def doOnce(self): ... + + def run(self) -> str | None: + items = [] + plugin_map: dict[str, Plugin | PluginDir] = {} + for plugin in self.plugins: + if isinstance(plugin, Plugin) and hasattr(plugin.module, "run"): + items.append( + ( + plugin.module_name.capitalize(), + str(plugin.module.__doc__), + ) + ) + plugin_map[plugin.module_name.capitalize()] = plugin + elif isinstance(plugin, PluginDir): + items.append( + (plugin.module_name.capitalize(), plugin.description) + ) + plugin_map[plugin.module_name.capitalize()] = plugin + + retcode, choice = self.module_globals["console"].menu( + self.module_name.capitalize(), + self.module_name.capitalize() + "\n", + items, + no_cancel=False, + ) + + if retcode != "ok": + if not self.parent: + return "advanced" + else: + return self.parent + + if choice in plugin_map: + return plugin_map[choice].path + else: + v: str = "_adv_" + choice.lower() + return v + + +class PluginManager: + """Object that holds various information about multiple `plugins`""" + + path_map: OrderedDict[str, Plugin | PluginDir] = OrderedDict() + + def __init__(self, path: str, module_globals: dict[str, Any]) -> None: + path = os.path.realpath(path) # Just in case + path_map: dict[str, Plugin | PluginDir] = {} + self.plugin_path = path + + module_globals.update( + { + "impByName": lambda *a, **k: self.impByName(*a, **k), + "impByDir": lambda *a, **k: self.impByDir(*a, **k), + "impByPath": lambda *a, **k: self.impByPath(*a, **k), + } + ) + + self.module_globals = module_globals + + if not os.path.isdir(path): + raise PluginError(f"Plugin directory '{path}' does not exist!") + + for root, dirs, files in os.walk(path): + for file_name in files: + if not file_name.endswith(".py"): + continue + + file_path = os.path.join(root, file_name) + if os.path.isfile(file_path): + if not os.stat(file_path).st_mode & 0o111 == 0: + path_map[file_path] = Plugin(file_path) + + for dir_name in dirs: + if dir_name == "__pycache__": + continue + dir_path = os.path.join(root, dir_name) + + if os.path.isdir(dir_path): + path_map[dir_path] = PluginDir(dir_path) + + self.path_map = OrderedDict( + sorted(path_map.items(), key=lambda x: x[0]) + ) + for key in path_map.keys(): + plugin = path_map[key] + if isinstance(plugin, Plugin): + # Run plugin init + plugin.updateGlobals(module_globals) + plugin.doOnce() + + for key in self.path_map: + if os.path.isdir(key): + sub_plugins = self.getByDir(key) + for plugin in sub_plugins: + plugin.parent = key + v = self.path_map[key] + assert isinstance(v, PluginDir) + v.plugins = list(sub_plugins) + + def updateGlobals(self, newglobals: dict[str, Any]) -> None: + for plugin in self.path_map.values(): + plugin.updateGlobals(newglobals) + # self.module_globals.update(newglobals) + + def getByName(self, name: str) -> Iterable[Plugin | PluginDir]: + """Return list of plugin objects matching given name""" + return filter(lambda x: x.module_name == name, self.path_map.values()) + + def getByDir(self, path: str) -> Iterable[Plugin | PluginDir]: + """Return a list of plugin objects in given directory""" + plugins = [] + for path_key in self.path_map: + if os.path.dirname(path_key) == path: + plugins.append(self.path_map[path_key]) + return plugins + + def getByPath(self, path: str) -> Plugin | PluginDir | None: + """Return plugin object with exact given path or None""" + return self.path_map[os.path.join(self.plugin_path, path)] + + # -- Used by plugins + def impByName(self, name: str) -> Iterable[ModuleInterface]: + """Return a list of python modules (from plugins excluding PluginDirs) + matching given name""" + + modules = [ + x.module for x in self.getByName(name) if isinstance(x, Plugin) + ] + + return list(filter(None, modules)) + + def impByDir(self, path: str) -> Iterable[ModuleInterface]: + """Return a list of python modules (from plugins excluding PluginDirs) + in given directory""" + + modules = [ + x.module for x in self.getByDir(path) if isinstance(x, Plugin) + ] + + return list(filter(None, modules)) + + def impByPath(self, path: str) -> ModuleInterface | None: + """Return a python module from plugin at given path or None""" + out = self.getByPath(path) + if out and isinstance(out, Plugin): + return out.module + return None diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/add-water-client b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/add-water-client new file mode 100755 index 0000000..b9a89a8 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/add-water-client @@ -0,0 +1,44 @@ +#! /usr/bin/python3 + +# Copyright (c) 2017-2020 TurnKey GNU/Linux - http://www.turnkeylinux.org +# +# Add-Water-Client - Agent to pass tokens to Add Water to serve +# Dehydrated Let's Encrypt challenges +# +# This file is part of Confconsole. +# +# Confconsole is free software; you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published by the +# Free Software Foundation; either version 3 of the License, or (at your +# option) any later version. + +from argparse import ArgumentParser +import socket +import sys + +if __name__ == "__main__": + parser = ArgumentParser( + description="add-water-client - Agent to pass tokens to add-water" + "server" + ) + token_group = parser.add_mutually_exclusive_group() + token_group.add_argument("--deploy", help="path to token file to serve") + token_group.add_argument("--clean", help="path to token file to serve") + args = parser.parse_args() + + if args.deploy: + op = "deploy" + token_path = args.deploy + elif args.clean: + op = "clean" + token_path = args.clean + else: + print("Nothing to do!") + sys.exit(1) + + host = "127.0.0.1" + port = 9977 + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((host, port)) + sock.sendall((op + " " + token_path).encode(sys.stdin.encoding)) diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/add-water-srv b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/add-water-srv new file mode 100755 index 0000000..505e3fc --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/add-water-srv @@ -0,0 +1,118 @@ +#! /usr/bin/python3 + +# Copyright (c) 2017-2019 TurnKey GNU/Linux - http://www.turnkeylinux.org +# +# Add-Water - Bottle based python HTTP server to serve +# Dehydrated Let's Encrypt challenges +# +# This file is part of Confconsole. +# +# Confconsole is free software; you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published by the +# Free Software Foundation; either version 3 of the License, or (at your +# option) any later version. + +import socket +from datetime import datetime +from argparse import ArgumentParser +from queue import Queue, Empty +from threading import Thread +from os.path import isfile, dirname, basename, abspath +from bottle import get, static_file, run, route, redirect + +# "Maintence" page to serve for all requested content, other than LE token +DEFAULT_INDEX = "/usr/share/confconsole/letsencrypt/index.html" +CUSTOM_INDEX = "/var/lib/confconsole/letsencrypt/index.html" + +if isfile(CUSTOM_INDEX): + INDEX_PATH = CUSTOM_INDEX +else: + INDEX_PATH = DEFAULT_INDEX + +INDEX_FILE = basename(INDEX_PATH) +INDEX_WEBROOT = dirname(INDEX_PATH) + +tokens = {} +token_queue = Queue() + + +def update_tokens(): + # pull in new tokens from token queue + while True: + try: + new_token = token_queue.get_nowait() + except Empty: + break + else: + + op, token = new_token.split(" ", 1) + + token_path = abspath(token) + token_file = basename(token_path) + token_webroot = dirname(token_path) + + if op == "deploy": + tokens[token_file] = token_webroot + elif op == "clean": + del tokens[token_file] + else: + raise ValueError("Unknown operation specified!") + + +@get("/.well-known/acme-challenge/") +def challenge(filename): + update_tokens() + if filename in tokens: + token_webroot = tokens[filename] + return static_file(filename, root=token_webroot) + else: + redirect("/") + + +@route("/") +def index(): + update_tokens() + return static_file(INDEX_FILE, root=INDEX_WEBROOT) + + +@route("") +def test(randompath): + _ = randompath + redirect("/") + + +def handle_token_input(): + host = "127.0.0.1" + port = 9977 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind((host, port)) + sock.listen(1) + + # client will only send 1 token per connection, might be + # more effecient way of doing this, but unsure how with dehydrated + while True: + conn, addr = sock.accept() + + token = conn.recv(4096).decode("utf8") + print(f"Got token {token} from {addr}: serving") + token_queue.put(token) + + conn.close() + + sock.close() + + +if __name__ == "__main__": + parser = ArgumentParser( + description="add-water - Bottle based python HTTP server to serve" + " Dehydrated Let's Encrypt challenges" + ) + parser.add_argument("-l", "--logfile", help="path to logfile") + args = parser.parse_args() + + input_handler = Thread(target=handle_token_input) + input_handler.start() + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{now}] Starting Server") + run(host="::", port=80) diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/cert_auto_renew.py b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/cert_auto_renew.py new file mode 100755 index 0000000..d5ee3ab --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/cert_auto_renew.py @@ -0,0 +1,60 @@ +"""Enable/Disable cert auto-renew""" + +from os import chmod, stat, path + +CRON_PATH = "/etc/cron.daily/confconsole-dehydrated" + + +def enable_cron(): + st = stat(CRON_PATH) + chmod(CRON_PATH, st.st_mode | 0o111) + + +def disable_cron(): + st = stat(CRON_PATH) + chmod(CRON_PATH, st.st_mode ^ 0o111) + + +def check_cron(): + if path.isfile(CRON_PATH): + st = stat(CRON_PATH) + return st.st_mode & 0o111 == 0o111 + else: + return "fail" + + +def run(): + enabled = check_cron() + if enabled == "fail": + msg = ( + "Cron job for dehydrated does not exist.\n" + "Please 'Get certificate' first." + ) + # console is inherited so doesn't need to be defined + r = console.msgbox("Error", msg) + else: + status = "enabled" if enabled else "disabled" + msg = """Automatic certificate renewal is currently {}""" + r = console._wrapper( + "yesno", + msg.format(status), + 10, + 30, + yes_label="Toggle", + no_label="Ok", + ) + while r == "ok": + if enabled: + disable_cron() + else: + enable_cron() + enabled = check_cron() + status = "enabled" if enabled else "disabled" + r = console._wrapper( + "yesno", + msg.format(status), + 10, + 30, + yes_label="Toggle", + no_label="Ok", + ) diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/dehydrated-wrapper b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/dehydrated-wrapper new file mode 100755 index 0000000..a5421a3 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/dehydrated-wrapper @@ -0,0 +1,349 @@ +#!/bin/bash -e + +# Copyright (c) 2016-2023 TurnKey GNU/Linux - https://www.turnkeylinux.org +# +# dehydrated-wrapper - A wrapper script for the Dehydrated +# Let's Encrypt client +# +# This file is part of Confconsole. +# +# Confconsole is free software; you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published by the +# Free Software Foundation; either version 3 of the License, or (at your +# option) any later version. + +### initial setup of vars and functions ### + +[[ "$DEBUG" = "y" ]] && set -x + +APP="$(basename "$0")" +DEHYD_ETC=/etc/dehydrated +SHARE=/usr/share/confconsole/letsencrypt +CONFIG="$DEHYD_ETC/confconsole.config" +CC_HOOK="$DEHYD_ETC/confconsole.hook.sh" +CC_DOMAINS="$DEHYD_ETC/confconsole.domains.txt" +FREQ=daily +CRON=/etc/cron.$FREQ/confconsole-dehydrated +LOG=/var/log/confconsole/letsencrypt.log +AUTHBIND80=/etc/authbind/byport/80 +[[ -f "$AUTHBIND80" ]] || touch "$AUTHBIND80" +AUTHBIND_USR=$(stat --format '%U' $AUTHBIND80) +EXIT_CODE=0 + +# space separated list of systemd services to restart +SERVICES_TO_RESTART="webmin.service" + +LE_TOS_URL=${LE_TOS_URL:-https://acme-v02.api.letsencrypt.org/directory} +LICENSE=$(curl "$LE_TOS_URL" 2>/dev/null | grep termsOfService \ + | sed 's|^.*Service": "||; s|",$||') + +SH_CONFIG=$SHARE/dehydrated-confconsole.config +SH_HOOK_HTTP=$SHARE/dehydrated-confconsole.hook-http-01.sh +SH_HOOK_DNS=$SHARE/dehydrated-confconsole.hook-dns-01.sh +SH_CRON=$SHARE/dehydrated-confconsole.cron +SH_DOMAINS=$SHARE/dehydrated-confconsole.domains +export LEXICON_CONFIG_DIR=$SHARE + +export TKL_CERTFILE="/usr/local/share/ca-certificates/cert.crt" +export TKL_KEYFILE="/etc/ssl/private/cert.key" +export TKL_COMBINED="/etc/ssl/private/cert.pem" +export TKL_DHPARAM="/etc/ssl/private/dhparams.pem" +cp $TKL_CERTFILE $TKL_CERTFILE.bak +cp $TKL_KEYFILE $TKL_KEYFILE.bak +cp $TKL_COMBINED $TKL_COMBINED.bak + +BASE_BIN_PATH="/usr/lib/confconsole/plugins.d/Lets_Encrypt" +export HTTP="add-water-client" +export HTTP_USR="www-data" +export HTTP_BIN="$BASE_BIN_PATH/$HTTP" +export HTTP_PID=/var/run/$HTTP/pid +export HTTP_LOG=$LOG +mkdir -p "$(dirname $HTTP_PID)" "$(dirname $LOG)" "$DEHYD_ETC" +touch $LOG +chown -R $HTTP_USR "$(dirname $HTTP_PID)" "$(dirname $LOG)" + +usage() { + echo "$@" + cat<] [--provider|-p ] [--log-info|-i] [--help|-h] + +TurnKey Linux wrapper script for dehydrated. + +Provides an easy and reliable way to get SSL/TLS certificates from an ACME +provider (Let's Encrypt by default), regardless of which webserver is being +used or how it is configured. + +This file is part of confconsole. + +Environment variables: + + DEBUG=y + + - $APP will be very verbose (set -x) + - INFO will be logged (default logging is WARNING & FATAL only) + +Options: + + --force|-f - Pass --force switch to dehydrated. + + This will force dehydrated to update certs + regardless of expiry. The included cron job does + this by default (after checking the expiry of + /etc/ssl/private/cert.pem). + + --register|-r - Accept Terms of Service (ToS) and register a + Let's Encrypt account. (Note if an LE account + already registered, this option makes no difference + so is safe to always use). + + Let's Encrypt ToS can currently be found here: + $LICENSE + + --challenge|-c - Use specific challenge type. + Valid types: http-01 | dns-01 + Will override and update value of CHALLENGETYPE in + $CONFIG + + --provider|-p - Specify DNS provider name to use with dns-01 + challenge. Refer to lexicon documentation for the + list of supported providers. + + --log-info|-i - INFO will be logged (default logging is + WARNING & FATAL only). + + --help|-h - Print this information and exit. + +For more info on advanced usage, please see + + https://www.turnkeylinux.org/docs/letsencrypt#advanced + +EOF + exit 1 +} + +fatal() { + echo "[$(date "+%F %T")] $APP: FATAL: $*" >&2 > >(tee -a $LOG >&2) + exit 1 +} + +warning() { + echo "[$(date "+%F %T")] $APP: WARNING: $*" | tee -a $LOG +} + +info() { + echo "[$(date "+%F %T")] $APP: INFO: $*" | tee -a "$DEBUG_LOG" +} + +copy_if_not_found() { + if [[ ! -f "$1" ]]; then + warning "$1 not found; copying default from $2" + cp "$2" "$1" + fi +} + +check_port() { + case $1 in + 80|443) local port=$1;; + *) fatal "Unexpected port: $1" + esac + netstat -ltpn | grep ":$port " | head -1 | cut -d/ -f2 \ + | sed -e 's|[[:space:]].*$||; s|[^a-zA-Z0-9]||g' +} + +stop_server() { + [[ -z "$*" ]] && return + info "stopping $1" + systemctl stop "$1" 2>&1 | tee -a $LOG + EXIT_CODE=${PIPESTATUS[0]} + while [[ "$(check_port "$PORT")" != "" ]] && [[ $EXIT_CODE -eq 0 ]]; do + info "waiting 1 second for $1 to stop" + sleep 1 + done +} + +# shellcheck disable=SC2317 # Errant "unreachable commands" warning because of trap +restart_servers() { + # intended splitting on space + for servicename in "$@"; do + info "(Re)starting $servicename" + systemctl restart "$servicename" | tee -a "$LOG" + [[ "${PIPESTATUS[0]}" -eq 0 ]] || EXIT_CODE=1 + done +} + +# shellcheck disable=SC2317 # Errant "unreachable commands" because of trap +clean_exit() { + # ideally do not use 'fatal' in this function + EXIT_CODE=$1 + if [[ "$PORT" == "80" ]]; then + check_port_80=$(check_port 80) + if [[ "$check_port_80" == 'python' ]] || [[ "$check_port_80" == 'python3' ]]; then + warning "Python is still listening on port $PORT" + info "attempting to kill add-water server" + systemctl stop add-water + fi + fi + [[ "$AUTHBIND_USR" = "$HTTP_USR" ]] || chown "$AUTHBIND_USR" "$AUTHBIND80" + if [[ $EXIT_CODE -ne 0 ]]; then + warning "Something went wrong, restoring original cert, key and combined files." + + mv "$TKL_CERTFILE.bak" "$TKL_CERTFILE" + mv "$TKL_KEYFILE.bak" "$TKL_KEYFILE" + mv "$TKL_COMBINED.bak" "$TKL_COMBINED" + else + info "Cleaning backup cert & key" + rm -f "$TKL_CERTFILE.bak" "$TKL_KEYFILE.bak" "$TKL_COMBINED.bak" + fi + if [[ "$WEBSERVER" == "tomcat"* ]]; then + update_tomcat_cert=/usr/lib/inithooks/firstboot.d/16tomcat-sslcert + if [[ -x "$update_tomcat_cert" ]]; then + $update_tomcat_cert + else + warning "Tomcat webserver found ($WEBSERVER) but can't run cert update ($update_tomcat_cert)." + fi + fi + # don't quote these service names as some values may be empty - which is anticipated + # shellcheck disable=SC2086 + restart_servers $WEBSERVER $SERVICES_TO_RESTART + if [[ $EXIT_CODE -ne 0 ]]; then + warning "Check today's previous log entries for details of error." + else + info "$APP completed successfully." + fi + systemctl stop add-water + # don't quote exit code as it may be empty (exit 0) + # shellcheck disable=SC2086 + exit $EXIT_CODE +} + +### some intial checks & set up trap ### + +trap '(exit 130)' INT +trap '(exit 143)' TERM +trap 'clean_exit' EXIT + +[[ "$EUID" = "0" ]] || fatal "$APP must be run as root" +[[ $(which dehydrated) ]] || fatal "Dehydrated not installed, or not in the \$PATH" +[[ $(which authbind) ]] || fatal "Authbind not installed" + +### read args & check config - set up whats needed ### +force= +while [[ $# -gt 0 ]]; do + arg="$1" + case $arg in + -f|--force) force="--force";; + -r|--register) REGISTER=y;; + -c|--challenge) if [[ -n $2 && ! $2 =~ ^- ]]; then + CTYPE=${2,,} + shift + fi;; + -p|--provider) if [[ -n $2 && ! $2 =~ ^- ]]; then + export PROVIDER=${2,,} + shift + fi;; + -i|--log-info) LOG_INFO=y;; + -h|--help) usage;; + *) usage "FATAL: unsupported or unknown argument: $1";; + esac + shift +done + +if [[ "$DEBUG" = "y" ]] || [[ "$LOG_INFO" = "y" ]]; then + DEBUG_LOG="$LOG" +else + DEBUG_LOG="/dev/null" + export HTTP_LOG=$DEBUG_LOG +fi + +info "started" + +copy_if_not_found "$CONFIG" "$SH_CONFIG" + +# shellcheck source=/dev/null +source "$CONFIG" + +copy_if_not_found "$DOMAINS_TXT" "$SH_DOMAINS" + +[[ "$DOMAINS_TXT" != "$CC_DOMAINS" ]] && warning "$CONFIG is not using $CC_DOMAINS" +[[ -z "$HOOK" ]] && fatal "hook script not defined in $CONFIG" + +export CHALLENGETYPE="${CTYPE:-$CHALLENGETYPE}" + +case $CHALLENGETYPE in + http-01) cp "$SH_HOOK_HTTP" "$CC_HOOK" + PORT=80 + sed -i '\|^CHALLENGETYPE=|s|=.*|="http-01"|' "$CONFIG";; + dns-01) cp "$SH_HOOK_DNS" "$CC_HOOK" + PORT=443 + sed -i '\|^CHALLENGETYPE=|s|=.*|="dns-01"|' "$CONFIG";; + *) fatal "Unexpected challenge type: $CHALLENGETYPE" +esac + +[[ "$HOOK" != "$CC_HOOK" ]] && warning "$CONFIG is not using $CC_HOOK" +chmod +x "$HOOK" + +copy_if_not_found "$CRON" "$SH_CRON" + +if [[ "$REGISTER" = 'y' ]]; then + DEHYDRATED_REGISTER="dehydrated --register --accept-terms --config $CONFIG" + if [[ "$DEBUG" = "y" ]] || [[ "$LOG_INFO" = "y" ]]; then + $DEHYDRATED_REGISTER 2>&1 | tee -a $DEBUG_LOG + EXIT_CODE=${PIPESTATUS[0]} + else + ($DEHYDRATED_REGISTER 3>&2 2>&1 1>&3) 2>/dev/null | tee -a $LOG + EXIT_CODE=${PIPESTATUS[0]} + fi + [[ $EXIT_CODE -eq 0 ]] || fatal "dehydrated failed to register account." +fi + +### main script ### + +WEBSERVER=$(check_port $PORT) +if [[ -n "$WEBSERVER" ]]; then + info "found $WEBSERVER listening on port $PORT" + if [[ "$CHALLENGETYPE" == 'http-01' ]]; then + case $WEBSERVER in + apache2 | lighttpd | nginx ) + stop_server "$WEBSERVER";; + java ) + TOMCAT=/etc/init.d/tomcat; + if [[ -x "${TOMCAT}8" ]]; then + WEBSERVER=tomcat8; + elif [[ -f "/lib/systemd/system/tomcat9.service" ]]; then + WEBSERVER=tomcat9; + elif [[ -f "/lib/systemd/system/tomcat10.service" ]]; then + WEBSERVER=tomcat10; + else + unset WEBSERVER; + fatal "An unknown Java app is listening on port $PORT"; + fi; + stop_server $WEBSERVER;; + python | python3 ) + unset WEBSERVER; + fatal "An unknown/unexpected Python app is listening on port $PORT";; + * ) + unknown="$WEBSERVER"; + unset WEBSERVER; + fatal "An unexpected service is listening on port $PORT: $unknown";; + esac + [[ "$AUTHBIND_USR" = "$HTTP_USR" ]] || chown $HTTP_USR $AUTHBIND80 + fi +else + info "No process found listening on port $PORT; continuing" +fi + +[[ "$CHALLENGETYPE" != "dns-01" ]] && systemctl start add-water +info "running dehydrated" +if [[ "$DEBUG" = "y" ]] || [[ "$LOG_INFO" = "y" ]]; then + dehydrated --cron $force --config $CONFIG 2>&1 | tee -a $DEBUG_LOG + EXIT_CODE=${PIPESTATUS[0]} +else + (dehydrated --cron $force --config $CONFIG 3>&2 2>&1 1>&3) 2>/dev/null | tee -a $LOG + EXIT_CODE=${PIPESTATUS[0]} +fi +if [[ $EXIT_CODE -ne 0 ]]; then + fatal "dehydrated exited with a non-zero exit code." +else + info "dehydrated complete" + exit 0 +fi diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/description b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/description new file mode 100644 index 0000000..f330e75 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/description @@ -0,0 +1 @@ +Let's Encrypt free SSL certificates diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/dns_01.py b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/dns_01.py new file mode 100755 index 0000000..cd841dd --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/dns_01.py @@ -0,0 +1,202 @@ +#! /usr/bin/python3 +import sys +import subprocess +import re + +from os import makedirs, chmod, chown +from os.path import isfile, join, exists, dirname +from shutil import which, copy + +from typing import Optional + +LEXICON_SHARE_DIR = "/usr/share/confconsole/letsencrypt" +LEXICON_CONF_DIR = "/etc/dehydrated" + + +def load_config(provider: str) -> tuple[str, list[str]]: + """Loads lexicon config if present, loads example if not, + returns tuple(conf_file, list(config)) + """ + if provider in ["route53", "cloudflare"]: + example_conf = join( + LEXICON_SHARE_DIR, f"lexicon-confconsole-provider_{provider}.yml" + ) + else: + example_conf = join( + LEXICON_SHARE_DIR, "lexicon-confconsole-provider_example.yml" + ) + conf_file = join(LEXICON_CONF_DIR, f"lexicon_{provider}.yml") + config = [] + if not isfile(conf_file): + copy(example_conf, conf_file) + # ensure root read/write only as file contains sensitive info + chown(conf_file, 0, 0) # chown root:root + chmod(conf_file, 0o600) # chmod 600 (owner read/write only) + with open(conf_file) as fob: + for line in fob: + config.append(line.rstrip()) + + return conf_file, config + + +def save_config(conf_file: str, config: list[str]) -> None: + """Saves lexicon configuration""" + with open(conf_file, "w") as fob: + for line in config: + if line: + fob.write(line.rstrip() + "\n") + # ensure root read/write only as file contains sensitive info + chown(conf_file, 0, 0) # chown root:root + chmod(conf_file, 0o600) # chmod 600 (owner read/write only) + + +def run_command( + command: list[str], env: Optional[dict[str, str]] = None +) -> tuple[int, str]: + """Simple subprocess wrapper for running commands""" + if env is None: + env = {} + proc = subprocess.run(command, env=env, capture_output=True, text=True) + if proc.returncode != 0: + com = " ".join(command) + return ( + proc.returncode, + f"Something went wrong when running '{com}':\n\n{proc.stderr}", + ) + else: + return 0, "success" + + +def apt_install(pkgs: list[str]) -> tuple[int, str]: + """Takes a list of package names, updates apt and installs packages, + returns tuple(exit_code, message) + """ + string = "" + exit_code = 0 + env = {"DEBIAN_FRONTEND": "noninteractive"} + for command in [ + ["apt-get", "update"], + ["apt-get", "install", *pkgs, "--yes"], + ]: + exit_code, string = run_command(command, env=env) + if exit_code != 0: + return exit_code, string + return exit_code, string + + +def check_pkg(pkg: str) -> bool: + """Takes a package name and returns True if installed, otherwise False""" + p = subprocess.run( + ["dpkg", "-s", pkg], + capture_output=True, + text=True, + ) + if p.returncode == 0: + return True # package installed + return False # package not installed + + +def initial_setup() -> None: + """Check lexicon and deps are installed and ready to go + + Returns a tuple of exit code (0 = success) and message + """ + msg_start = "lexicon tool is required for dns-01 challenge, " + msg_mid = "" + msg_end = "\n\nDo you wish to continue?" + msg: str | None = "" + install_venv = False + unexpected = False + + lexicon_bin = which("turnkey-lexicon") + venv = "/usr/local/src/venv/lexicon" + if not exists(venv): + # turnkey lexicon venv wrapper not found - offer to install + install_venv = True + msg_mid = "however it is not found on your system, so installing." + elif exists(f"{venv}/bin/lexicon"): + # lexicon venv bin found - no message required + msg = None + else: + msg_mid = "but your system is in an unexpected state" + unexpected = True + if msg is not None: + msg = msg_start + msg_mid + msg_end + # console is inherited so doesn't need to be defined + ret = console.yesno(msg, autosize=True) + if ret != "ok": + return + if install_venv or unexpected: + pkgs = [] + pip = which("pip") + python3_venv = check_pkg("python3-venv") + if not pip: + pkgs.append("python3-pip") + if not python3_venv: + pkgs.append("python3-venv") + if pkgs: + print("Please wait while required packages are installed") + exit_code, msg = apt_install(pkgs) + if exit_code != 0: + pkgs_l = " ".join(pkgs) + console.msgbox( + "Error", f"Apt installing {pkgs_l} failed:\n\n{msg}" + ) + makedirs(dirname(venv), exist_ok=True) + venv_pip = join(venv, "bin/pip") + for comment_command in [ + ("venv is set up", ["/usr/bin/python3", "-m", "venv", venv]), + ( + "lexicon is installed (into venv)", + [venv_pip, "install", "dns-lexicon[full]"], + ), + ]: + comment, command = comment_command + assert isinstance(command, list) + if comment: + print(f"Please wait while {comment}") + exit_code, msg = run_command(command) + if exit_code != 0: + com = " ".join(command) + console.msgbox("Error", f"Command '{com}' failed:\n\n{msg}") + return None + + lexicon_bin = which("turnkey-lexicon") + if not lexicon_bin: + console.msgbox( + "Error", + "Could not find 'turnkey-lexicon'? Should be installed with" + " Confconsole.", + ) + return None + + +def get_providers() -> tuple[list[tuple[str, str]] | None, str | None]: + """Get list of supported DNS providers from lexicon""" + lexicon_bin = which("turnkey-lexicon") + if not lexicon_bin: + return ( + None, + "turnkey-lexicon is not found on your system, is it installed?", + ) + print("Please wait while list of supported DNS providers is downloaded") + proc = subprocess.run( + [lexicon_bin, "--lexicon-help"], + encoding=sys.stdin.encoding, + capture_output=True, + ) + if proc.returncode != 0: + return None, proc.stderr.strip() + + match = re.search(r"(?<={).*(?=})", proc.stdout.strip()) + if not match: + return None, "Could not obtain DNS providers list from lexicon!" + + providers = [] + for provider in match.group().split(","): + if len(provider) > 0: + providers.append((provider, f"{provider} provider")) + + if providers: + return providers, None + return None, "DNS providers list is empty!" diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/get_certificate.py b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/get_certificate.py new file mode 100755 index 0000000..179ecc4 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Lets_Encrypt/get_certificate.py @@ -0,0 +1,444 @@ +"""Get Let's Encrypt SSl cert""" + +import requests +import subprocess + +from os import remove +from os.path import join, exists, isfile, isdir, basename, dirname +from shutil import copyfile, which +from json import JSONDecodeError +from glob import glob + +LE_INFO_URL = "https://acme-v02.api.letsencrypt.org/directory" + +TITLE = "Certificate Creation Wizard" + +DESC = """Please enter domain(s) to generate certificate for. + +To generate a single certificate for up to five domains (including subdomains), +enter each domain into a box, one domain per box. Empty boxes will be ignored. + +Wildcard domains are supported, but only when using DNS-01 challenge. Alias +will be auto generated, so should not be entered here. See: +https://www.turnkeylinux.org/docs/confconsole/letsencrypt#wildcard + +To generate multiple certificates, please consult the advanced docs: +https://www.turnkeylinux.org/docs/letsencrypt#advanced +""" + +dehydrated_conf = "/etc/dehydrated" +domain_path = join(dehydrated_conf, "confconsole.domains.txt") +d_conf_path = join(dehydrated_conf, "confconsole.config") +share = "/usr/share/confconsole" +d_conf_example = join(share, "letsencrypt/dehydrated-confconsole.config") +d_dom_example = join(share, "letsencrypt/dehydrated-confconsole.domains") + +example_domain = "example.com" +# XXX Debug paths + + +def doOnce() -> None: + global dns_01 + # impByPath inherited so doesn't need to be defined + dns_01 = impByPath("Lets_Encrypt/dns_01.py") + + +def read_conf(path: str) -> list[str]: + """Read config from path and return as a list (line to an item)""" + with open(path) as fob: + return fob.read().split("\n") + + +def write_conf(conf: list[str]) -> None: + """Writes (list of) config lines to dehydrated conf path""" + with open(d_conf_path, "w") as fob: + for line in conf: + fob.write(line.rstrip() + "\n") + + +def update_conf(conf: list[str], new_values: dict[str, str]) -> list[str]: + """Given a list of conf lines, lines which match keys from new_values + {K: V} will be updated to 'K=V' - if K does not exist, will be ignored""" + new_conf = [] + new_val_keys = list(new_values.keys()) + for line in conf: + if line is None: + continue + if "=" in line and not line.startswith("#"): + key = line.split("=", 1)[0] + if key in new_val_keys: + line = f'{key}="{new_values[key]}"' + new_conf.append(line) + write_conf(new_conf) + return new_conf + + +def get_conf_value(conf: list[str], key: str) -> str | None: + """Given a list of config lines and a key, returns the (first) + corresponding value (non case sensititive). If nothing found, returns None. + """ + for line in conf: + if not line: + continue + if "=" in line and not line.startswith("#"): + if key.lower() == line.split("=", 1)[0].lower(): + return line.split("=", 1)[1].strip('"').strip("'") + return None + + +def initial_load_conf(provider: str | None = None) -> list[str]: + """Create or update Dehydrated conf file, if not passed provider, assumes + http-01 challenge, otherwise assume dns-01. Also returns conf as list of + lines""" + src = d_conf_path + if not exists(d_conf_path): + src = d_conf_example + conf = read_conf(src) + if provider is None: # assume http-01 + new_conf = {"CHALLENGETYPE": "http-01"} + else: # assume dns-01 + new_conf = {"CHALLENGETYPE": "dns-01", "PROVIDER": provider} + conf = update_conf(conf, new_conf) + write_conf(conf) + return conf + + +def gen_alias(line: str) -> str: + return line.split(" ")[0].replace("*", "star").replace(".", "_") + + +def load_domains() -> tuple[list[str], str | None]: + """Loads domain conf, writes default config if non-existent. Expects + "/etc/dehydrated" to exist + returns a tuple of list(domains) and alias""" + if not isfile(domain_path): + copyfile(d_dom_example, domain_path) + return [example_domain, "", "", "", ""], None + else: + backup_domain_path = ".".join([domain_path, "bak"]) + copyfile(domain_path, backup_domain_path) + domains = [] + alias = None + with open(domain_path) as fob: + for line in fob: + line = line.strip() + # only read first uncommented line that contains text + if line and not line.startswith("#"): + if ">" in line: + line, alias = line.split(">", 1) + if line.startswith("*") and not alias: + alias = gen_alias(line) + domains = line.split(" ") + break + if alias: + alias = alias.strip() + while len(domains) > 5: + domains.pop() + while len(domains) < 5: + domains.append("") + return domains, alias + + +def save_domains(domains: list[str], alias: str | None = None) -> None: + """Saves domain configuration""" + for index, domain in enumerate(domains): + if ">" in domain and not alias: + domains[index], alias = map(str.strip, domain.split(">", 1)) + elif ">" in domain: + domains[index], _ = map(str.strip, domain.split(">", 1)) + new_line = " ".join(domains) + if not alias: + if new_line.startswith("*"): + alias = gen_alias(new_line) + elif "*" in new_line: + for domain in domains: + if domain.startswith("*"): + alias = gen_alias(domain) + break + if alias: + new_line = f"{new_line} > {alias}" + with open(domain_path) as fob: + old_dom_file = fob.readlines() + found_line = False + with open(domain_path, "w") as fob: + for line in old_dom_file: + if line.startswith("#"): + fob.write(line) + elif not found_line: + fob.write(new_line + "\n") + found_line = True + else: + fob.write(line) + + +def invalid_domains(domains: list[str], challenge: str) -> str | None: + """Validates well known limitations of domain-name specifications + doesn"t enforce when or if special characters are valid. Returns a + string if domains are invalid explaining why, otherwise returns None""" + if domains[0] == "": + return ( + f"Error: At least one domain must be provided in {domain_path}" + " (with no preceeding space)" + ) + for domain in domains: + if ">" in domain: + domain, alias = map(str.strip, domain.split(">", 1)) + if len(domain) != 0: + if len(domain) > 254: + return ( + f"Error in {domain}: Domain names must not exceed 254" + " characters" + ) + if domain.count(".") < 1: + return ( + f"Error in {domain}: Domain may not have less than 2" + " segments" + ) + for part in domain.split("."): + if not 0 < len(part) < 64: + return ( + f"Error in {domain}: Domain segments may not be" + " larger than 63 characters or less than 1" + ) + elif domain.startswith("*") and challenge.startswith("http"): + return ( + f"Error in {domain}: Wildcard domains are only valid with" + " DNS-01 challenge" + ) + return None + + +def run() -> None: + field_width = 60 + field_names = ["domain1", "domain2", "domain3", "domain4", "domain5"] + + canceled = False + + tos_url = None + msg = "" + try: + response = requests.get(LE_INFO_URL) + tos_url = response.json()["meta"]["termsOfService"] + except ConnectionError: + msg = "Connection error. Failed to connect to " + LE_INFO_URL + except JSONDecodeError: + msg = "Data error, no JSON data found" + except KeyError: + msg = "Data error, no value found for 'terms-of-service'" + if not tos_url: + console.msgbox("Error", msg, autosize=True) + return + + ret = console.yesno( + "Before getting a Let's Encrypt certificate, you must agree to the" + " current Terms of Service.\n\n" + "You can find the current Terms of Service here:\n\n" + f"{tos_url}\n\n" + "Do you agree to the Let's Encrypt Terms of Service?", + autosize=True, + ) + if ret != "ok": + return + + if not isdir(dehydrated_conf): + console.msgbox( + "Error", + f"Dehydrated not installed or {dehydrated_conf} not found," + " dehydrated can be installed via apt from the Debian repos.\n\n" + "More info: www.turnkeylinux.org/docs/letsencrypt", + autosize=True, + ) + return + + ret, challenge = console.menu( + "Challenge type", + "Select challenge type to use", + [ + ("http-01", "Requires public web access to this system"), + ("dns-01", "Requires your DNS provider to provide an API"), + ], + ) + if ret != "ok": + return + + if challenge == "http-01": + ret = console.yesno( + "DNS must be configured before obtaining certificates." + " Incorrectly configured DNS and excessive attempts could" + " lead to being temporarily blocked from requesting" + " certificates.\n\n" + "You can check for a valid 'A' DNS record for your domain via" + " Google:\n https://toolbox.googleapps.com/apps/dig/\n\n" + "Do you wish to continue?", + autosize=True, + ) + if ret != "ok": + return + d_conf = initial_load_conf() + write_conf(d_conf) + + elif challenge == "dns-01": + dns_01.initial_setup() + conf = "" + l_conf_possible = glob(join(dns_01.LEXICON_CONF_DIR, "lexicon_*.yml")) + if len(l_conf_possible) == 0: + conf = None + elif len(l_conf_possible) == 1: + conf = l_conf_possible[0] + elif len(l_conf_possible) >= 2: + console.msgbox( + "Error", + "Multiple lexicon_*.yml conf files found in" + f" {dns_01.LEXICON_CONF_DIR}, please ensure there is only one", + autosize=True, + ) + return + + if conf: + provider = basename(conf).split("_", 1)[1][:-4] + else: + providers, err = dns_01.get_providers() + if err: + console.msgbox("Error", err, autosize=True) + return + if not providers: + console.msgbox( + "Error", + "No providers found, please report to TurnKey", + autosize=True, + ) + return + ret, provider = console.menu( + "DNS providers list", + "Select DNS provider you'd like to use", + providers, + ) + if ret != "ok": + return + + if provider == "auto" and not which("nslookup"): + ret = console.yesno( + "nslookup tool is required to use dns-01 challenge with" + " auto provider.\n\n" + "Do you wish to install it now?", + autosize=True, + ) + if ret != "ok": + return + returncode, message = dns_01.apt_install(["dnsutils"]) + if returncode != 0: + console.msgbox("Error", message, autosize=True) + return + if not provider: + console.msgbox("Error", "No provider selected", autosize=True) + + d_conf = initial_load_conf(provider) + conf_file, config = dns_01.load_config(provider) + if len(config) > 12: + console.msgbox( + "Error", + "Config file too big - needs to be 12 lines or less", + autosize=True, + ) + return + elif len(config) < 12: + config.extend([""] * (12 - len(config))) + fields = [ + ("", 1, 0, config[0], 1, 10, field_width, 255), + ("", 2, 0, config[1], 2, 10, field_width, 255), + ("", 3, 0, config[2], 3, 10, field_width, 255), + ("", 4, 0, config[3], 4, 10, field_width, 255), + ("", 5, 0, config[4], 5, 10, field_width, 255), + ("", 6, 0, config[5], 6, 10, field_width, 255), + ("", 7, 0, config[6], 7, 10, field_width, 255), + ("", 8, 0, config[7], 8, 10, field_width, 255), + ("", 9, 0, config[8], 9, 10, field_width, 255), + ("", 10, 0, config[9], 10, 10, field_width, 255), + ("", 11, 0, config[10], 11, 10, field_width, 255), + ("", 12, 0, config[11], 12, 10, field_width, 255), + ] + ret, values = console.form( + "Lexicon configuration", + "Review and adjust current lexicon configuration as" + "necessary.\n\n Please see https://www.turnkeylinux.org/docs/" + "confconsole/letsencrypt#dns-01", + fields, + autosize=True, + ) + if ret != "ok": + return + + if config != values: + dns_01.save_config(conf_file, values) + + domains, alias = load_domains() + m = invalid_domains(domains, challenge) + + if m: + ret = console.yesno( + (str(m) + "\n\nWould you like to ignore and overwrite data?") + ) + if ret == "ok": + remove(domain_path) + domains, alias = load_domains() + else: + return + + values = domains + + while True: + while True: + fields = [ + ("Domain 1", 1, 0, values[0], 1, 10, field_width, 255), + ("Domain 2", 2, 0, values[1], 2, 10, field_width, 255), + ("Domain 3", 3, 0, values[2], 3, 10, field_width, 255), + ("Domain 4", 4, 0, values[3], 4, 10, field_width, 255), + ("Domain 5", 5, 0, values[4], 5, 10, field_width, 255), + ] + ret, values = console.form(TITLE, DESC, fields, autosize=True) + + if ret != "ok": + canceled = True + break + + msg = invalid_domains(values, challenge) + if msg: + console.msgbox("Error", msg) + continue + + if ret == "ok": + ret2 = console.yesno( + "This will overwrite any previous settings (saving a" + " backup) and check for certificate. Continue?" + ) + if ret2 == "ok": + save_domains(values) + break + + if canceled: + break + + # User has accepted ToS as part of this process, so pass "--register" + + # PLUGIN_PATH is inherited so is actually defined + dehyd_wrapper = join(dirname(PLUGIN_PATH), "dehydrated-wrapper") + dehydrated_bin = [ + "/bin/bash", + dehyd_wrapper, + "--register", + "--log-info", + "--challenge", + challenge, + ] + if challenge == "dns-01": + dehydrated_bin.append("--provider") + dehydrated_bin.append(provider) + proc = subprocess.run( + dehydrated_bin, + capture_output=True, + text=True, + ) + if proc.returncode == 0: + break + else: + console.msgbox("Error!", proc.stderr) diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Mail_Relaying/description b/debian/confconsole/usr/lib/confconsole/plugins.d/Mail_Relaying/description new file mode 100644 index 0000000..0de9c6f --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Mail_Relaying/description @@ -0,0 +1 @@ +Enable mail relaying to a remote server diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Mail_Relaying/mail_relay.py b/debian/confconsole/usr/lib/confconsole/plugins.d/Mail_Relaying/mail_relay.py new file mode 100755 index 0000000..6543935 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Mail_Relaying/mail_relay.py @@ -0,0 +1,173 @@ +"""Setup relaying""" + +import ssl +import socket +import sys +from smtplib import SMTP, SMTP_SSL, SMTPException +import os +import subprocess + +TITLE = "Mail Relay" + +TEXT = ( + "By default, TurnKey servers send e-mail directly. An SMTP relay provides" + " more robust mail deliverability.\n\n" + "Send up to 9000 emails per month with a free Brevo account. To sign up," + " open the below URL in your web browser and follow the prompts:\n\n" + "https://hub.turnkeylinux.org/email" +) + +FORMNOTE = ( + "Please enter the settings below.\n\n" + "Note: The relay authentication procedure requires the user password to be" + " stored in plain text at /etc/postfix/sasl_passwd (readable only by" + " root). If this is not what you want, you should cancel this" + " configuration step." +) + + +def testsettings(host, port, login, password): + encoding = sys.stdin.encoding + + # login username and password must be string + def bytes2string(string): + if type(string) is bytes: + return string.decode(encoding) + return string + + host = host.encode(encoding) + port = int(port) + login = bytes2string(login) + password = bytes2string(password) + + try: # SSL + smtp = SMTP_SSL(host, port) + ret, msg = smtp.login(login, password) + smtp.quit() + + if ret == 235: # 2.7.0 Authentication successful + return True, None + except (ssl.SSLError, SMTPException): + pass + except socket.gaierror as e: + return None, e.args + + try: # STARTTLS or plaintext + smtp = SMTP(host, port) + smtp.starttls() + smtp.ehlo() + ret, msg = smtp.login(login, password) + smtp.quit() + + if ret == 235: + return True, None + except (ssl.SSLError, SMTPException) as e: + ret, msg = e.args[0], bytes2string(e.args[1]) + pass + + return False, (ret, msg) + + +def run(): + host = "localhost" + port = "25" + login = "" + password = "" + + cmd = os.path.join(os.path.dirname(__file__), "mail_relay.sh") + + # console is inherited so doesn"t need to be defined + retcode, choice = console.menu( + TITLE, + TEXT, + [ + ("SendinBlue", "TurnKey's preferred SMTP gateway"), + ("Custom", "Custom mail relay configuration"), + ("Deconfigure", "Erase current mail relay settings"), + ], + ) + + if choice: + if choice == "Deconfigure": + proc = subprocess.run( + [cmd, "deconfigure"], capture_output=True, text=True + ) + if proc.returncode != 0: + console.msgbox( + "Error", + proc.stderr, + ) + return + + console.msgbox( + TITLE, + "The mail relay settings were succesfully erased." + " No relaying will take place from now on.", + ) + return + + if choice == "Brevo": + host = "smtp-relay.brevo.com" + port = "587" + + field_width = field_limit = 100 + + while 1: + fields = [ + ("Host", 1, 0, host, 1, 10, field_width, field_limit), + ("Port", 2, 0, port, 2, 10, field_width, field_limit), + ("Login", 3, 0, login, 3, 10, field_width, field_limit), + ("Password", 4, 0, password, 4, 10, field_width, field_limit), + ] + + retcode, values = console.form(TITLE, FORMNOTE, fields) + + if retcode != "ok": + console.msgbox( + TITLE, + "You have cancelled the configuration process. No relaying" + " of mail will be performed.", + ) + return + + host, port, login, password = tuple(values) + + if not login: + ret = console.yesno( + "No login username provided. Unable to test SMTP" + " connection before configuring.\n\n" + "Are you sure you want to configure SMTP forwarding with" + " no login credentials?", + autosize=True, + ) + if ret != "ok": + return + else: + break + + else: + success, error_msg = testsettings(*values) + if success: + console.msgbox( + TITLE, + "SMTP connection test successful.\n\n" + "Ready to configure Postfix.", + ) + break + + else: + console.msgbox( + TITLE, + "Could not connect with supplied parameters.\n\n" + "Error code: {}\n\nMessage:\n\n {}\n\nPlease" + " check config and try again.".format(*error_msg), + ) + return + + proc = subprocess.run( + [cmd, host, port, login, password], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + console.msgbox("Error", proc.stderr) diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Mail_Relaying/mail_relay.sh b/debian/confconsole/usr/lib/confconsole/plugins.d/Mail_Relaying/mail_relay.sh new file mode 100755 index 0000000..61dc7bd --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Mail_Relaying/mail_relay.sh @@ -0,0 +1,78 @@ +#!/bin/bash -ex + +fatal() { echo "fatal [$(basename $0)]: $@" 1>&2; exit 1; } +warning() { echo "warning [$(basename $0)]: $@" ; } +info() { echo "info [$(basename $0)]: $@"; } + +usage() { +cat<> $cfgfile + for (( i = 0; i < ${#options[@]}; i++ )); do + _value=${values[i]} + if [[ "$_value" == "NULL" ]]; then + _value= + fi + sed -i "/${options[$i]}/d; \$a${options[i]} = ${_value}" $cfgfile + done + + cat << EOF > $pwdfile +$hostport $username:$password +EOF + + chown root:root $pwdfile + chmod 600 $pwdfile + + postmap $pwdfile + postfix reload || true +} + +deconfigure_postfix() { + [[ -e $pwdfile ]] && shred -u $pwdfile + for var in "${options[@]}"; do + sed -i "/${var}/d" $cfgfile + done +} + +if [[ $# -lt 1 || $# -gt 4 ]]; then + usage +fi + +if [[ $# == 1 && $1 == 'deconfigure' ]]; then + deconfigure_postfix +else + configure_postfix "$@" +fi + +sleep 10 diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Proxy_Settings/apt.py b/debian/confconsole/usr/lib/confconsole/plugins.d/Proxy_Settings/apt.py new file mode 100755 index 0000000..ddf14db --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Proxy_Settings/apt.py @@ -0,0 +1,81 @@ +"""Set APT's HTTP Proxy""" + +from re import match, sub, MULTILINE, search +from os.path import isfile +from urllib.parse import urlparse + +CONF = "/etc/apt/apt.conf.d/80proxy" +PROXY_LINE = r'Acquire::http::Proxy "(.*)";' +PROXY_REPL = r'Acquire::http::Proxy "{}";' + + +def get_proxy() -> str: + proxy = "" + if not isfile(CONF): + return proxy + with open(CONF) as fob: + for line in fob: + lmatch = match(PROXY_LINE, line) + if lmatch: + proxy = lmatch.group(1) + return proxy + + +def set_proxy(prox: str) -> None: + if isfile(CONF): + with open(CONF) as fob: + data = fob.read() + else: + data = "" + + if search(PROXY_LINE, data): + data = sub(PROXY_LINE, PROXY_REPL.format(prox), data, 1, MULTILINE) + else: + data += "\n" + (PROXY_REPL.format(prox)) + "\n" + + with open(CONF, "w") as fob: + fob.write(data) + + +def validate_address(addr: str) -> bool: + parsed = urlparse(addr) + return bool(parsed.scheme) and len(parsed.netloc.split(".")) > 1 + + +def doOnce(): + pass + + +def run(): + original_proxy = get_proxy() + while True: + # console is inherited so doesn't need to be defined + code, prox = console.inputbox( + "Set proxy", + 'Set a HTTP Proxy. Must contain scheme "http://example.com"' + ' but not "example.com"', + init=original_proxy, + ) + if code == "ok": + if prox and not validate_address(prox): + console.msgbox( + "Invalid Proxy", + "A valid proxy address must at least have a net" + " location and scheme (http://example.com) but not" + " (example.com)", + ) + else: + if not prox and original_proxy: + # if no proxy chosen but there WAS a proxy set previously + if ( + console.yesno( + "Are you sure you want to disable apt proxy?" + ) + == "ok" + ): + set_proxy(prox) + else: + set_proxy(prox) + break + else: + break diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Proxy_Settings/description b/debian/confconsole/usr/lib/confconsole/plugins.d/Proxy_Settings/description new file mode 100644 index 0000000..d5843f6 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Proxy_Settings/description @@ -0,0 +1 @@ +Configure Proxy Settings diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/description b/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/description new file mode 100644 index 0000000..cb805f7 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/description @@ -0,0 +1 @@ +Region & time settings diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/keyboard.py b/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/keyboard.py new file mode 100755 index 0000000..128f18c --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/keyboard.py @@ -0,0 +1,56 @@ +"""Reconfigure Keyboard""" + +import subprocess +from subprocess import check_output, check_call + + +def is_installed(pkg: str) -> bool: + for line in check_output( + ["apt-cache", "policy", pkg], text=True + ).splitlines(): + if line.startswith(" Installed"): + _, val = line.split(":") + if val.strip() in ("(none)", ""): + return False + return True + + +def run(): + flag = [] + # interactive is inherited so doesn't need to be defined + if interactive: + to_install = [] + for package in ["console-setup", "keyboard-configuration"]: + if not is_installed(package): + to_install.append(package) + if to_install: + ret = console.yesno( + "The following package(s) is/are required for this" + " operation:\n\n" + f" {' '.join(to_install)}\n\n" + "Do you wish to install now?", + autosize=True, + ) + + if ret == "ok": + check_call(["apt-get", "-y", "install", *to_install]) + else: + return + + ret = console.yesno( + "Note: If new keyboard settings are not applied, you may need" + " to reboot your operating system. Continue with" + " configuration?", + autosize=True, + ) + + if ret != 0: + return + else: + flag = ["-f", "noninteractive"] + + subprocess.run(["dpkg-reconfigure", "keyboard-configuration", *flag]) + subprocess.run( + ["udevadm", "trigger", "--subsystem-match=input", "--action=change"] + ) + subprocess.run(["service", "keyboard-setup", "restart"]) diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/locales.py b/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/locales.py new file mode 100755 index 0000000..1282b39 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/locales.py @@ -0,0 +1,32 @@ +"""Reconfigure locales""" + +import subprocess +import os + + +def run(): + # interactive & console are inherited so doesn't need to be defined + if interactive: + console.msgbox( + "Locale", + 'We STRONGLY recommend you choose "None" as your default locale.', + autosize=True, + ) + + subprocess.run(["dpkg-reconfigure", "locales"]) + else: + locale = os.getenv("LOCALE") + + if locale: + subprocess.run(["locale-gen", locale]) + subprocess.run( + [ + "update-locale", + f"LANG={locale}", + f"LANGUAGE={locale}", + f"LC_ALL={locale}", + ] + ) + subprocess.run( + ["dpkg-reconfigure", "-f", "noninteractive", "locales"] + ) diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/tzdata.py b/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/tzdata.py new file mode 100755 index 0000000..8a0a651 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/Region_Config/tzdata.py @@ -0,0 +1,21 @@ +"""Reconfigure TZdata""" + +import subprocess +import os + + +def run(): + flag = [] + # interactive is inherited so doesn't need to be defined + if not interactive: + tz = os.getenv("TZ") + + if tz: + with open("/etc/timezone", "w") as f: + f.write(tz) + + flag = ["-f", "noninteractive"] + + subprocess.run( + ["dpkg-reconfigure", *flag, "tzdata"], stderr=subprocess.DEVNULL + ) diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/Confconsole_auto_start.py b/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/Confconsole_auto_start.py new file mode 100755 index 0000000..8c481a3 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/Confconsole_auto_start.py @@ -0,0 +1,57 @@ +"""Enable/Disable Confconsole autostart on login""" + +from os import chmod, stat, path + +CONFCONSOLE_AUTO = path.expanduser("~/.bashrc.d/confconsole-auto") + + +def enable_autostart() -> None: + st = stat(CONFCONSOLE_AUTO) + chmod(CONFCONSOLE_AUTO, st.st_mode | 0o111) + + +def disable_autostart() -> None: + st = stat(CONFCONSOLE_AUTO) + chmod(CONFCONSOLE_AUTO, st.st_mode ^ 0o111) + + +def check_autostart() -> str | bool: + if path.isfile(CONFCONSOLE_AUTO): + st = stat(CONFCONSOLE_AUTO) + return st.st_mode & 0o111 == 0o111 + else: + return "fail" + + +def run(): + enabled = check_autostart() + if enabled == "fail": + msg = "Auto-start file for Confconsole does not exist.\n" + # console is inherited so doesn't need to be defined + r = console.msgbox("Error", msg) + else: + status = "enabled" if enabled else "disabled" + msg = """Confconsole Auto start is currently {}""" + r = console._wrapper( + "yesno", + msg.format(status), + 10, + 30, + yes_label="Toggle", + no_label="Ok", + ) + while r == "ok": + if enabled: + disable_autostart() + else: + enable_autostart() + enabled = check_autostart() + status = "enabled" if enabled else "disabled" + r = console._wrapper( + "yesno", + msg.format(status), + 10, + 30, + yes_label="Toggle", + no_label="Ok", + ) diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/Secupdates_adv_conf.py b/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/Secupdates_adv_conf.py new file mode 100755 index 0000000..7e13daa --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/Secupdates_adv_conf.py @@ -0,0 +1,120 @@ +"""Config SecUpdate behaviour""" + +import os +from os.path import exists, islink +from typing import Optional + +FILE_PATH = "/etc/cron-apt/action.d/5-install" +CONF_DEFAULT = "/etc/cron-apt/action-available.d/5-install.default" +CONF_ALT = "/etc/cron-apt/action-available.d/5-install.alt" + +doc_url = "www.turnkeylinux.org/secupdates#issue-res" + +info_default = """ +This is the historic and default TurnKey cronapt behaviour. Only packages \ +from the repos listed in security.sources.list will be installed. \ +Missing dependencies (extremely rare) will not be installed and will cause \ +package removal. This package removal may cause one or more services to fail.\ +""" + +info_alternate = """ +This is a new option which is similar to the default. However, it will not \ +allow removal of packages. This will maximise uptime of all services, but \ +conversely, may also allow services with unpatched security vulnerabilities \ +to continue running.""" + + +def new_link(link_path: str, target_path: str) -> None: + try: + os.unlink(link_path) + except FileNotFoundError: + pass + os.symlink(target_path, link_path) + + +def change_link(new_path: str) -> None: + new_link(FILE_PATH, new_path) + + +def check_paths() -> tuple[int, list[str]]: + errors: list[str] = [] + for _path in [FILE_PATH, CONF_DEFAULT, CONF_ALT]: + if not exists(_path): + errors.append(f"Path not found:\n{_path}") + if errors: + return 2, errors + if islink(FILE_PATH): + _target_path = os.readlink(FILE_PATH) + if _target_path.startswith("../action-available.d/5-install"): + _target_path = _target_path.replace("..", "/etc/cron-apt") + if _target_path == CONF_DEFAULT: + return 0, ["default"] + elif _target_path == CONF_ALT: + return 0, ["alternate"] + else: + return 1, [f"Unexpected link target:\n{_target_path}"] + else: + return 1, [f"{FILE_PATH}\nis not a symlink"] + + +def button_label(current: str) -> str: + options = ["default", "alternate"] + try: + options.remove(current) + except ValueError: + pass + + other = options[0] + msg = f"Enable '{other}'" + + return f"{msg:^20}" + + +def get_details(choice: str) -> Optional[str]: + if choice == "default": + return info_default + elif choice == "alternate": + return info_alternate + else: + return None + + +def run() -> None: + retcode, data = check_paths() + if retcode: + msg = "Error(s) encountered while checking status:" + for message in data: + msg = f"{msg}\n{message}" + msg = f"{msg}\nFor more info please see\n\n{doc_url}" + r = console.msgbox("Error", msg) + else: + # if retcode == 0, then data == [status] + status = data[0] + msg = ( + "Current SecUpate Issue resolution strategy is:\n\n\t{}" + "\n{}\n\nFor more info please see\n\n{}" + ) + r = console._wrapper( + "yesno", + msg.format(status, get_details(status), doc_url), + 20, + 60, + yes_label=button_label(status), + no_label="Back", + ) + while r == "ok": + # Toggle was clicked + if data == ["default"]: + change_link(CONF_ALT) + else: + change_link(CONF_DEFAULT) + retcode, data = check_paths() + status = data[0] + r = console._wrapper( + "yesno", + msg.format(status, get_details(status), doc_url), + 20, + 60, + yes_label=button_label(status), + no_label="Back", + ) diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/Security_Update.py b/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/Security_Update.py new file mode 100755 index 0000000..a6365ff --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/Security_Update.py @@ -0,0 +1,10 @@ +"""Install Security Updates""" + +from subprocess import check_call, CalledProcessError + + +def run(): + try: + check_call(["turnkey-install-security-updates"]) + except CalledProcessError: + console.msgbox("An error occured while running security updates!") diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/description b/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/description new file mode 100644 index 0000000..a0abc9a --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/description @@ -0,0 +1 @@ +Various global settings diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/hostname.py b/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/hostname.py new file mode 100755 index 0000000..fea6855 --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/System_Settings/hostname.py @@ -0,0 +1,129 @@ +"""Update machine hostname.""" + +import re +import subprocess +from subprocess import Popen, PIPE + +TITLE = "Update Hostname" + + +def _validate_hostname(hostname): + pattern = r"^[-\w]*$" + hostname_parts = hostname.split(".") + match_parts = [] + fail_parts = [] + for part in hostname_parts: + match = re.match(pattern, part) + if match: + match_parts.append(part) + else: + fail_parts.append(part) + if len(hostname_parts) == len(match_parts): + return hostname + else: + return None + + +def _get_current_hostname(): + with open("/etc/hostname") as fob: + return fob.readline().strip() + + +def run(): + while True: + ret, new_hostname = console.inputbox( + TITLE, + "Please enter the new hostname for this machine:", + _get_current_hostname(), + ) + if ret == "ok": + valid_hostname = _validate_hostname(new_hostname) + if not valid_hostname: + console.msgbox( + TITLE, + f"Invalid hostname ({new_hostname}", + ) + continue + else: + proc = Popen(["hostname", new_hostname], stderr=PIPE) + _, out = proc.communicate() + returncode = proc.returncode + + if returncode: + console.msgbox( + TITLE, + f"{out} ({new_hostname})", + ) + continue + + new_localhost = new_hostname.split(".")[0] + + with open("/etc/hostname", "w") as fob: + fob.write(new_localhost + "\n") + + if new_localhost != new_hostname: + add_hosts = f"{new_localhost} {new_hostname}" + else: + add_hosts = new_hostname + with open("/etc/hosts", "r") as fob: + lines = fob.readlines() + with open("/etc/hosts", "w") as fob: + for line in lines: + fob.write( + re.sub( + r"^127\.0\.1\.1 .*", "127.0.1.1 " + add_hosts, line + ) + ) + + with open("/etc/postfix/main.cf", "r") as fob: + lines = fob.readlines() + with open("/etc/postfix/main.cf", "w") as fob: + for line in lines: + fob.write( + re.sub( + r"myhostname =.*", + f"myhostname = {new_hostname}", + line, + ) + ) + with open("/etc/network/interfaces", "r") as fob: + lines = fob.readlines() + with open("/etc/network/interfaces", "w") as fob: + for line in lines: + fob.write( + re.sub( + r"hostname .*", f"hostname {new_hostname}", line + ) + ) + should_restart = ( + console.yesno( + "Networking must be restarted to apply these changes. " + "However restarting networking may close your ssh " + "connection as hostname changes may effect the address " + "allocated to you by DHCP. Not restarting may have other " + "adverse effects in other software.\n\nDo you want to " + "restart networking?", + True, + ) + == "ok" + ) + + if should_restart: + proc = subprocess.run(["systemctl", "restart", "networking"]) + proc = subprocess.run( + ["postfix", "reload"], capture_output=True, text=True + ) + if proc.returncode != 0: + console.msgbox( + TITLE, + f"Error reloading postfix:\n{proc.stderr}", + ) + console.msgbox( + TITLE, + "Hostname updated successfully. Some applications" + " may require restart before the settings are" + " applied.", + ) + break + else: + break diff --git a/debian/confconsole/usr/lib/confconsole/plugins.d/example.py b/debian/confconsole/usr/lib/confconsole/plugins.d/example.py new file mode 100644 index 0000000..45aee7c --- /dev/null +++ b/debian/confconsole/usr/lib/confconsole/plugins.d/example.py @@ -0,0 +1,43 @@ +"""I will be the description""" + +# +""" +Note: plugins must be executable + + +Global Variables: + +eventManager - allows access to the event system + eventManager.add_event() adds an event of said name + eventManager.add_handler(, ) adds handler to event + eventManager.fire_event() call all handers for said event +console - allows python dialog access (see confconsole.py) + +impByName - a function, takes a name and returns all plugin modules + matching that name. +impByDir - a function, takes a path and returns all plugin modules within + that directory. +impByPath - a function, takes a path and returns the plugin module at + specified path or None. + + +Plugin Functions/Scope: + +main body - the main body is run at load time of the plugin, none of the + global variables are set at this point, neither are all the + plugins loaded. + +doOnce() - if defined is run once, after loading all plugins and before + running confconsole. + +run() - if defined is run whenever the plugin is selected, if not defined, no + menu entry is created for this plugin. +""" + + +def doOnce(): + eventManager.add_event("test_event") + + +def run(): + eventManager.fire_event("test_event") diff --git a/debian/confconsole/usr/share/confconsole/autostart/confconsole-auto b/debian/confconsole/usr/share/confconsole/autostart/confconsole-auto new file mode 100755 index 0000000..d9f1e1d --- /dev/null +++ b/debian/confconsole/usr/share/confconsole/autostart/confconsole-auto @@ -0,0 +1,45 @@ +#!/bin/bash -e +# Simple auto starter script for confconsole +# +# To enable every login: +# - set "autostart true" in /etc/confconsole/confconsole.conf +# +# To enable next login only: +# - set "autostart once" in /etc/confconsole/confconsole.conf; or +# +# To disable autostart: +# - set "autostart false" /etc/confconsole/confconsole.conf; or +# - comment out or remove "autostart" in /etc/confconsole/confconsole.conf +# - make this script non-executable +# +# Note that if this script is non-executable, or not located in ~/.bashrc.d/ +# of a root or a sudo user account, confconsole will NOT autostart, +# regardless of "autostart" value in /etc/confconsole/confconsole.conf. + + +# if "dumb" terminal (e.g. scp or others) exit straight away +if [ "$TERM" = "dumb" ]; then + return +fi + +conf=/etc/confconsole/confconsole.conf +autostart=$(grep "^autostart" $conf | tail -1 | cut -d' ' -f2) +while true; do + case "$autostart" in + once) + # disable autostart if set to "once" + sed -i "s|^autostart.*|autostart false|g" $conf + break;; + true) + break;; + *) + return;; + esac +done + +# if not root use sudo (support for sudoadmin) +if [ "$(whoami)" != "root" ]; then + SUDO=sudo +fi + +$SUDO confconsole diff --git a/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.config b/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.config new file mode 100644 index 0000000..1a7459c --- /dev/null +++ b/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.config @@ -0,0 +1,29 @@ +######################################################## +# This is the config file for dehydrated when launched # +# via confconsole on TurnKey GNU/Linux. # +# # +# It is loaded by the dehydrated-wrapper script. # +# # +# For more information about the confconsole Let's # +# Encrypt plugin and/or the dehydrated-wrapper please # +# see: # +# /usr/share/doc/confconsole/docs/Lets_Encrypt.rst # +# or: # +# https://www.turnkeylinux.org/docs/letsencrypt # +# # +# For more comprehensive example conf, see # +# /usr/share/doc/dehydrated/examples/config # +######################################################## + +BASEDIR=/var/lib/dehydrated +WELLKNOWN="${BASEDIR}/acme-challenges" +DOMAINS_TXT="/etc/dehydrated/confconsole.domains.txt" +HOOK="/etc/dehydrated/confconsole.hook.sh" +CHALLENGETYPE="http-01" + +# required for DNS-01 only - ignored by HTTP-01 challenge +PROVIDER='auto' +LEXICON_CONFIG_DIR='/etc/dehydrated' + +# staging server for testing - leave commented for production +#CA="https://acme-staging-v02.api.letsencrypt.org/directory" diff --git a/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.cron b/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.cron new file mode 100644 index 0000000..5417880 --- /dev/null +++ b/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.cron @@ -0,0 +1,34 @@ +#!/bin/bash + +export PATH="$PATH:/usr/sbin" + +CERT=/etc/ssl/private/cert.pem +RENEW=2592000 # seconds to cert expiry to try renew: 2592000 = 30 days +LOG=/var/log/confconsole/letsencrypt.log +DEHYDRATED=/usr/lib/confconsole/plugins.d/Lets_Encrypt/dehydrated-wrapper +ARG="--force" + +log() { + echo "[$(date "+%Y-%m-%d %H:%M:%S")] cron: $*" >> $LOG +} + +exit_code=0 + +cert_expire=$(/usr/bin/openssl x509 -checkend $RENEW -noout -in $CERT) \ + || exit_code=$? +log "${CERT}: ${cert_expire} within $(( RENEW / 60 / 60 / 24 )) days" + +if [[ "$exit_code" -eq 0 ]]; then + log "Nothing to do." +else + exit_code=0 + log "Attempting renewal." + dehydrated_output="$($DEHYDRATED $ARG 2>&1)" || exit_code=$? + log "$dehydrated_output" + if [[ $exit_code -ne 0 ]]; then + log "ERR: $(basename $DEHYDRATED) exited with a non-zero exit code." + exit 1 + else + log "certificate renewed" + fi +fi diff --git a/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.domains b/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.domains new file mode 100644 index 0000000..6902053 --- /dev/null +++ b/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.domains @@ -0,0 +1,5 @@ +# This is confconsole's domain.txt file - please use with confconsole +# If configuring/using Dehydrated directly, it is advised to use with +# appropriate configuration files. +example.com www.example.com ftp.example.com +# Add additional custom domains below here and confconsole will ignore diff --git a/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.hook-dns-01.sh b/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.hook-dns-01.sh new file mode 100644 index 0000000..288e6ed --- /dev/null +++ b/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.hook-dns-01.sh @@ -0,0 +1,99 @@ +#!/bin/bash -e + +# This dehydrated hook script is packaged with Confconsole. +# It is designed to be used in conjunction with the TurnKey dehydrated-wrapper +# and turnkey-lexicon wrapper, which in turn depends on lexicon installed to +# a venv (confconsole will install if needed). +# For more info, please see https://www.turnkeylinux.org/docs/letsencypt + +# DNS-01 Hook Script + +export PROVIDER_UPDATE_DELAY=${PROVIDER_UPDATE_DELAY:-"30"} +#provider 'auto' can be used since roughly v3.3.13 of lexicon. +export PROVIDER=${PROVIDER:-"auto"} + +function hook_log { + default="[$(date "+%F %T")] $(basename "$0"):" + case ${1} in + info) echo "$default INFO: ${2}";; + success) echo "$default SUCCESS: ${2}" >&2;; + fatal) echo "$default FATAL: ${2}" >&2; exit 1;; + esac +} + +for var in PROVIDER LEXICON_CONFIG_DIR TKL_KEYFILE TKL_CERTFILE TKL_COMBINED TKL_DHPARAM; do + eval "z=\$$var" + [[ -z "$z" ]] && hook_log fatal "$var is not set. Exiting..." +done + +function deploy_challenge { + local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" + + hook_log info "Deploying challenge for $DOMAIN." + hook_log info "Creating a TXT challenge-record with $PROVIDER." + turnkey-lexicon --config-dir="$LEXICON_CONFIG_DIR" \ + "$PROVIDER" create "${DOMAIN}" TXT \ + --name="_acme-challenge.${DOMAIN}." \ + --content="${TOKEN_VALUE}" + + local DELAY_COUNTDOWN=$PROVIDER_UPDATE_DELAY + while [[ $DELAY_COUNTDOWN -gt 0 ]]; do + echo -ne "${DELAY_COUNTDOWN}\033[0K\r" + sleep 1 + : $((DELAY_COUNTDOWN--)) + done +} + +function invalid_challenge() { + local DOMAIN="${1}" RESPONSE="${2}" + + hook_log fatal "Challenge response for ${DOMAIN} failed: ${RESPONSE}." +} + +function clean_challenge { + local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" + + hook_log info "Clean challenge for ${DOMAIN}." + + turnkey-lexicon --config-dir="$LEXICON_CONFIG_DIR" \ + "$PROVIDER" delete "${DOMAIN}" TXT \ + --name="_acme-challenge.${DOMAIN}." \ + --content="${TOKEN_VALUE}" +} + +function deploy_cert { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}" + + hook_log success "Cert request successful. Writing relevant files for $DOMAIN." + hook_log info "fullchain: $FULLCHAINFILE" + hook_log info "keyfile: $KEYFILE" + cat "$KEYFILE" > "$TKL_KEYFILE" + cat "$FULLCHAINFILE" > "$TKL_CERTFILE" + cat "$TKL_CERTFILE" "$TKL_KEYFILE" "$TKL_DHPARAM" > "$TKL_COMBINED" + hook_log success "Files written/created for $DOMAIN: $TKL_CERTFILE - $TKL_KEYFILE - $TKL_COMBINED." +} + +function unchanged_cert { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" + + hook_log info "cert for $DOMAIN is unchanged - nothing to do" +} + +[[ $(which turnkey-lexicon) ]] || hook_log fatal "turnkey-lexicon is not found." +if [[ "$PROVIDER" = "auto" ]]; then + [[ $(which nslookup) ]] || hook_log fatal "nslookup is not installed (provided by dnsutils package)." +fi + +HANDLER="$1"; shift +case "$HANDLER" in + deploy_challenge) + deploy_challenge "$@";; + invalid_challenge) + invalid_challenge "$@";; + clean_challenge) + clean_challenge "$@";; + deploy_cert) + deploy_cert "$@";; + unchanged_cert) + unchanged_cert "$@";; +esac diff --git a/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.hook-http-01.sh b/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.hook-http-01.sh new file mode 100644 index 0000000..5cbafd4 --- /dev/null +++ b/debian/confconsole/usr/share/confconsole/letsencrypt/dehydrated-confconsole.hook-http-01.sh @@ -0,0 +1,66 @@ +#!/bin/bash -e + +# This dehydrated hook script is packaged with Confconsole. +# It is designed to be used in conjunction with the TurnKey dehydrated-wrapper. +# For more info, please see https://www.turnkeylinux.org/docs/letsencypt + +# HTTP-01 Hook Script + +function hook_log { + default="[$(date "+%F %T")] $(basename "$0"):" + case ${1} in + info) echo "$default INFO: ${2}";; + success) echo "$default SUCCESS: ${2}" >&2;; + fatal) echo "$default FATAL: ${2}" >&2; exit 1;; + esac +} + +for var in HTTP HTTP_BIN HTTP_PID HTTP_LOG TKL_KEYFILE TKL_CERTFILE TKL_COMBINED TKL_DHPARAM; do + eval "z=\$$var" + [[ -z "$z" ]] && hook_log fatal "$var is not set. Exiting..." +done + +function deploy_challenge { + local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" + + hook_log info "Deploying challenge for $DOMAIN" + hook_log info "Serving $WELLKNOWN/$TOKEN_FILENAME on http://$DOMAIN/.well-known/acme-challenge/$TOKEN_FILENAME" + $HTTP_BIN --deploy "$WELLKNOWN/$TOKEN_FILENAME" +} + +function clean_challenge { + local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" + + hook_log info "Clean challenge for $DOMAIN" + $HTTP_BIN --clean "$WELLKNOWN/$TOKEN_FILENAME" +} + +function deploy_cert { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}" + + hook_log success "Cert request successful. Writing relevant files for $DOMAIN." + hook_log info "fullchain: $FULLCHAINFILE" + hook_log info "keyfile: $KEYFILE" + cat "$KEYFILE" > "$TKL_KEYFILE" + cat "$FULLCHAINFILE" > "$TKL_CERTFILE" + cat "$TKL_CERTFILE" "$TKL_KEYFILE" "$TKL_DHPARAM" > "$TKL_COMBINED" + hook_log success "Files written/created for $DOMAIN: $TKL_CERTFILE - $TKL_KEYFILE - $TKL_COMBINED." +} + +function unchanged_cert { + local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" + + hook_log info "cert for $DOMAIN is unchanged - nothing to do" +} + +HANDLER="$1"; shift +case "$HANDLER" in + deploy_challenge) + deploy_challenge "$@";; + clean_challenge) + clean_challenge "$@";; + deploy_cert) + deploy_cert "$@";; + unchanged_cert) + unchanged_cert "$@";; +esac diff --git a/debian/confconsole/usr/share/confconsole/letsencrypt/index.html b/debian/confconsole/usr/share/confconsole/letsencrypt/index.html new file mode 100644 index 0000000..4e15c83 --- /dev/null +++ b/debian/confconsole/usr/share/confconsole/letsencrypt/index.html @@ -0,0 +1,29 @@ + + + +Temporarily Down for Maintenance + + + +

Temporarily Down for Maintenance

+

The site is currently down for scheduled maintenance. +We should be back online in a few minutes.

+ +

For further information, please contact the webmaster or system administrator.

+ + diff --git a/debian/confconsole/usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_cloudflare.yml b/debian/confconsole/usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_cloudflare.yml new file mode 100644 index 0000000..71521c2 --- /dev/null +++ b/debian/confconsole/usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_cloudflare.yml @@ -0,0 +1,22 @@ +# Cloudflare Lexicon conf template provided by TurnKey Linux's Confconsole +# +# Confconsole uses Lexicon to support the Let's Encrypt DNS-01 challenge type: +# https://dns-lexicon.github.io/dns-lexicon/ +# https://www.turnkeylinux.org/docs/confconsole/letsencrypt#dns-01 +# https://letsencrypt.org/docs/challenge-types/#dns-01-challenge +# +# Uncomment and update the example authentication lines below relevant to your +# desired Cloudflare authentication method. See Lexicon Cloudflare config docs +# for more info: +# https://dns-lexicon.github.io/dns-lexicon/configuration_reference.html#cloudflare + +# Cloudflare example 1 - using global api key +#auth_username: YOUR_CF_USERNAME +#auth_token: YOUR_CF_API_TOKEN + +# Cloudflare example 2 - using unscoped API token +#auth_token: YOUR_CF_UNSCOPED_API_TOKEN + +# Cloudflare example 3 - using scoped API token +#auth_token: YOUR_CF_SCOPED_API_TOKEN +#zone_id: YOUR_CF_ZONE_ID diff --git a/debian/confconsole/usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_example.yml b/debian/confconsole/usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_example.yml new file mode 100644 index 0000000..1740aeb --- /dev/null +++ b/debian/confconsole/usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_example.yml @@ -0,0 +1,18 @@ +# Generic Lexicon conf template provided by TurnKey Linux's Confconsole +# +# Confconsole uses Lexicon to support the Let's Encrypt DNS-01 challenge type: +# https://dns-lexicon.github.io/dns-lexicon/ +# https://www.turnkeylinux.org/docs/confconsole/letsencrypt#dns-01 +# https://letsencrypt.org/docs/challenge-types/#dns-01-challenge +# +# AWS Route53 & Cloudflare specific Lexicon config are also provided by +# Confconsole. When using either of those DNS providers, rerunning Confconsole +# and selecting the relevant provider is recommended. +# +# Replace or add Lexicon config relevant to your DNS provider; see the Lexicon +# doc page for your specific provider: +# https://dns-lexicon.github.io/dns-lexicon/configuration_reference.html + +# Adjust/replace as per Lexicon docs for your specific DNS provider +#config_key_1: config_value_1 +#config_key_2: config_value_2 diff --git a/debian/confconsole/usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_route53.yml b/debian/confconsole/usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_route53.yml new file mode 100644 index 0000000..857b088 --- /dev/null +++ b/debian/confconsole/usr/share/confconsole/letsencrypt/lexicon-confconsole-provider_route53.yml @@ -0,0 +1,23 @@ +# Route53 Lexicon conf template provided by TurnKey Linux's Confconsole +# +# Confconsole uses Lexicon to support the Let's Encrypt DNS-01 challenge type: +# https://dns-lexicon.github.io/dns-lexicon/ +# https://www.turnkeylinux.org/docs/confconsole/letsencrypt#dns-01 +# https://letsencrypt.org/docs/challenge-types/#dns-01-challenge +# +# Uncomment and update the example authentication lines below relevant to your +# Route53 authentication method. See Lexicon Route53 config docs for more +# info: +# https://dns-lexicon.github.io/dns-lexicon/configuration_reference.html#route53 + +# AWS Route53 example 1 - AWS API key/secret auth: +#auth_access_key: YOUR_AWS_ACCESS_KEY +#auth_access_secret: YOUR_AWS_ACCESS_SECRET +#private_zone: False # generally you'll always want public zone +#zone_id: YOUR_ZONE_ID + +# AWS Route53 example 2 - AWS username/token auth: +#auth_username: YOUR_AWS_USERNAME +#auth_token: YOUR_AWS_TOKEN +#private_zone: False # generally you'll always want public zone +#zone_id: YOUR_ZONE_ID diff --git a/debian/confconsole/usr/share/doc/confconsole/Lets_encrypt#advanced.rst.gz b/debian/confconsole/usr/share/doc/confconsole/Lets_encrypt#advanced.rst.gz new file mode 120000 index 0000000..0aa8f0a --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/Lets_encrypt#advanced.rst.gz @@ -0,0 +1 @@ +Lets_encrypt.rst.gz \ No newline at end of file diff --git a/debian/confconsole/usr/share/doc/confconsole/Lets_encrypt.rst.gz b/debian/confconsole/usr/share/doc/confconsole/Lets_encrypt.rst.gz new file mode 100644 index 0000000..c8ebe9d Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/Lets_encrypt.rst.gz differ diff --git a/debian/confconsole/usr/share/doc/confconsole/Mail_relay.rst b/debian/confconsole/usr/share/doc/confconsole/Mail_relay.rst new file mode 100644 index 0000000..69a178d --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/Mail_relay.rst @@ -0,0 +1,65 @@ +Confconsole - Mail relay +======================== + +.. contents:: + +Overview +-------- + +Confconsole Mail relay plugin allows, users to easily configure a remote +SMTP relay to send emails through. + +.. image:: ./images/04_confconsole_mail_relay.png + +By default TurnKey Linux appliances send email directly. However to +provide more robust email delivery, it is highly recommended to use a +third party SMTP relay. + +SendinBlue +---------- + +We have researched the available third party SMTP relay options and +have concluded that SendinBlue are currently providing the best mix +of features and value for money. + +A free SendinBlue account allows sending up to 9000 emails per +month, so should provide sufficient capabilities for general +low-medium email traffic use. If you wish to send mass-marketing +emails or are sending large volumes of transactional emails, you may +be better served by upgrading to a paid plan. + +To sign up for a free SendinBlue account, please browse to: + +https://hub.turnkeylinux.org/email/ + +You should only need to enter your Login (SendinBlue username) and +Password. The host and Port should be pre-configured within +Confconsole. + +Custom +------ + +The custom option allows you to configure your server to use an +alternate SMTP relay service. It should work with all avaialble SMTP +relays (public or private). + +Some additional configuration may be required for some SMTP relay +services. E.g. to use Google SMTP you need to adjust the config of +your account to allow "less secure applications". Please consult +with your remote SMTP relay provider for relevant details. + +Deconfigure +----------- + +This option removes all configuration and returns TurnKey to it's +default config. + +Notes +----- + +The SMTP relay authentication procedure requires the SMTP relay user +password to be stored in plain text at `/etc/postfix/sasl_passwd` +(readable only by root). As a general rule, so long as no other users +are granted read access, that should be sufficently secure. However, +if you have concerns about someone else (who has root access) +accessing this then you are advised to consider an alternate path. diff --git a/debian/confconsole/usr/share/doc/confconsole/Networking.rst b/debian/confconsole/usr/share/doc/confconsole/Networking.rst new file mode 100644 index 0000000..3e23dba --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/Networking.rst @@ -0,0 +1,82 @@ +Confconsole - Networking +======================== + +.. contents:: + +Overview +-------- + +Networking allows the user to allocate a server IP address via DHCP +(default) or set a static IP. + +**IMPORTANT:** setting a static IP address on a cloud instance (e.g. AWS +EC2) will break your server's internet access and may make it unreachable. +**unless you know exactly what you are doing; DO NOT ADJUST NETWORKING ON +CLOUD SERVERS!** You have been warned! + +As of Confconsole v2.0.0 (default in v16.0+ TurnKey appliances), all cloud +builds have Confconsole's Networking options disabled (``networking false`` +in ``/etc/confconsole/confconsole.conf``). + +.. image:: ./images/02_confconsole_core_networking.png + +DHCP +---- + +Selecting **DHCP** from the menu will querry the local DHCP server for a new +dynamically allocated IP address. + +Static +------ + +As noted above; **DO NOT change network settings on a cloud server!** + +Selecting **StaticIP** from the menu allows you to set a static IP address +as follows: + +- IP Adress: The desired static IP address +- Netmask: Subnet details (if you are on a LAN with 192.168.x.x then + it's probably 255.255.255.0) +- Default Gateway: The internet Gateway IP address (your router IP if on + a LAN) +- Name Server(s): The IP address(es) of DNS servers to use. Currently + allows up to 3. + +Notes +----- + +By default, initially TurnKey Linux sets an IP address via DHCP (see +limitations below). + +Changes to network config via Confconsole are persistent and will +survive reboot (see limitations below). + +Limitations +----------- + +Only IPv4 addresses are currently supported. + +In most build types, by default TurnKey Linux sets an IP address via +DHCP. The exceptions to that are Proxmox/LXC and Docker. Generally these +builds have an IP (static or dynamic), set via the host when +initially created. + +In some limited cases (e.g. Proxmox - depending on configuration), +any networking adjustments made via Confconsole (or other means) will apply, +but will NOT be persistent post-reboot. The networking can still be +reconfigured on the running system. However, changes will be lost on +reboot. As a general rule, it is recommended that unless you have a need +to reconfigure networking within the instance, set the desired configuration +on the host. + +Some other platforms (e.g. AWS EC2 and OpenStack) will almost certainly +break if a static IP is set! As of Confconsole v2.0.0 (default in TurnKey +v16.0+) TurnKey Cloud builds (currently includes EC2, Xen and OpenStack) +have the Networking config options disabled. + +Technical note +-------------- + +Technically the Networking option is not provided by a plugin as it +is a legacy "Advanced" menu option. + diff --git a/debian/confconsole/usr/share/doc/confconsole/Plugins.rst.gz b/debian/confconsole/usr/share/doc/confconsole/Plugins.rst.gz new file mode 100644 index 0000000..9e925da Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/Plugins.rst.gz differ diff --git a/debian/confconsole/usr/share/doc/confconsole/Proxy_settings.rst b/debian/confconsole/usr/share/doc/confconsole/Proxy_settings.rst new file mode 100644 index 0000000..48f4998 --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/Proxy_settings.rst @@ -0,0 +1,32 @@ +Confconsole - Proxy settings +============================ + +.. contents:: + +Overview +-------- + +The confconsole proxy setting plugin currently allows you to set a proxy +for apt. This will allow you to get system updates and install +packages, even if your server is hidden behind a proxy server. + +.. image:: ./images/05_confconsole_proxy_settings.png + +Apt proxy +--------- + +Selecting this option allows you to set the domain of a HTTP proxy for +use by the apt package management system. + +Currently only HTTP proxies are supported (i.e. not HTTPS). If you +require use of an HTTPS proxy, unfortunately, you'll need to manually +configure that. + +Alternate ports (other than 80) are also possible by appending the port +to the end. E.g. http://proxy.example.com:8080 + +**Note:** you must include the scheme for your proxy. As only HTTP is +supported, that means it should look like this: + + http://proxy.example.com + diff --git a/debian/confconsole/usr/share/doc/confconsole/README.gz b/debian/confconsole/usr/share/doc/confconsole/README.gz new file mode 100644 index 0000000..b8cd193 Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/README.gz differ diff --git a/debian/confconsole/usr/share/doc/confconsole/Region_config.rst b/debian/confconsole/usr/share/doc/confconsole/Region_config.rst new file mode 100644 index 0000000..9a65381 --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/Region_config.rst @@ -0,0 +1,83 @@ +Confconsole - Region config +=========================== + +.. contents:: + +Overview +-------- + +The Region config plug in allows users to customize common regional +settings on their TurnKey server. Configurable setting include +keyboard layout, locales (i.e. language and encoding) and timezone. + +.. image:: ./images/06_confconsole_region_config.png + +This is only useful if you wish to change from the defaults. The TurnKey +Linux system defaults are: + +- keyboard: English (US) - aka US International +- locale: + +Keyboard +-------- + +Select this option to reconfigure the default keyboard layout which your +server will use. Default is English (US). + +Please note that additional packages are needed to reconfigure this, so +your server will need to be connected to the internet to complete this. + +If the required package is not already installed, you will be given +the option to install it. + +Locale +------ + +A system "locale" refers to the regional conventions for things such as +date and time formatting, character display and currency display. This +essentially includes language, although not explicitly (not all programs +support alternate languages). + +When you first select this option, you will be greated with an extensive +list of avaialble locales. Simply scroll through them and use to +select/deselect locales. + +It is strongly suggested that you leave 'en_US.UTF-8' (the default) +enabled as some software requires it to function properly. It is +further suggested that you only enable the relevant UTF-8 character +set. UTF is generally the acceptd standard. + +**Important note:** You are strongly urged to set the default locale to +"None". This ensures that users logging in via SSH can use their local +PC locale, rather than being forced to use the system setting. + +**Note:** TurnKey includes a utility called locale-purge. This allows us +to keep the size of the instalation as small as possible. The downside +of that is that the system by default does not include documentation and +for languages other than English. If you do no use English as a first +language and would like to restore the non-English documentation, you +will need to manually re-install any/all packages for which the +alternate language documentation is missing. Please note, not all +packages include non-English docs. Also note that this only applies to +packages installed prior to setting your locale. All packages installed +after setting your locale, will keep everything related to configured +locales. + +Tzdata +------ + +Tzdata relates to the local timezone. On a server, as a general rule +it is best to use UTC time. And that is indeed the TurnKey default. +However, Linux can be easily configured to adopt a region specific +offset. + +This means, that whilst the underlaying system will still use UTC, +for any users (and software running on the system) it will display +the local timezone by default. + +Simply select your region. Then select the relevant city/area. + +**Note:** Some PHP applications may require you to also set the +timezone in your php.ini file. This confconsole plug in does NOT do +that! If you need to do that, you will need to manually adjust that +yourself. diff --git a/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.1.txt b/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.1.txt new file mode 100644 index 0000000..dd198fb --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.1.txt @@ -0,0 +1,16 @@ +==================== +v0.9.1 Release Notes +==================== + +* info dialog text is now created from templates/info.txt + + - simpler, flexible, generic + - templates are installed to /usr/share/confconsole/templates + - the following variables will be substitued: $ipaddr, $appname + +* ports are not verified to be open + + - unnecessary complexity + - each appliance can have their own template + +* bugfix: dialog is now configured not to "collapse" whitespaces and tabs diff --git a/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.2.txt b/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.2.txt new file mode 100644 index 0000000..28f3bc2 --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.2.txt @@ -0,0 +1,40 @@ +==================== +v0.9.2 Release Notes +==================== + +multiple nic support +==================== + +- auto configuration of default nic used in usage display + (previously eth0 was hardcoded) + +- option to set default nic for display in Usage + + $ cat /etc/confconsole.conf + default_nic eth0 + +- if no nics are configured, go directly to nic configuration + +- network configuration information displayed inline + +- basic customized manual configurations in /etc/network/interfaces + will be retained (this is a side-effect, not a feature!) + +- depends on resolvconf to support different nameservers for + different nics + + +many bugfixes and changes, these are just a few +=============================================== + +- save default route when configuration static ip (#LP:303498) + +- catch exceptions generated by 'route -n' (#LP:306928) + +- ip address validation + +- updated init script to execute confconsole more like a daemon + +- stop confconsole before shutdown/reboot (cosmetic) + + diff --git a/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.3.txt b/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.3.txt new file mode 100644 index 0000000..8fcb733 --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.3.txt @@ -0,0 +1,42 @@ +==================== +v0.9.3 Release Notes +==================== + +* new configuration variable: CONFCONSOLE_FGVT + + - when executing shutdown, change to vt CONFCONSOLE_FGVT if set + - fixes "unable to deallocate vt" error, and displays shutdown output + +* handle use cases as expected (user experience) + + - only display list of nics when more than 1 are available + - dont display "this is default nic" if only 1 nic + - if no nics are configured at all display error and go to advanced + - dont display cancel button on advanced menu when no nics at all + - provide user with more descriptive error (no ip or mask provided) + - dont display 'set as default nic' if interface is not configured yet + - allow user to remedy mistakes in the staticip configuration screen + - allow user to delete/unconfigure nameserver/gateway + - unconfigure the nic if all fields are empty when setting static ip + +* updated default button labels to make more sense + + - Globally: OK -> Select , Cancel -> Back + - Form (StaticIP): OK -> Apply , Cancel -> Cancel (due to global) + + - Back button now acts like a back button (rewrote the dialog + looping code) + +* fixed static nameserver configuration to persist across reboots + +* intercept exit signal and verify whether to quit (ESC, ALT+?) + +* validate all input prior to attempting to apply them (with better ip + validation) + +* template changes + + - added $hostname variable for usage substitution + - updated webmin default port to 12321 + + diff --git a/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.4.txt b/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.4.txt new file mode 100644 index 0000000..2c7662b --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.4.txt @@ -0,0 +1,99 @@ +==================== +v0.9.4 Release Notes +==================== + +* leverage debian networking tools (ifup/ifdown) + + - instead of reinventing (poorly) manipulation of network interface, + the correct way (and seperation of concerns) is to manipulate + various configuration files and rely on ifup/ifdown to configure + the interface. + + - not to mention the hooks that are relied on: + if-down.d if-post-down.d if-pre-up.d if-up.d + +* removed udhcpc dependency and demoted resolvconf to recommends + + - we now rely on ifup to start/stop whatever dhcp client happens to + be installed. + + - the confconsole works fine without resolvconf, but it is + recommended when using multiple nics. + + - none-the-less, when displaying an interface's nameserver: + + - check if one is set in /etc/network/interfaces (static) + - check if resolvconf via any dhcp client + - if not, fallback to /etc/resolv.conf + +* handle exceptions in a user friendly way (fault tolerant) + + - errors/bugs are inevitable, we should attempt to minimize their + impact as much as possible. + + - raising an exception terminates confconsole, and this is + unfriendly to new users (confconsole suddently crashes). + + - instead, we intercept the exception and provide a useful traceback + that can be submitted (so we an squash the bug), and finally + return the user to the previous dialog. + +* retain iface options which are already defined when updating + interfaces configuration + + - provides support for pre/post up and down configurations (flexibility) + + - bugfix: iface options were lost when updating configuration + + - this was originally by design as the user was prompted to + remove the header if manual changes were made. + + - but we need this functionality to allow other applications to + update the configuration (e.g., webmin firewall activate on + boot). + +* added support for multiple nameservers. noticable UI changes: + + - minimum of 2 nameserver fields + - atleast 1 blank nameserver field (which means an infinate amount of + nameservers can be added) + +* moved template to /etc/confconsole/usage.txt and set as conffile + + - it is a configuration file, and can be customized. + - it should not be automatically replaced when upgrading. + +* template changes + + - added web shell with port 12320 + - updated SSH line to display SFTP as well + + - generic dialog changes: increased default height (18 -> 20) + +* bugfixes + + - fixed severely broken static IP configuration screen (configuring + a static IP without a gateway would raise an exception). + + - refuse to run confconsole without root privileges. + + - changed resolvconf interface path + + - ubuntu implemented a workaround to store "run" info in /var/run + - its better to look in the path it is meant to be (ubuntu + created a symlink to /var/run) + + - install docs/ + +* misc + + - added sanity check to set_static. + + - refactored away indecipherable regexp from is_ipaddr. + + - use clean upaddr module to determine if dateway is in IP range, + and include a more helpful error message if not. + + - standardized enumeration of /etc/network/interfaces information + + diff --git a/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.txt b/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.txt new file mode 100644 index 0000000..eb819cf --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/RelNotes-0.9.txt @@ -0,0 +1,8 @@ +================== +v0.9 Release Notes +================== + +This is a beta release which is nonetheless feature complete as +originally designed. v1.0 will be released after more rigorous +testing (and potential bugfixes/tweaks) in a production setting. + diff --git a/debian/confconsole/usr/share/doc/confconsole/RelNotes-1.0.0.txt b/debian/confconsole/usr/share/doc/confconsole/RelNotes-1.0.0.txt new file mode 100644 index 0000000..0bf651b --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/RelNotes-1.0.0.txt @@ -0,0 +1,24 @@ +==================== +v1.0.0 Release Notes +==================== + +* update license to GPL v3 + +* update to work reliably with SystemD + +* numerous bugfixes and improvements, especially related to display + and networking + +* plugin system + + - refacted confconsole to support additional functionality + by way of a plugin system. + + - new plugins: + + - Let's Encrypt - free SSL certs + - Mail relaying - remote SMTP mail relay config + - Proxy settings - only apt proxy so far + - Region config - keyboard, locales & tzdata + - System settings - install secupdates & update hostname (so + far) diff --git a/debian/confconsole/usr/share/doc/confconsole/RelNotes-2.1.0.txt b/debian/confconsole/usr/share/doc/confconsole/RelNotes-2.1.0.txt new file mode 100644 index 0000000..8e96e98 --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/RelNotes-2.1.0.txt @@ -0,0 +1,5 @@ +==================== +v2.1.0 Release Notes +==================== + +* implemented support for dns-01 challenge in Let's Encrypt plugin. diff --git a/debian/confconsole/usr/share/doc/confconsole/System_settings.rst b/debian/confconsole/usr/share/doc/confconsole/System_settings.rst new file mode 100644 index 0000000..34e44e6 --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/System_settings.rst @@ -0,0 +1,52 @@ +System settings +=============== + +.. contents:: + +Overview +-------- + +Miscellaneous system settings. Currently a bit of a "catchall" for some +functionality we wanted to include. + +.. image:: ./images/07_confconsole_system_settings.png + +Security updates +---------------- + +This option manually checks for and installs Debian and TurnKey +security updates for package managed software (i.e. the base OS and +most; but not neccessarily all software pre-included). + +By default, all TurnKey servers automatically install security updates +daily. This option allows you to manually trigger the updates. + +This plugin leverages `turnkey-install-security-updates`, thus provides +exactly the same functionality. + +**Note:** As stated, this only installs software that is covered by the +Debian package management system. It does not apply to third party +package management software (such as pip for python, cpan for perl, +gem for ruby, composer for php, etc). More often than not, that also +means NOT webapps installed direct from third parties. Upgrading +third party software must be done manually. + +Hostname +-------- + +This option allows you to manually update the system's hostname. By +default TurnKey systems have a default hostname that matches the name +of the appliance. E.g. our LAMP server has a hostname of 'lamp', +WordPress server has a hostname of 'wordpress', etc. + +A hostname may consist of multiple segements/labels, separated by a +period/full-stop (i.e.: '.'). Each segment must contain only the +ASCII letters 'a' through 'z' (case-insensitive), the digits '0' +through '9', and the hyphen ('-'). No other symbols, punctuation +characters, or white space are permitted. Each segment must be no +more than 64 characters and the total hostname length must not exceed +255 characters. + +Some applications may need to be restarted to note the new hostname. +Rebooting is one easy way to ensure that the new hostname is being +used everywhere. diff --git a/debian/confconsole/usr/share/doc/confconsole/changelog.gz b/debian/confconsole/usr/share/doc/confconsole/changelog.gz new file mode 100644 index 0000000..396918b Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/changelog.gz differ diff --git a/debian/confconsole/usr/share/doc/confconsole/copyright b/debian/confconsole/usr/share/doc/confconsole/copyright new file mode 100644 index 0000000..a8ca672 --- /dev/null +++ b/debian/confconsole/usr/share/doc/confconsole/copyright @@ -0,0 +1,22 @@ +Author: Alon Swartz + +License: + + Copyright (C) 2008 Alon Swartz + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +On Debian and Ubuntu systems, the complete text of the GNU General Public +License can be found in /usr/share/common-licenses/GPL file. diff --git a/debian/confconsole/usr/share/doc/confconsole/images/00_confconsole_core_main.png b/debian/confconsole/usr/share/doc/confconsole/images/00_confconsole_core_main.png new file mode 100644 index 0000000..28328b3 Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/images/00_confconsole_core_main.png differ diff --git a/debian/confconsole/usr/share/doc/confconsole/images/01_confconsole_core_advanced.png b/debian/confconsole/usr/share/doc/confconsole/images/01_confconsole_core_advanced.png new file mode 100644 index 0000000..2fd6b5a Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/images/01_confconsole_core_advanced.png differ diff --git a/debian/confconsole/usr/share/doc/confconsole/images/02_confconsole_core_networking.png b/debian/confconsole/usr/share/doc/confconsole/images/02_confconsole_core_networking.png new file mode 100644 index 0000000..77b55ce Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/images/02_confconsole_core_networking.png differ diff --git a/debian/confconsole/usr/share/doc/confconsole/images/03_confconsole_lets_encrypt.png b/debian/confconsole/usr/share/doc/confconsole/images/03_confconsole_lets_encrypt.png new file mode 100644 index 0000000..1a6f62c Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/images/03_confconsole_lets_encrypt.png differ diff --git a/debian/confconsole/usr/share/doc/confconsole/images/04_confconsole_mail_relay.png b/debian/confconsole/usr/share/doc/confconsole/images/04_confconsole_mail_relay.png new file mode 100644 index 0000000..df270b0 Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/images/04_confconsole_mail_relay.png differ diff --git a/debian/confconsole/usr/share/doc/confconsole/images/05_confconsole_proxy_settings.png b/debian/confconsole/usr/share/doc/confconsole/images/05_confconsole_proxy_settings.png new file mode 100644 index 0000000..c33d25f Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/images/05_confconsole_proxy_settings.png differ diff --git a/debian/confconsole/usr/share/doc/confconsole/images/06_confconsole_region_config.png b/debian/confconsole/usr/share/doc/confconsole/images/06_confconsole_region_config.png new file mode 100644 index 0000000..aa06039 Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/images/06_confconsole_region_config.png differ diff --git a/debian/confconsole/usr/share/doc/confconsole/images/07_confconsole_system_settings.png b/debian/confconsole/usr/share/doc/confconsole/images/07_confconsole_system_settings.png new file mode 100644 index 0000000..5de5380 Binary files /dev/null and b/debian/confconsole/usr/share/doc/confconsole/images/07_confconsole_system_settings.png differ diff --git a/debian/confconsole/usr/share/python3/runtime.d/confconsole.rtupdate b/debian/confconsole/usr/share/python3/runtime.d/confconsole.rtupdate new file mode 100755 index 0000000..a6372ec --- /dev/null +++ b/debian/confconsole/usr/share/python3/runtime.d/confconsole.rtupdate @@ -0,0 +1,7 @@ +#! /bin/sh +set -e + +if [ "$1" = rtupdate ]; then + py3clean -p confconsole /usr/lib/confconsole + py3compile -p confconsole /usr/lib/confconsole +fi \ No newline at end of file diff --git a/debian/debhelper-build-stamp b/debian/debhelper-build-stamp new file mode 100644 index 0000000..fad2eaf --- /dev/null +++ b/debian/debhelper-build-stamp @@ -0,0 +1 @@ +confconsole diff --git a/debian/files b/debian/files new file mode 100644 index 0000000..1dae165 --- /dev/null +++ b/debian/files @@ -0,0 +1,2 @@ +confconsole_2.2.1_all.deb misc optional +confconsole_2.2.1_amd64.buildinfo misc optional diff --git a/ifutil.py b/ifutil.py index 62356ae..9537897 100644 --- a/ifutil.py +++ b/ifutil.py @@ -413,8 +413,11 @@ def set_dhcp(ifname: str) -> str | None: raise e finally: output = ifup(ifname, True) - - net = InterfaceInfo(ifname) + for _retry in range(10): + net = InterfaceInfo(ifname) + if net.address: + break + sleep(1) if not net.address: raise IfError(f"Error obtaining IP address\n\n{output}") return None @@ -437,6 +440,25 @@ def get_ipconf( return (None, None, net.get_gateway(error), get_nameservers(ifname)) + +def get_ipv6conf(ifname: str) -> tuple[str | None, str | None]: + """Get IPv6 global address and prefix for an interface.""" + try: + out = subprocess.check_output( + ["ip", "-6", "addr", "show", ifname, "scope", "global"], + text=True, stderr=subprocess.DEVNULL + ) + for line in out.splitlines(): + line = line.strip() + if line.startswith("inet6"): + parts = line.split() + addr_prefix = parts[1] + addr, prefix = addr_prefix.split("/") + return (addr, prefix) + except Exception: + pass + return (None, None) + def get_ifmethod(ifname: str) -> str | None: interfaces = NetworkInterfaces() interfaces.read()