Compare commits

..

1 Commits

Author SHA1 Message Date
holger krekel
195c680455 ci: replace staging workflows with LXC-local testing
Replace the two staging-server CI workflows and their zone-file
helpers with a single lxc-test job in ci.yaml that runs
'cmdeploy lxc-test' inside an ubuntu-24.04 runner.

The new workflow installs Incus from the Zabbly apt repository,
initialises it, bootstraps the venv, caches the base LXC image
together with SSH keys, and runs the full LXC pipeline
(container creation, deploy, DNS zones, tests).
2026-03-09 10:33:08 +01:00
14 changed files with 101 additions and 380 deletions

View File

@@ -16,27 +16,77 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: download filtermail - name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.5.2/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.5.2/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests - name: run chatmaild tests
working-directory: chatmaild working-directory: chatmaild
run: pipx run tox run: pipx run tox
scripts: scripts:
name: deploy-chatmail tests name: deploy-chatmail tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: initenv - name: initenv
run: scripts/initenv.sh run: scripts/initenv.sh
- name: append venv/bin to PATH - name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH run: echo venv/bin >>$GITHUB_PATH
- name: run formatting checks - name: run formatting checks
run: cmdeploy fmt -v run: cmdeploy fmt -v
- name: run deploy-chatmail offline tests - name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy run: pytest --pyargs cmdeploy
# all other cmdeploy commands require a staging server lxc-test:
# see https://github.com/deltachat/chatmail/issues/100 name: LXC deploy and test
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: install incus
run: |
# zabbly is the official incus community packages source
curl -fsSL https://pkgs.zabbly.com/key.asc \
| sudo gpg --dearmor -o /etc/apt/keyrings/zabbly.gpg
sudo sh -c 'cat <<EOF > /etc/apt/sources.list.d/zabbly-incus-stable.sources
Enabled: yes
Types: deb
URIs: https://pkgs.zabbly.com/incus/stable
Suites: $(. /etc/os-release && echo ${VERSION_CODENAME})
Components: main
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/zabbly.gpg
EOF'
sudo apt-get update
sudo apt-get install -y incus
- name: initialise incus
run: |
sudo systemctl stop docker.socket docker || true
sudo iptables -P FORWARD ACCEPT
sudo sysctl -w fs.inotify.max_user_instances=65535
sudo sysctl -w fs.inotify.max_user_watches=65535
sudo incus admin init --minimal
sudo usermod -aG incus-admin "$USER"
- name: initenv
run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: lxc-test
run: sg incus-admin -c 'cmdeploy lxc-test'
- name: export images for cache
if: always()
run: |
for alias in localchat-base localchat-ns localchat-test0 localchat-test1; do
if ! [ -f /tmp/$alias.tar.gz ]; then
sg incus-admin -c "incus image export $alias /tmp/$alias" || true
fi
done

View File

@@ -1,20 +0,0 @@
;; Zone file for staging-ipv4.testrun.org
$ORIGIN staging-ipv4.testrun.org.
$TTL 300
@ IN SOA ns.testrun.org. root.nine.testrun.org (
2023010101 ; Serial
7200 ; Refresh
3600 ; Retry
1209600 ; Expire
3600 ; Negative response caching TTL
)
;; Nameservers.
@ IN NS ns.testrun.org.
;; DNS records.
@ IN A 37.27.95.249
mta-sts.staging-ipv4.testrun.org. CNAME staging-ipv4.testrun.org.
www.staging-ipv4.testrun.org. CNAME staging-ipv4.testrun.org.

View File

@@ -1,21 +0,0 @@
;; Zone file for staging2.testrun.org
$ORIGIN staging2.testrun.org.
$TTL 300
@ IN SOA ns.testrun.org. root.nine.testrun.org (
2023010101 ; Serial
7200 ; Refresh
3600 ; Retry
1209600 ; Expire
3600 ; Negative response caching TTL
)
;; Nameservers.
@ IN NS ns.testrun.org.
;; DNS records.
@ IN A 37.27.24.139
mta-sts.staging2.testrun.org. CNAME staging2.testrun.org.
www.staging2.testrun.org. CNAME staging2.testrun.org.

View File

