Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 28: Securing the Host

"A homelab on a private subnet is not exposed to the internet. It is exposed to your laptop, which is exposed to everything else on your laptop."


Why

Part 26 admitted that tcp://0.0.0.0:2375 is plain text and unauthenticated, and called the trade-off acceptable for the default homelab threat model. This part is for the user who does not accept it.

There are three reasons to harden the host:

  1. The "every device on my LAN" threat. The private host-only network is only private to the host machine in VirtualBox / Hyper-V / Parallels — but on Linux libvirt and on some VirtualBox configurations, it can leak. A hardened lab assumes the network is untrusted.
  2. The "I share this lab with my team" threat. When DevLab is running on a developer's workstation that other developers SSH into, the unauthenticated Docker socket is a backdoor.
  3. The "I want this to look like production" rehearsal. SREs who use HomeLab as a staging environment for production policies want the staging to enforce the same constraints production does.

The thesis of this part is: HomeLab ships an optional HardenedHostContributor that layers SSH hardening, sudoers restrictions, a host firewall, mTLS for the Docker socket, audit logging, and secret-at-rest into the same Packer bundle. It is one config field away. The user does not edit any of the resulting files.


The shape

[Injectable(ServiceLifetime.Singleton)]
[Order(30)]
public sealed class HardenedHostContributor : IPackerBundleContributor
{
    private readonly HomeLabConfig _config;
    private readonly ITlsCertificateProvider _tls;

    public HardenedHostContributor(IOptions<HomeLabConfig> config, ITlsCertificateProvider tls)
    {
        _config = config.Value;
        _tls = tls;
    }

    public bool ShouldContribute() => _config.Security?.Hardened == true;

    public void Contribute(PackerBundle bundle)
    {
        if (!ShouldContribute()) return;

        AddSshHardening(bundle);
        AddSudoersRestrictions(bundle);
        AddFirewall(bundle);
        AddDockerMtls(bundle);
        AddAuditLogging(bundle);
        AddBuildTimeSecretCleanup(bundle);
    }

    private void AddSshHardening(PackerBundle bundle)
    {
        bundle.Scripts.Add(new PackerScript("harden-ssh.sh", """
            #!/bin/sh
            set -eux

            cat > /etc/ssh/sshd_config.d/99-homelab.conf <<EOF
            PasswordAuthentication no
            PermitRootLogin no
            PubkeyAuthentication yes
            AuthenticationMethods publickey
            X11Forwarding no
            AllowAgentForwarding no
            AllowTcpForwarding no
            PermitTunnel no
            MaxAuthTries 3
            ClientAliveInterval 300
            ClientAliveCountMax 2
            EOF

            service sshd restart
            """));
    }

    private void AddSudoersRestrictions(PackerBundle bundle)
    {
        bundle.Scripts.Add(new PackerScript("harden-sudoers.sh", """
            #!/bin/sh
            set -eux

            # Allow alpine to manage docker without password, but nothing else
            cat > /etc/sudoers.d/99-homelab <<EOF
            alpine ALL=(root) NOPASSWD: /sbin/service docker *
            alpine ALL=(root) NOPASSWD: /usr/sbin/iptables -L
            Defaults:alpine !lecture
            Defaults:alpine timestamp_timeout=5
            EOF
            chmod 0440 /etc/sudoers.d/99-homelab
            visudo -c
            """));
    }

    private void AddFirewall(PackerBundle bundle)
    {
        bundle.Scripts.Add(new PackerScript("harden-firewall.sh", $$"""
            #!/bin/sh
            set -eux

            apk add --no-cache iptables iptables-openrc

            iptables -F
            iptables -X
            iptables -P INPUT DROP
            iptables -P FORWARD DROP
            iptables -P OUTPUT ACCEPT

            iptables -A INPUT -i lo -j ACCEPT
            iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

            # SSH from the host-only subnet only
            iptables -A INPUT -p tcp --dport 22 -s {{_config.Vos.Subnet}}.0/24 -j ACCEPT

            # Docker mTLS port from the host-only subnet only
            iptables -A INPUT -p tcp --dport 2376 -s {{_config.Vos.Subnet}}.0/24 -j ACCEPT

            # Internal compose networks
            iptables -A INPUT -p tcp --dport 80 -s {{_config.Vos.Subnet}}.0/24 -j ACCEPT
            iptables -A INPUT -p tcp --dport 443 -s {{_config.Vos.Subnet}}.0/24 -j ACCEPT

            # Persist
            /etc/init.d/iptables save
            rc-update add iptables default
            """));
    }

