feat: add hardened base and per-edition image overlays

Add the files that land in the image, organised as layered rootfs trees
(base first, then the edition layer wins on conflict).

- base: hardened kernel cmdline + sysctl, default-deny nftables,
  key-only ssh, persistent journald, module blacklist, no core dumps,
  snapshot timer units, motd.
- server: Prometheus + auto-provisioned Grafana + Loki + promtail.
- workstation: dev profile and desktop sysctl relaxations (perf,
  rootless containers).
- cloud: cloud-init provisioning config.
This commit is contained in:
Blake Ridgway
2026-08-21 13:15:43 -05:00
parent 729f191950
commit 94ab6043e7
19 changed files with 516 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
# Arcline OS — disabled kernel modules
# Modules that add attack surface without being needed on the base system.
# The workstation edition keeps most of these disabled too; hardware you
# actually use can be re-enabled per-module.
# exotic / historically-risky network protocols
blacklist sctp
blacklist dccp
blacklist rds
blacklist tipc
# unneeded legacy filesystems (mountable by a user with local access)
install cramfs /bin/false
install freevxfs /bin/false
install jffs2 /bin/false
install hfs /bin/false
install hfsplus /bin/false
install udf /bin/false

8
overlays/base/etc/motd Normal file
View File

@@ -0,0 +1,8 @@
Welcome to Arcline OS
Secure by default. Zero telemetry. Self-hosted by principle.
* hardening guide: man arcline-hardening (or docs/hardening.md)
* manage snapshots: arcline-snapshot snapshot|list|prune
* check firewall: nft list ruleset

View File

@@ -0,0 +1,44 @@
#!/usr/sbin/nft -f
# Arcline OS — default-deny firewall (nftables)
# Every edition ships with this policy: drop inbound by default, allow only
# what you opt in to. Services in `accept` rules are the baseline; add yours
# with `nft add rule inet filter input tcp dport <port> accept` (or edit this
# file and reload with `systemctl reload nftables`).
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
# established traffic is fine
ct state established,related accept
# loopback always
iif "lo" accept
# drop invalid packets early
ct state invalid drop
# ICMP (needed for PMTU discovery; rate-limited by kernel)
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
# baseline services
# ssh (key-based auth only — see sshd_config.d)
tcp dport 22 accept
# web (commented out by default; enable when hosting)
# tcp dport 80 accept
# tcp dport 443 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
# containers route via their own bridge tables (docker/podman);
# this chain stays drop for anything else.
}
chain output {
type filter hook output priority filter; policy accept;
# zero telemetry means we never call out — but we don't block
# outgoing by default. Arcline never phones home on its own.
}
}

View File

@@ -0,0 +1,24 @@
# Arcline OS — ssh hardening
# Secure by default: key-based auth only, root login via key only.
# To allow password auth, comment the PasswordAuthentication line — you opt
# in to exposure, never out.
#
# Matches Debian's drop-in semantics (Read drop-ins from sshd_config.d).
# authentication
PermitRootLogin prohibit-password
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
MaxAuthTries 3
LoginGraceTime 30
# session hardening
X11Forwarding no
AllowTcpForwarding yes
AllowAgentForwarding yes
ClientAliveInterval 300
ClientAliveCountMax 2
TCPKeepAlive no
UseDNS no

View File

@@ -0,0 +1,52 @@
# Arcline OS — kernel hardening (sysctl)
# Applies to every edition via the base overlay. Conservative defaults;
# the workstation edition relaxes a couple of entries (see overlays/workstation).
# ── memory ──────────────────────────────────────────────────────────────────
vm.swappiness=10
vm.overcommit_memory=2
vm.dirty_ratio=5
vm.dirty_background_ratio=2
# ── networking ──────────────────────────────────────────────────────────────
# strict reverse-path filtering
net.ipv4.conf.all.rp_filter=1
net.ipv4.conf.default.rp_filter=1
# no ICMP redirects
net.ipv4.conf.all.accept_redirects=0
net.ipv4.conf.default.accept_redirects=0
net.ipv4.conf.all.secure_redirects=0
net.ipv4.conf.default.secure_redirects=0
net.ipv4.conf.all.send_redirects=0
net.ipv4.conf.default.send_redirects=0
net.ipv6.conf.all.accept_redirects=0
net.ipv6.conf.default.accept_redirects=0
# ICMP hardening
net.ipv4.icmp_echo_ignore_broadcasts=1
net.ipv4.icmp_ignore_bogus_error_responses=1
# TCP hardening
net.ipv4.tcp_syncookies=1
net.ipv4.tcp_rfc1337=1
net.ipv4.tcp_timestamps=0
net.ipv4.conf.all.log_martians=1
net.ipv4.conf.default.log_martians=1
# IPv6: don't auto-accept router advertisements
net.ipv6.conf.all.accept_ra=0
net.ipv6.conf.default.accept_ra=0
# ── kernel / visibility ─────────────────────────────────────────────────────
kernel.dmesg_restrict=1
kernel.kptr_restrict=2
kernel.perf_event_paranoid=3
kernel.yama.ptrace_scope=1
kernel.core_uses_pid=1
kernel.unprivileged_bpf_disabled=1
kernel.randomize_va_space=2
kernel.sysrq=4
# ── filesystem hardening ────────────────────────────────────────────────────
fs.protected_hardlinks=1
fs.protected_symlinks=1
fs.protected_fifos=2
fs.protected_regular=2
fs.suid_dumpable=0