@@ -1,104 +0,0 @@
name: deploy on staging-ipv4.testrun.org, and run tests
on:
push:
branches:
- main
pull_request:
paths-ignore:
- 'scripts/**'
- '**/README.md'
- 'CHANGELOG.md'
- 'LICENSE'
jobs:
deploy:
name: deploy on staging-ipv4.testrun.org, and run tests
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: staging-ipv4.testrun.org
url: https://staging-ipv4.testrun.org/
concurrency: staging-ipv4.testrun.org
steps:
- uses: actions/checkout@v4
- name: prepare SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging-ipv4.testrun.org > ~/.ssh/known_hosts
# save previous acme & dkim state
rsync -avz root@staging-ipv4.testrun.org:/var/lib/acme acme-ipv4 || true
rsync -avz root@staging-ipv4.testrun.org:/etc/dkimkeys dkimkeys-ipv4 || true
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
if [ -f dkimkeys-ipv4/dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys-ipv4 root@ns.testrun.org:/tmp/ || true; fi
if [ "$(ls -A acme-ipv4/acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme-ipv4 root@ns.testrun.org:/tmp/ || true; fi
# make sure CAA record isn't set
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: rebuild staging-ipv4.testrun.org to have a clean VPS
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"image":"debian-12"}' \
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_IPV4_SERVER_ID }}/actions/rebuild"
- run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: upload TLS cert after rebuilding
run: |
echo " --- wait until staging-ipv4.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org id -u ; do sleep 1 ; done
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org id -u
# download acme & dkim state from ns.testrun.org
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme-ipv4/acme acme-restore || true
rsync -avz root@ns.testrun.org:/tmp/dkimkeys-ipv4/dkimkeys dkimkeys-restore || true
# restore acme & dkim state to staging2.testrun.org
rsync -avz acme-restore/acme root@staging-ipv4.testrun.org:/var/lib/ || true
rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown root:root -R /var/lib/acme || true
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- name: setup dependencies
run: |
ssh root@staging-ipv4.testrun.org apt update
ssh root@staging-ipv4.testrun.org apt install -y git python3.11-venv python3-dev gcc
ssh root@staging-ipv4.testrun.org git clone https://github.com/chatmail/relay
ssh root@staging-ipv4.testrun.org "cd relay && git checkout " ${{ github.head_ref }}
ssh root@staging-ipv4.testrun.org "cd relay && scripts/initenv.sh"
- name: initialize config
run: |
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy init staging-ipv4.testrun.org"
ssh root@staging-ipv4.testrun.org "sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' relay/chatmail.ini"
ssh root@staging-ipv4.testrun.org "sed -i 's/#\s*mtail_address/mtail_address/' relay/chatmail.ini"
- run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy run --verbose --skip-dns-check --ssh-host localhost"
- name: set DNS entries
run: |
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone --ssh-host localhost"
ssh root@staging-ipv4.testrun.org cat relay/staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: ssh root@staging-ipv4.testrun.org "cd relay && CHATMAIL_DOMAIN2=ci-chatmail.testrun.org scripts/cmdeploy test --slow --ssh-host localhost"
- name: cmdeploy dns
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost"

View File

@@ -1,97 +0,0 @@
name: deploy on staging2.testrun.org, and run tests
on:
push:
branches:
- main
pull_request:
paths-ignore:
- 'scripts/**'
- '**/README.md'
- 'CHANGELOG.md'
- 'LICENSE'
jobs:
deploy:
name: deploy on staging2.testrun.org, and run tests
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: staging2.testrun.org
url: https://staging2.testrun.org/
concurrency: staging2.testrun.org
steps:
- uses: actions/checkout@v4
- name: prepare SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging2.testrun.org > ~/.ssh/known_hosts
# save previous acme & dkim state
rsync -avz root@staging2.testrun.org:/var/lib/acme . || true
rsync -avz root@staging2.testrun.org:/etc/dkimkeys . || true
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
if [ -f dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys root@ns.testrun.org:/tmp/ || true; fi
if [ "$(ls -A acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme root@ns.testrun.org:/tmp/ || true; fi
# make sure CAA record isn't set
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: rebuild staging2.testrun.org to have a clean VPS
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"image":"debian-12"}' \
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
- run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: upload TLS cert after rebuilding
run: |
echo " --- wait until staging2.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u ; do sleep 1 ; done
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u
# download acme & dkim state from ns.testrun.org
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme acme-restore || true
rsync -avz root@ns.testrun.org:/tmp/dkimkeys dkimkeys-restore || true
# restore acme & dkim state to staging2.testrun.org
rsync -avz acme-restore/acme root@staging2.testrun.org:/var/lib/ || true
rsync -avz dkimkeys-restore/dkimkeys root@staging2.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
- name: add hpk42 key to staging server
run: ssh root@staging2.testrun.org 'curl -s https://github.com/hpk42.keys >> .ssh/authorized_keys'
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- run: |
cmdeploy init staging2.testrun.org
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
- run: cmdeploy run --verbose --skip-dns-check
- name: set DNS entries
run: |
cmdeploy dns --zonefile staging-generated.zone --verbose
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
- name: cmdeploy dns
run: cmdeploy dns -v

View File

@@ -437,7 +437,7 @@ def main(args=None):
if args.func is None: if args.func is None:
return parser.parse_args(["-h"]) return parser.parse_args(["-h"])
out = Out(verbosity=args.verbose) out = Out(sepchar="\u2501", verbosity=args.verbose)
kwargs = {} kwargs = {}
if args.inipath is not None and args.func.__name__ not in ("init_cmd", "fmt_cmd"): if args.inipath is not None and args.func.__name__ not in ("init_cmd", "fmt_cmd"):

View File

@@ -463,9 +463,8 @@ class ChatmailDeployer(Deployer):
("iroh", None, None), ("iroh", None, None),
] ]
def __init__(self, config): def __init__(self, mail_domain):
self.config = config self.mail_domain = mail_domain
self.mail_domain = config.mail_domain
def install(self): def install(self):
files.put( files.put(
@@ -494,17 +493,6 @@ class ChatmailDeployer(Deployer):
) )
def configure(self): def configure(self):
# Ensure the per-domain mailbox directory exists before
# chatmail-metadata starts (it crashes without it).
files.directory(
name="Ensure vmail mailbox directory exists",
path=f"/home/vmail/mail/{self.mail_domain}",
user="vmail",
group="vmail",
mode="700",
present=True,
)
# This file is used by auth proxy. # This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName # https://wiki.debian.org/EtcMailName
server.shell( server.shell(
@@ -514,15 +502,6 @@ class ChatmailDeployer(Deployer):
], ],
) )
files.directory(
name=f"Ensure mailboxes directory {self.config.mailboxes_dir} exists",
path=str(self.config.mailboxes_dir),
user="vmail",
group="vmail",
mode="700",
present=True,
)
class FcgiwrapDeployer(Deployer): class FcgiwrapDeployer(Deployer):
def install(self): def install(self):
@@ -641,7 +620,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
tls_deployer = get_tls_deployer(config, mail_domain) tls_deployer = get_tls_deployer(config, mail_domain)
all_deployers = [ all_deployers = [
ChatmailDeployer(config), ChatmailDeployer(mail_domain),
LegacyRemoveDeployer(), LegacyRemoveDeployer(),
FiltermailDeployer(), FiltermailDeployer(),
JournaldDeployer(), JournaldDeployer(),

View File

@@ -145,7 +145,7 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
if not can_modify: if not can_modify:
print( print(
"\n!!!! refusing to attempt sysctl setting in shared-kernel containers\n" "\n!!!! refusing to attempt sysctl setting in shared-kernel containers\n"
f"!!!! dovecot: sysctl {key!r}={value}, should be >65534 for production setups\n" f"!!!! dovecot: sysctl {key!r}={value}, should be >65535 for production setups\n"
"!!!!" "!!!!"
) )
continue continue

View File

@@ -114,7 +114,7 @@ def _lxc_start_cmd(args, out):
) )
sub.green(f" Include {ssh_cfg}") sub.green(f" Include {ssh_cfg}")
# Optionally run cmdeploy run + dns on each relay # Optionally run cmdeploy run on each relay
if args.run: if args.run:
for ct in relays: for ct in relays:
with out.section(f"cmdeploy run: {ct.sname} ({ct.domain})"): with out.section(f"cmdeploy run: {ct.sname} ({ct.domain})"):
@@ -123,20 +123,6 @@ def _lxc_start_cmd(args, out):
out.red(f"Deploy to {ct.sname} failed (exit {ret})") out.red(f"Deploy to {ct.sname} failed (exit {ret})")
return ret return ret
with out.section("loading DNS zones"):
for ct in relays:
ret = _run_cmdeploy(
"dns", ct, ix, out,
extra=["--zonefile", str(ct.zone)],
)
if ret:
out.red(f"DNS for {ct.sname} failed (exit {ret})")
return ret
if ct.zone.exists():
dns_ct.set_dns_records(ct.zone.read_text())
out.print(f"Restarting filtermail-incoming on {ct.name}")
ct.bash("systemctl restart filtermail-incoming")
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# lxc-stop # lxc-stop
@@ -261,13 +247,6 @@ def lxc_test_cmd(args, out):
out.print(f"Loading {ct.zone} into PowerDNS ...") out.print(f"Loading {ct.zone} into PowerDNS ...")
dns_ct.set_dns_records(zone_data) dns_ct.set_dns_records(zone_data)
# Restart filtermail so its in-process DNS cache
# does not hold stale negative DKIM responses
# from before the zones were loaded.
for ct in map(ix.get_container, relay_names):
out.print(f"Restarting filtermail-incoming on {ct.name} ...")
ct.bash("systemctl restart filtermail-incoming")
with out.section("cmdeploy test"): with out.section("cmdeploy test"):
first = ix.get_container(relay_names[0]) first = ix.get_container(relay_names[0])
env = None env = None

