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:
- 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.
- 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.
- 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",
}
}
});
}
}[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 ...
}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);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");
}
}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
Tlslibrary 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
Orderattribute
The bargain pays back the first time you ship a hardened lab to a security-conscious colleague and they cannot find an obvious hole.