View File

@@ -0,0 +1,12 @@
# Arcline OS — journald
# Persistent, bounded, compressed logs. Logs live on @log so they survive
# system rollbacks (see btrfs/README.md).
[Journal]
Storage=persistent
Compress=yes
SystemMaxUse=500M
SystemMaxFileSize=100M
MaxRetentionSec=14day
ForwardToSyslog=no
ForwardToConsole=no

View File

@@ -0,0 +1,9 @@
# Arcline OS — systemd manager hardening
# Global defaults applied to every unit. No core dumps on disk, sane limits.
[Manager]
DefaultLimitNOFILE=65535
DefaultLimitNPROC=4096
DefaultLimitCORE=0
DumpCore=no
CoreDumpSize=0

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Arcline btrfs snapshot
Documentation=file:///usr/local/sbin/arcline-snapshot
RequiresMountsFor=/.snapshots
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/arcline-snapshot snapshot
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Daily Arcline btrfs snapshot
Requires=arcline-snapshot.service
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,41 @@
# Arcline OS — cloud-init configuration (cloud edition)
# Minimal, deterministic first-boot provisioning. No telemetry: cloud-init is
# pointed only at the configured datasource and never phones home.
# hostname
preserve_hostname: false
hostname: arclines
fqdn: arclines.local
# datasource: auto-detected (aws, gce, azure, openstack, hetzner, ...)
datasource_list: [ NoCloud, ConfigDrive, OpenStack, Ec2, GCE, Azure, Hetzner, None ]
datasource:
Ec2:
strict_id: false
metadata_urls: ["http://169.254.169.254"]
# only the arcline user is guaranteed; everything else comes from user-data
system_info:
default_user:
name: arcline
lock_passwd: true
gecos: Arcline Cloud User
groups: [adm, sudo, systemd-journal]
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
shell: /bin/bash
# disable cloud-init's own telemetry/reporting
reporting:
logging:
type: log
# grow root partition on first boot (cloud volumes)
growpart:
mode: auto
devices: ["/"]
resize_rootfs: true
# keep ssh config managed by Arcline, not cloud-init
ssh_genkeytypes: ["ed25519"]
disable_ec2_metadata: false

View File

@@ -0,0 +1,13 @@
# Arcline OS — Grafana dashboard provisioning (server edition)
apiVersion: 1
providers:
- name: Arcline
orgId: 1
folder: Arcline
type: file
disableDeletion: false
allowUiUpdates: true
updateIntervalSeconds: 30
options:
path: /etc/grafana/provisioning/dashboards

View File

@@ -0,0 +1,94 @@
{
"uid": "arcline-node-overview",
"title": "Arcline Node Overview",
"tags": ["arcline", "node"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": { "from": "now-1h", "to": "now" },
"panels": [
{
"id": 1,
"title": "System load",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "node_load1",
"legendFormat": "load1",
"refId": "A"
},
{
"expr": "node_load5",
"legendFormat": "load5",
"refId": "B"
}
],
"fieldConfig": {
"defaults": { "unit": "short" },
"overrides": []
}
},
{
"id": 2,
"title": "Memory used",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
"legendFormat": "% used",
"refId": "A"
}
],
"fieldConfig": {
"defaults": { "unit": "percent", "max": 100, "min": 0 },
"overrides": []
}
},
{
"id": 3,
"title": "Disk usage",
"type": "gauge",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "(1 - node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"}) * 100",
"legendFormat": "/",
"refId": "A"
}
],
"fieldConfig": {
"defaults": { "unit": "percent", "max": 100, "min": 0 },
"overrides": []
}
},
{
"id": 4,
"title": "Network traffic",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "Prometheus" },
"targets": [
{
"expr": "rate(node_network_receive_bytes_total[5m])",
"legendFormat": "rx {{ $labels.device }}",
"refId": "A"
},
{
"expr": "rate(node_network_transmit_bytes_total[5m])",
"legendFormat": "tx {{ $labels.device }}",
"refId": "B"
}
],
"fieldConfig": {
"defaults": { "unit": "Bps" },
"overrides": []
}
}
]
}