View File

@@ -99,22 +99,6 @@ class Incus:
target = f"include {self.ssh_config_path}".lower() target = f"include {self.ssh_config_path}".lower()
return any(line.strip().lower() == target for line in lines) return any(line.strip().lower() == target for line in lines)
def get_host_nameservers(self):
"""Return upstream nameservers found on the host."""
ns = []
for path in ["/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"]:
p = Path(path)
if p.exists():
for line in p.read_text().splitlines():
if line.strip().startswith("nameserver "):
addr = line.split()[1]
if addr not in ("127.0.0.1", "127.0.0.53", "::1"):
if addr not in ns:
ns.append(addr)
if ns:
break
return ns
def run(self, args, check=True, capture=True, input=None): def run(self, args, check=True, capture=True, input=None):
"""Run an incus command. """Run an incus command.
@@ -122,7 +106,7 @@ class Incus:
to the terminal line-by-line while also being captured for to the terminal line-by-line while also being captured for
later return via result.stdout. later return via result.stdout.
""" """
cmd = ["incus", "--quiet"] + list(args) cmd = ["incus"] + list(args)
sub = self.out.new_prefixed_out(" ") sub = self.out.new_prefixed_out(" ")
if not capture: if not capture:
@@ -144,9 +128,9 @@ class Incus:
proc = subprocess.Popen( proc = subprocess.Popen(
cmd, cmd,
text=True, text=True,
stdin=subprocess.PIPE if input else subprocess.DEVNULL, stdin=subprocess.PIPE if input else None,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.STDOUT,
) )
stdout_lines = [] stdout_lines = []
@@ -159,17 +143,15 @@ class Incus:
if sub.verbosity >= 2: if sub.verbosity >= 2:
sub.print(f" > {line.rstrip()}") sub.print(f" > {line.rstrip()}")
stderr = proc.stderr.read()
ret = proc.wait() ret = proc.wait()
stdout = "".join(stdout_lines) stdout = "".join(stdout_lines)
if check and ret != 0: if check and ret != 0:
full_output = stdout + stderr for line in stdout.splitlines():
for line in full_output.splitlines():
if sub.verbosity < 1: # and we haven't printed it yet if sub.verbosity < 1: # and we haven't printed it yet
sub.red(line) sub.red(line)
raise subprocess.CalledProcessError(ret, cmd, output=stdout, stderr=stderr) raise subprocess.CalledProcessError(ret, cmd, output=stdout)
return subprocess.CompletedProcess(cmd, ret, stdout=stdout, stderr=stderr) return subprocess.CompletedProcess(cmd, ret, stdout=stdout)
def run_json(self, args, check=True): def run_json(self, args, check=True):
"""Run an incus command with ``--format=json``. """Run an incus command with ``--format=json``.
@@ -259,10 +241,8 @@ class Incus:
key_path = self.ssh_key_path key_path = self.ssh_key_path
pub_key = key_path.with_suffix(".pub").read_text().strip() pub_key = key_path.with_suffix(".pub").read_text().strip()
host_ns = self.get_host_nameservers()
ns_lines = "\n".join(f"nameserver {n}" for n in host_ns)
ct.bash(f""" ct.bash(f"""
printf '{ns_lines}\n' > /etc/resolv.conf echo 'nameserver 9.9.9.9' > /etc/resolv.conf
apt-get -o DPkg::Lock::Timeout=60 update apt-get -o DPkg::Lock::Timeout=60 update
DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server python3 DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server python3
systemctl enable ssh systemctl enable ssh
@@ -299,11 +279,12 @@ class Incus:
class Container: class Container:
"""The base container handle wraps all interactions with incus.""" """The base container handle wraps all interactions with incus."""
def __init__(self, incus, name, domain=None): def __init__(self, incus, name, domain=None, memory="200MiB"):
self.incus = incus self.incus = incus
self.out = incus.out self.out = incus.out
self.name = name self.name = name
self.domain = domain or f"{name}{DOMAIN_SUFFIX}" self.domain = domain or f"{name}{DOMAIN_SUFFIX}"
self.memory = memory
self.ipv4 = None self.ipv4 = None
self.ipv6 = None self.ipv6 = None
@@ -348,6 +329,7 @@ class Container:
cfg = [] cfg = []
cfg += ("-c", f"{LABEL_KEY}=true") cfg += ("-c", f"{LABEL_KEY}=true")
cfg += ("-c", f"user.localchat-domain={self.domain}") cfg += ("-c", f"user.localchat-domain={self.domain}")
cfg += ("-c", f"limits.memory={self.memory}")
self.incus.run(["launch", image, self.name, *cfg]) self.incus.run(["launch", image, self.name, *cfg])
return image return image
@@ -432,6 +414,7 @@ class RelayContainer(Container):
incus, incus,
f"{name}-localchat", f"{name}-localchat",
domain=f"_{name}{DOMAIN_SUFFIX}", domain=f"_{name}{DOMAIN_SUFFIX}",
memory="600MiB",
) )
self.sname = name self.sname = name
self.ini = incus.lxconfigs_dir / f"chatmail-{name}.ini" self.ini = incus.lxconfigs_dir / f"chatmail-{name}.ini"
@@ -456,14 +439,11 @@ class RelayContainer(Container):
self.bash(""" self.bash("""
sysctl -w net.ipv6.conf.all.disable_ipv6=1 sysctl -w net.ipv6.conf.all.disable_ipv6=1
sysctl -w net.ipv6.conf.default.disable_ipv6=1 sysctl -w net.ipv6.conf.default.disable_ipv6=1
mkdir -p /etc/sysctl.d
printf 'net.ipv6.conf.all.disable_ipv6=1\\n
net.ipv6.conf.default.disable_ipv6=1\\n'
> /etc/sysctl.d/99-disable-ipv6.conf
""") """)
self.push_file_content(
"/etc/sysctl.d/99-disable-ipv6.conf",
"""
net.ipv6.conf.all.disable_ipv6=1
net.ipv6.conf.default.disable_ipv6=1
""",
)
def configure_hosts(self, ip): def configure_hosts(self, ip):
"""Set hostname and /etc/hosts inside the container.""" """Set hostname and /etc/hosts inside the container."""
@@ -503,29 +483,22 @@ class RelayContainer(Container):
def verify_ssh(self, ssh_config): def verify_ssh(self, ssh_config):
"""Verify SSH connectivity to this container.""" """Verify SSH connectivity to this container."""
cmd = f"ssh -F {ssh_config} -o ConnectTimeout=60 root@{self.domain} hostname" cmd = f"ssh -F {ssh_config} -o ConnectTimeout=10 root@{self.domain} hostname"
return shell(cmd, timeout=60).returncode == 0 return shell(cmd, timeout=15).returncode == 0
def configure_dns(self, dns_ip): def configure_dns(self, dns_ip):
"""Point this container's resolver at *dns_ip* and verify DNS is reachable.""" """Point this container's resolver at *dns_ip* and verify DNS is reachable."""
self.bash(f""" self.bash(f"""
systemctl disable --now systemd-resolved 2>/dev/null || true systemctl disable --now systemd-resolved 2>/dev/null || true
rm -f /etc/resolv.conf rm -f /etc/resolv.conf
printf 'nameserver {dns_ip}\\n' >/etc/resolv.conf echo 'nameserver {dns_ip}' > /etc/resolv.conf
mkdir -p /etc/unbound/unbound.conf.d mkdir -p /etc/unbound/unbound.conf.d
printf 'server:\\n domain-insecure: "localchat"\\n\\n
forward-zone:\\n name: "localchat"\\n
forward-addr: {dns_ip}\\n'
> /etc/unbound/unbound.conf.d/localchat-forward.conf
systemctl restart unbound 2>/dev/null || true
""") """)
self.push_file_content(
"/etc/unbound/unbound.conf.d/localchat-forward.conf",
f"""
server:
domain-insecure: "localchat"
forward-zone:
name: "localchat"
forward-addr: {dns_ip}
""",
)
self.bash("systemctl restart unbound 2>/dev/null || true")
self._wait_dns_reachable(dns_ip) self._wait_dns_reachable(dns_ip)
def _wait_dns_reachable(self, dns_ip, timeout=10): def _wait_dns_reachable(self, dns_ip, timeout=10):
@@ -589,7 +562,7 @@ class DNSContainer(Container):
""") """)
self._wait_dns_ready() self._wait_dns_ready()
def _wait_dns_ready(self, timeout=60): def _wait_dns_ready(self, timeout=10):
"""Poll until the recursor answers a query on port 53.""" """Poll until the recursor answers a query on port 53."""
deadline = time.time() + timeout deadline = time.time() + timeout
while time.time() < deadline: while time.time() < deadline:
@@ -631,13 +604,10 @@ class DNSContainer(Container):
if self.run_cmd("which", "pdns_server", check=False) is not None: if self.run_cmd("which", "pdns_server", check=False) is not None:
return return
host_ns = self.incus.get_host_nameservers() self.bash("""
ns_lines = "\n".join(f"nameserver {n}" for n in host_ns)
self.bash(f"""
systemctl disable --now systemd-resolved 2>/dev/null || true systemctl disable --now systemd-resolved 2>/dev/null || true
rm -f /etc/resolv.conf rm -f /etc/resolv.conf
printf '{ns_lines}\n' > /etc/resolv.conf echo 'nameserver 9.9.9.9' > /etc/resolv.conf
# Block automatic service startup during package installation # Block automatic service startup during package installation
printf '#!/bin/sh\\nexit 101\\n' > /usr/sbin/policy-rc.d printf '#!/bin/sh\\nexit 101\\n' > /usr/sbin/policy-rc.d
@@ -673,6 +643,7 @@ class DNSContainer(Container):
local-address=0.0.0.0 local-address=0.0.0.0
local-port=53 local-port=53
forward-zones=localchat=127.0.0.1:5353 forward-zones=localchat=127.0.0.1:5353
forward-zones-recurse=.=9.9.9.9;149.112.112.112
allow-from=0.0.0.0/0 allow-from=0.0.0.0/0
dont-query= dont-query=
dnssec=off dnssec=off

View File

@@ -89,10 +89,7 @@ def test_concurrent_logins_same_account(
assert login_results.get() assert login_results.get()
def test_no_vrfy(chatmail_config, cmfactory): def test_no_vrfy(chatmail_config):
ac1 = cmfactory.get_online_account()
addr = ac1.get_config("addr")
domain = chatmail_config.mail_domain domain = chatmail_config.mail_domain
s = smtplib.SMTP(domain) s = smtplib.SMTP(domain)
@@ -101,7 +98,7 @@ def test_no_vrfy(chatmail_config, cmfactory):
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}") s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")
result = s.getreply() result = s.getreply()
print(result) print(result)
s.putcmd("vrfy", addr) s.putcmd("vrfy", f"echo@{chatmail_config.mail_domain}")
result2 = s.getreply() result2 = s.getreply()
print(result2) print(result2)
assert result[0] == result2[0] == 252 assert result[0] == result2[0] == 252