    private void AddDockerMtls(PackerBundle bundle)
    {
        // The CA + server cert + client cert are generated at build time on the host
        // (via ITlsCertificateProvider) and copied into the VM via a file provisioner.
        bundle.Provisioners.Add(new PackerProvisioner
        {
            Type = "file",
            Properties = new()
            {
                ["source"] = "tls/docker-ca.crt",
                ["destination"] = "/etc/docker/certs/ca.pem"
            }
        });
        bundle.Provisioners.Add(new PackerProvisioner
        {
            Type = "file",
            Properties = new()
            {
                ["source"] = "tls/docker-server.crt",
                ["destination"] = "/etc/docker/certs/server-cert.pem"
            }
        });
        bundle.Provisioners.Add(new PackerProvisioner
        {
            Type = "file",
            Properties = new()
            {
                ["source"] = "tls/docker-server.key",
                ["destination"] = "/etc/docker/certs/server-key.pem"
            }
        });

        bundle.Scripts.Add(new PackerScript("enable-docker-mtls.sh", """
            #!/bin/sh
            set -eux

            chmod 0644 /etc/docker/certs/ca.pem /etc/docker/certs/server-cert.pem
            chmod 0600 /etc/docker/certs/server-key.pem
            chown -R root:root /etc/docker/certs

            # Replace the daemon.json's `hosts` array with a TLS-protected one
            cat > /etc/docker/daemon.json <<'EOF'
            {
              "hosts": ["unix:///var/run/docker.sock", "tcp://0.0.0.0:2376"],
              "tls": true,
              "tlsverify": true,
              "tlscacert": "/etc/docker/certs/ca.pem",
              "tlscert": "/etc/docker/certs/server-cert.pem",
              "tlskey": "/etc/docker/certs/server-key.pem",
              "log_driver": "json-file",
              "log_opts": { "max-size": "10m", "max-file": "3" }
            }
            EOF

            service docker restart
            """));
    }

    private void AddAuditLogging(PackerBundle bundle)
    {
        bundle.Scripts.Add(new PackerScript("enable-audit.sh", """
            #!/bin/sh
            set -eux

            apk add --no-cache audit
            cat > /etc/audit/rules.d/homelab.rules <<EOF
            -w /etc/passwd -p wa -k passwd_changes
            -w /etc/sudoers -p wa -k sudoers_changes
            -w /etc/sudoers.d/ -p wa -k sudoers_changes
            -w /etc/docker/ -p wa -k docker_changes
            -w /var/log/docker.log -p wa -k docker_log
            -a exit,always -F arch=b64 -S execve -F uid>=1000 -k user_commands
            EOF
            rc-update add auditd default
            service auditd start
            """));
    }

    private void AddBuildTimeSecretCleanup(PackerBundle bundle)
    {
        bundle.Provisioners.Add(new PackerProvisioner
        {
            Type = "shell",
            Properties = new()
            {
                ["inline"] = new[]
                {
                    // Remove the build SSH password from /etc/shadow
                    "passwd -d alpine",
                    "passwd -d root",
                    // Remove bash history
                    "rm -f /root/.bash_history /home/alpine/.bash_history",
                    // Truncate logs
                    "find /var/log -type f -exec truncate -s 0 {} +",
                    // Remove cloud-init residue if any
                    "rm -rf /var/lib/cloud /var/log/cloud-init.log",
                }
            }
        });
    }
}

Six concerns. Six small scripts. All run by Packer at build time. The result is a .box file that boots already hardened — there is no post-boot manual step.


The wiring

Because the contributor needs cert files generated before packer build runs, the pipeline does the cert generation in the Generate stage, before the bundle is written:

public async Task<Result<HomeLabContext>> RunAsync(HomeLabContext ctx, CancellationToken ct)
{
    // ... existing generation logic ...

    if (ctx.Config!.Security?.Hardened == true)
    {
        var ca = await _tls.GenerateCaAsync("HomeLab Docker CA", ct);
        if (ca.IsFailure) return ca.Map<HomeLabContext>();

        var serverCert = await _tls.GenerateCertAsync(ca.Value, "docker-server", new[] { "*.frenchexdev.lab" }, ct);
        if (serverCert.IsFailure) return serverCert.Map<HomeLabContext>();

        var clientCert = await _tls.GenerateCertAsync(ca.Value, "docker-client", new[] { "client" }, ct);
        if (clientCert.IsFailure) return clientCert.Map<HomeLabContext>();

        // Write the certs into the packer build dir so the file provisioners can copy them
        var tlsDir = Path.Combine(ctx.Request.OutputDir.FullName, "packer", "tls");
        Directory.CreateDirectory(tlsDir);
        await File.WriteAllBytesAsync(Path.Combine(tlsDir, "docker-ca.crt"),     ca.Value.Certificate, ct);
        await File.WriteAllBytesAsync(Path.Combine(tlsDir, "docker-server.crt"), serverCert.Value.Certificate, ct);
        await File.WriteAllBytesAsync(Path.Combine(tlsDir, "docker-server.key"), serverCert.Value.PrivateKey, ct);
        await File.WriteAllBytesAsync(Path.Combine(tlsDir, "docker-client.crt"), clientCert.Value.Certificate, ct);
        await File.WriteAllBytesAsync(Path.Combine(tlsDir, "docker-client.key"), clientCert.Value.PrivateKey, ct);
    }

    // ... rest of generation ...
}

The host's Docker calls then use the client cert via the WithEnv mechanism from Part 15:

var clientCertPath = Path.Combine(workingDir, "packer", "tls");
var result = await _docker.PsAsync(all: true)
    .WithEnv("DOCKER_HOST", "tcp://192.168.56.10:2376")
    .WithEnv("DOCKER_TLS_VERIFY", "1")
    .WithEnv("DOCKER_CERT_PATH", clientCertPath);

The test

public sealed class HardenedHostContributorTests
{
    [Fact]
    public void contributor_does_nothing_when_security_is_disabled()
    {
        var bundle = new PackerBundle();
        var c = new HardenedHostContributor(Options.Create(new HomeLabConfig
        {
            Name = "x", Topology = "single", Engine = "docker",
            Security = new() { Hardened = false }
        }), Mock.Of<ITlsCertificateProvider>());

        c.Contribute(bundle);

        bundle.Scripts.Should().NotContain(s => s.FileName.Contains("harden"));
    }

    [Fact]
    public void contributor_adds_six_hardening_scripts_when_enabled()
    {
        var bundle = new PackerBundle();
        var c = new HardenedHostContributor(Options.Create(new HomeLabConfig
        {
            Name = "x", Topology = "single", Engine = "docker", Vos = new() { Subnet = "192.168.56" },
            Security = new() { Hardened = true }
        }), Mock.Of<ITlsCertificateProvider>());

        c.Contribute(bundle);

        bundle.Scripts.Should().Contain(s => s.FileName == "harden-ssh.sh");
        bundle.Scripts.Should().Contain(s => s.FileName == "harden-sudoers.sh");
        bundle.Scripts.Should().Contain(s => s.FileName == "harden-firewall.sh");
        bundle.Scripts.Should().Contain(s => s.FileName == "enable-docker-mtls.sh");
        bundle.Scripts.Should().Contain(s => s.FileName == "enable-audit.sh");
    }

    [Fact]
    public void firewall_script_uses_subnet_from_config()
    {
        var bundle = new PackerBundle();
        var c = new HardenedHostContributor(Options.Create(new HomeLabConfig
        {
            Name = "x", Topology = "single", Engine = "docker", Vos = new() { Subnet = "10.10.10" },
            Security = new() { Hardened = true }
        }), Mock.Of<ITlsCertificateProvider>());

        c.Contribute(bundle);

        var fw = bundle.Scripts.First(s => s.FileName == "harden-firewall.sh").Content;
        fw.Should().Contain("10.10.10.0/24");
    }
}

What this gives you that bash doesn't

A hardened bash script is set -euo pipefail plus iptables -L plus cat > /etc/ssh/sshd_config.d/... plus a comment that says # tested on Alpine 3.18, may not work on 3.21. The first time the user upgrades Alpine, the script breaks. The second time, it gets forked.

A typed HardenedHostContributor gives you, for the same surface area:

  • Six concerns, each in its own small script, each generated from typed config
  • Subnet-aware firewall rules computed from config.vos.subnet
  • mTLS for Docker with certs generated by the same Tls library that issues the wildcard cert
  • A clean build-time secret cleanup that removes passwords, history, and logs
  • Tests that lock the script contents
  • Composability with the Alpine and Docker contributors via the Order attribute

The bargain pays back the first time you ship a hardened lab to a security-conscious colleague and they cannot find an obvious hole.


⬇ Download