View File

@@ -0,0 +1,20 @@
# Arcline OS — Grafana datasource provisioning (server edition)
# Grafana is installed from the upstream apt repo (see configure-system.sh /
# docs/observability.md). These files auto-provision on first start.
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://localhost:9090
isDefault: true
editable: false
- name: Loki
type: loki
access: proxy
url: http://localhost:3100
isDefault: false
editable: false

View File

@@ -0,0 +1,35 @@
# Arcline OS — Loki configuration (server edition)
# Single-node, filesystem storage, 14-day retention. Loki is installed from
# the upstream tarball (see docs/observability.md).
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9095
common:
path_prefix: /var/lib/loki
storage:
filesystem:
chunks_directory: /var/lib/loki/chunks
rules_directory: /var/lib/loki/rules
replication_factor: 1
ring:
instance_addr: 127.0.0.1
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 14d
allow_structured_metadata: false

View File

@@ -0,0 +1,43 @@
# Arcline OS — pre-built alerting rules (Prometheus)
groups:
- name: arcline-node
rules:
- alert: NodeDown
expr: up == 0
for: 2m
labels:
severity: critical
annotations:
summary: "{{ $labels.instance }} is unreachable"
- alert: HighCpuLoad
expr: node_load1 / count(node_cpu_seconds_total{mode="idle"}) > 2
for: 10m
labels:
severity: warning
annotations:
summary: "sustained load average > 2 on {{ $labels.instance }}"
- alert: DiskAlmostFull
expr: (1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes) * 100 > 85
for: 10m
labels:
severity: warning
annotations:
summary: "filesystem {{ $labels.mountpoint }} on {{ $labels.instance }} > 85%"
- alert: RootDiskCritical
expr: (1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 > 95
for: 5m
labels:
severity: critical
annotations:
summary: "root filesystem on {{ $labels.instance }} > 95%"
- alert: ServiceRestarts
expr: increase(node_systemd_unit_restart_total[15m]) > 2
for: 0m
labels:
severity: warning
annotations:
summary: "unit {{ $labels.name }} restarting repeatedly on {{ $labels.instance }}"

View File

@@ -0,0 +1,25 @@
# Arcline OS — Prometheus configuration (server edition)
# Scrapes itself and node_exporter on the local host. Extension points for
# the Arcline toolchain (arcline-uptime exposes :8081/metrics) are commented.
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
cluster: arcline
rule_files:
- /etc/prometheus/arcline.rules.yml
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
- job_name: node
static_configs:
- targets: ["localhost:9100"]
# - job_name: arcline-uptime
# static_configs:
# - targets: ["localhost:8081"]

View File

@@ -0,0 +1,31 @@
# Arcline OS — Promtail configuration (server edition)
# Ships systemd journal + /var/log files to Loki. Zero telemetry: this only
# talks to the local Loki instance on localhost.
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /var/lib/promtail/positions.yaml
clients:
- url: http://localhost:3100/loki/api/v1/push
scrape_configs:
- job_name: systemd-journal
journal:
path: /var/log/journal
max_age: 12h
labels:
job: systemd-journal
relabel_configs:
- source_labels: ["__journal__systemd_unit"]
target_label: "unit"
- job_name: varlogs
static_configs:
- targets: [localhost]
labels:
job: varlogs
__path__: /var/log/**/*.log

View File

@@ -0,0 +1,12 @@
# Arcline Workstation — developer environment (profile.d)
# Standard Go / Rust / Node paths for every login shell.
# Go
export GOPATH="$HOME/go"
export PATH="$PATH:/usr/local/go/bin:$GOPATH/bin"
# Rust (rustup installs to ~/.cargo)
export PATH="$PATH:$HOME/.cargo/bin"
# Node
export PATH="$PATH:./node_modules/.bin"

View File

@@ -0,0 +1,12 @@
# Arcline Workstation — desktop relaxation overrides
# The base hardening is strict (perf_event_paranoid=3 blocks `perf` for
# unprivileged users). A workstation developer wants perf, tracing, and
# unprivileged user namespaces (used by podman/rootless containers). These
# lines land AFTER the base file, so they win.
kernel.perf_event_paranoid=1
kernel.unprivileged_bpf_disabled=0
kernel.yama.ptrace_scope=0
# rootless containers
kernel.unprivileged_userns_clone=1