View File

@@ -487,16 +487,13 @@ def cmfactory(
@pytest.fixture @pytest.fixture
def remote(sshdomain, pytestconfig): def remote(sshdomain, pytestconfig):
r = Remote(sshdomain, ssh_config=pytestconfig.getoption("ssh_config")) return Remote(sshdomain, ssh_config=pytestconfig.getoption("ssh_config"))
yield r
r.close()
class Remote: class Remote:
def __init__(self, sshdomain, ssh_config=None): def __init__(self, sshdomain, ssh_config=None):
self.sshdomain = sshdomain self.sshdomain = sshdomain
self.ssh_config = ssh_config self.ssh_config = ssh_config
self._procs = []
def iter_output(self, logcmd="", ready=None): def iter_output(self, logcmd="", ready=None):
getjournal = "journalctl -f" if not logcmd else logcmd getjournal = "journalctl -f" if not logcmd else logcmd
@@ -512,15 +509,12 @@ class Remote:
command.extend(["-F", self.ssh_config]) command.extend(["-F", self.ssh_config])
command.append(f"root@{self.sshdomain}") command.append(f"root@{self.sshdomain}")
[command.append(arg) for arg in getjournal.split()] [command.append(arg) for arg in getjournal.split()]
popen = subprocess.Popen( self.popen = subprocess.Popen(
command, command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
) )
self._procs.append(popen)
while 1: while 1:
line = popen.stdout.readline() line = self.popen.stdout.readline()
res = line.decode().strip().lower() res = line.decode().strip().lower()
if not res: if not res:
break break
@@ -529,12 +523,6 @@ class Remote:
ready = None ready = None
yield res yield res
def close(self):
while self._procs:
proc = self._procs.pop()
proc.kill()
proc.wait()
@pytest.fixture @pytest.fixture
def lp(request): def lp(request):

View File

@@ -15,10 +15,10 @@ from termcolor import colored
class Out: class Out:
"""Convenience output printer providing coloring and section formatting.""" """Convenience output printer providing coloring and section formatting."""
def __init__(self, prefix="", verbosity=0): def __init__(self, sepchar="\u2501", prefix="", verbosity=0):
self.section_timings = [] self.section_timings = []
self.prefix = prefix self.prefix = prefix
self.sepchar = "\u2501" self.sepchar = sepchar
self.verbosity = verbosity self.verbosity = verbosity
env_width = os.environ.get("_CMDEPLOY_WIDTH") env_width = os.environ.get("_CMDEPLOY_WIDTH")
if env_width: if env_width:
@@ -31,6 +31,7 @@ class Out:
sharing section_timings with the parent. sharing section_timings with the parent.
""" """
out = Out( out = Out(
sepchar=self.sepchar,
prefix=self.prefix + newprefix, prefix=self.prefix + newprefix,
verbosity=self.verbosity, verbosity=self.verbosity,
) )
@@ -89,7 +90,6 @@ class Out:
cmd, cmd,
shell=True, shell=True,
text=True, text=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
env=env, env=env,
@@ -133,7 +133,6 @@ def shell(cmd, check=False, **kwargs):
""" """
if "capture_output" not in kwargs and "stdout" not in kwargs: if "capture_output" not in kwargs and "stdout" not in kwargs:
kwargs["capture_output"] = True kwargs["capture_output"] = True
kwargs.setdefault("stdin", subprocess.DEVNULL)
return subprocess.run(collapse(cmd), shell=True, text=True, check=check, **kwargs) return subprocess.run(collapse(cmd), shell=True, text=True, check=check, **kwargs)

View File

@@ -176,7 +176,7 @@ running two `PowerDNS <https://www.powerdns.com/>`_ services:
* **pdns-recursor** (recursive) listens on the Incus * **pdns-recursor** (recursive) listens on the Incus
bridge so all containers can use it. bridge so all containers can use it.
Forwards ``.localchat`` queries to the local Forwards ``.localchat`` queries to the local
authoritative server and resolves everything else recursively. authoritative server and everything else to Quad9 (``9.9.9.9``).
After the DNS container is up, ``lxc-start`` configures the Incus bridge After the DNS container is up, ``lxc-start`` configures the Incus bridge
to advertise its IP via DHCP and disables Incus's own DNS. to advertise its IP via DHCP and disables Incus's own DNS.