dacloud — netcup ArchLinux VPS Setup Guide
These are my notes for getting my netcup ArchLinux VPS up and running. I use it mostly for security research, CTF challenges and other intents and purposes. Running a new instance is a two-step process:
- Install Arch from the netcup SCP panel.
- Run
dacloud-setup.shover SSH. The script handles the base configuration: a sudo user, SSH hardening, sshguard, an nftables firewall, Docker, andyayas AUR helper.
Contents:
Install a basic ArchLinux
Prepare the SSH key
SSH login is key-based all the way through. If needed, generate a new key:
ssh-keygen -t ed25519 -a 100 -C "skw@skywhi.net" -f ~/.ssh/id_dacloud
cat ~/.ssh/id_dacloud.pub
- The
.pubfile is what gets pasted into netcup. - The private key
~/.ssh/id_dacloudshould remain private (/duh!).
Install ArchLinux from the netcup SCP
- Open the server's SCP page.
- Go to Media => Images and pick Arch.
- In the install dialog:
- Hostname:
dacloud. - Timezone / language: Europe/Berlin, English. The setup script sets these anyway, so defaults are fine here.
- SSH key: paste your public key. netcup installs it for
root, so you get key-based root login and no password. The setup script picks this same key up later for your normal user, so you only paste it once.
- Hostname:
- Start the install and wait for it to finish. It only takes a few seconds.
- Write down the server's IPv4 address from the SCP console.
Add DNS record (Gandi)
- Log in at admin.gandi.net and open Domain names => <domain> => DNS records.
Click Add a record and fill in:
Field Value Type AName dacloudTTL 300Value the VPS IPv4 address Do the same for IPv6 if needed with
AAAAinstead ofAand with the IPv6 address in the SCP console.- Run
dig +short dacloud.skywhi.netto see if the changes have propagated.
Run the setup script
Copy it up and log in
# copy the script up
scp dacloud-setup.sh root@dacloud.skywhi.et:/root/
# log in as root (works because you imported your key in step 3)
ssh root@dacloud.skywhi.net
Run it on the server
Either edit the CONFIG block at the top of the script or pass values as
environment variables. These are the variables and their defaults:
| Variable | Default | Meaning |
|---|---|---|
USERNAME |
skw |
Your unprivileged login user |
TIMEZONE |
Europe/Berlin |
System timezone |
LOCALE |
en_US.UTF-8 |
System locale |
HOSTNAME_ |
dacloud |
Hostname (note the trailing underscore) |
PUBKEY |
(empty) | SSH public key; empty reuses the root key from SCP |
CONSOLE_PASSWORD |
(empty) | Console password for VNC recovery; empty = locked |
EXTRA_PACKAGES |
(empty) | Extra pacman packages, space-separated |
MIRROR_COUNTRY |
Germany |
Country reflector ranks pacman mirrors from |
Some examples:
# minimal install:
USERNAME=skw bash /root/dacloud-setup.sh
# with some extra tools:
USERNAME=skw EXTRA_PACKAGES="tmux ripgrep fd jq" bash /root/dacloud-setup.sh
The script turns off root SSH login and password authentication. If you also
skip the console password, the user's password gets locked and then nobody
can log in at the netcup VNC console. At that point a broken SSH config means a
full reinstall. Setting a console password doesn't weaken remote login at all,
since it can only be used in the local console through the SCP console.
CONSOLE_PASSWORD is also the password assigned to the user USERNAME during
setup and will be required by sudo.
root login is off once the script finishes, so confirm the new user works while the root session is still open. From another device or a new terminal:
ssh [-i path/to/key] skw@dacloud.skywhi.net
Host alias
Add this to ~/.ssh/config on your local host so that it's possible to login
withe ssh dacloud:
Host dacloud
HostName dacloud.skywhi.net
User skw
IdentityFile ~/codaz/VPS/dacloud/ssh/vps-ssh-1
IdentitiesOnly yes
Setup script
#!/usr/bin/env bash
#
# dacloud-setup.sh: base setup for dacloud, a netcup ArchLinux VPS.
#
# Usage:
# scp dacloud-setup.sh root@YOUR_IP:/root/
# ssh root@YOUR_IP
# # edit the CONFIG block below, or pass the values as env vars, then:
# USERNAME=skw EXTRA_PACKAGES="tmux nano" bash /root/dacloud-setup.sh
#
set -euo pipefail
# CONFIG (override via env or edit here)
USERNAME="${USERNAME:-skw}"
TIMEZONE="${TIMEZONE:-Europe/Berlin}"
LOCALE="${LOCALE:-en_US.UTF-8}"
HOSTNAME_="${HOSTNAME_:-dacloud}"
# Public SSH key for ${USERNAME}. Leave empty to reuse whatever key you gave
# the netcup SCP at install time (it lands in /root/.ssh/authorized_keys).
PUBKEY="${PUBKEY:-}"
# Optional console password for ${USERNAME}. SSH stays key-only either way.
# This only matters if SSH breaks and you need the netcup VNC console to get
# back in. Leave it empty and the account is locked, which means the only way
# back is a reinstall from the SCP. The password will also be used for the newly
# created user.
CONSOLE_PASSWORD="${CONSOLE_PASSWORD:-}"
EXTRA_PACKAGES="${EXTRA_PACKAGES:-tmux}"
MIRROR_COUNTRY="${MIRROR_COUNTRY:-Germany}"
log() { printf '[*] %s\n' "$*"; }
[ "$(id -u)" -eq 0 ] || { log "run this as root on the VPS"; exit 1; }
command -v pacman >/dev/null || { log "this is not an Arch system"; exit 1; }
# Enable a unit at boot and tell the caller whether it can also be started right
# now. Relies on KERNEL_STALE, which is set after the update.
enable_svc() {
systemctl enable "$1" >/dev/null 2>&1
if [ "${KERNEL_STALE}" = 1 ]; then
log "$1 enabled at boot but not started now (kernel upgraded, reboot to activate)"
return 1
fi
return 0
}
# Resolve the SSH public key.
if [ -z "${PUBKEY}" ] && [ -s /root/.ssh/authorized_keys ]; then
# Take the first key, starting at the key type token. That way a line with
# an options prefix (restrict,command="..." ssh-ed25519 AAAA...) still
# matches and the root-only options don't get copied to the new user.
PUBKEY="$(grep -m1 -oE '(sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com|ssh-(ed25519|rsa|dss)|ecdsa-sha2-[a-z0-9-]+)[[:space:]]+AAAA[A-Za-z0-9+/]+=*([[:space:]]+\S.*)?' /root/.ssh/authorized_keys 2>/dev/null | head -1 || true)"
[ -n "${PUBKEY}" ] && log "reusing the SSH key imported via the netcup SCP"
fi
[ -n "${PUBKEY}" ] || { log "no SSH key found: set PUBKEY='ssh-ed25519 ...' or import one in the SCP first"; exit 1; }
# Resolve the console password.
# If nothing was passed in and we're on a terminal, ask for it with hidden
# input. Passing it inline as an env var would leave it in shell history and
# briefly in the process list. Enter on an empty prompt means "locked".
# Non-interactive runs still pick it up from the environment.
if [ -z "${CONSOLE_PASSWORD}" ] && [ -t 0 ]; then
read -rsp "Console password for ${USERNAME} (VNC recovery; empty = locked): " CONSOLE_PASSWORD
printf '\n'
fi
# Full system update. Rank the mirrors first with reflector, update the pacman
# keyring correctly and perform an upgrade.
log "ranking pacman mirrors with reflector (country=${MIRROR_COUNTRY})"
if pacman -Sy --needed --noconfirm reflector; then
cp -a /etc/pacman.d/mirrorlist "/etc/pacman.d/mirrorlist.bak.$(date +%s)" 2>/dev/null || true
reflector --country "${MIRROR_COUNTRY}" --latest 20 --protocol https --sort rate \
--save /etc/pacman.d/mirrorlist \
|| log "reflector failed, keeping the existing mirrorlist"
else
log "could not install reflector, keeping the existing mirrorlist"
fi
log "initializing the pacman keyring and updating the system"
RUNNING_KERNEL="$(uname -r)"
pacman-key --init >/dev/null 2>&1 || true
pacman-key --populate archlinux >/dev/null 2>&1 || true
pacman -Sy --noconfirm archlinux-keyring
pacman -Syu --noconfirm
# The upgrade may have replaced the kernel. If it did, /usr/lib/modules no
# longer has the modules for the kernel we're actually running, so loading
# anything (nftables, Docker's overlay and bridge) fails until a reboot. Note
# that here so the steps below enable their services instead of starting them,
# rather than dying under set -e.
KERNEL_STALE=0
if [ ! -d "/usr/lib/modules/${RUNNING_KERNEL}" ]; then
KERNEL_STALE=1
log "The kernel was upgraded and the modules for ${RUNNING_KERNEL} are gone."
log "Services that need kernel modules will be enabled but not started. Reboot and re-run to activate them."
fi
# Everything goes in one transaction: admin tools, base-devel and go (to build
# yay below), sshguard, nftables, docker plus anything from EXTRA_PACKAGES.
log "installing base admin tools, base-devel, go, sshguard, nftables and Docker"
pacman -S --needed --noconfirm sudo openssh vim git curl wget htop base-devel go sshguard nftables \
docker docker-compose docker-buildx ${EXTRA_PACKAGES}
# Perform locale, timezone and hostname config.
log "setting timezone=${TIMEZONE} locale=${LOCALE} hostname=${HOSTNAME_}"
ln -sf "/usr/share/zoneinfo/${TIMEZONE}" /etc/localtime
sed -i "s/^#\s*\(${LOCALE//./\\.} \)/\1/" /etc/locale.gen
grep -q "^${LOCALE}" /etc/locale.gen || echo "${LOCALE} UTF-8" >> /etc/locale.gen
locale-gen
echo "LANG=${LOCALE}" > /etc/locale.conf
hostnamectl set-hostname "${HOSTNAME_}" 2>/dev/null || echo "${HOSTNAME_}" > /etc/hostname
# Time sync.
log "enabling network time sync (systemd-timesyncd)"
systemctl enable --now systemd-timesyncd >/dev/null 2>&1 || log "could not enable systemd-timesyncd"
# Cap the journal so it can't slowly eat the disk on a box that stays up for
# months.
log "capping systemd journal size (SystemMaxUse=500M)"
install -d -m755 /etc/systemd/journald.conf.d
cat > /etc/systemd/journald.conf.d/00-size.conf <<'JCONF'
[Journal]
SystemMaxUse=500M
SystemKeepFree=1G
JCONF
systemctl restart systemd-journald >/dev/null 2>&1 || true
# Kernel and network sysctl hardening.
log "applying kernel/network sysctl hardening"
cat > /etc/sysctl.d/99-hardening.conf <<'SYSCTL'
kernel.kptr_restrict=2
kernel.dmesg_restrict=1
kernel.yama.ptrace_scope=1
net.ipv4.conf.all.accept_redirects=0
net.ipv4.conf.default.accept_redirects=0
net.ipv4.conf.all.accept_source_route=0
net.ipv4.conf.default.accept_source_route=0
net.ipv6.conf.all.accept_redirects=0
net.ipv6.conf.default.accept_redirects=0
SYSCTL
sysctl --system >/dev/null 2>&1 || log "could not apply sysctl settings now (they still apply on next boot)"
# Creating user ${USERNAME}
log "creating sudo user '${USERNAME}'"
if ! id "${USERNAME}" >/dev/null 2>&1; then
useradd -m -G wheel -s /bin/bash "${USERNAME}"
fi
echo '%wheel ALL=(ALL:ALL) ALL' > /etc/sudoers.d/10-wheel
chmod 440 /etc/sudoers.d/10-wheel
visudo -cf /etc/sudoers.d/10-wheel >/dev/null || { log "sudoers syntax check failed"; exit 1; }
if [ -n "${CONSOLE_PASSWORD}" ]; then
printf '%s:%s\n' "${USERNAME}" "${CONSOLE_PASSWORD}" | chpasswd
log "set a console password for '${USERNAME}' (VNC console recovery works; SSH stays key-only)"
else
passwd -l "${USERNAME}" >/dev/null 2>&1 || true
log "no CONSOLE_PASSWORD given, so '${USERNAME}' is locked: the netcup VNC console won't let you in, only a reinstall will"
fi
# Append the key rather than overwrite, so keys added by hand survive a re-run.
install -d -m700 -o "${USERNAME}" -g "${USERNAME}" "/home/${USERNAME}/.ssh"
AKEYS="/home/${USERNAME}/.ssh/authorized_keys"
echo "${PUBKEY}" >> "${AKEYS}"
chown "${USERNAME}:${USERNAME}" "${AKEYS}"
chmod 600 "${AKEYS}"
# Install yay with the privileges of user ${USERNAME}, as it does not want to be
# installed by root. Built from source (needs base-devel and go, installed above)
# and installed as root with pacman -U, since makepkg -i would wait for a sudo
# password. The glob skips the yay-debug package makepkg also produces.
if command -v yay >/dev/null 2>&1; then
log "yay already installed, skipping"
else
log "building and installing yay (AUR helper)"
YAY_BUILD="$(mktemp -d)"
chown "${USERNAME}:${USERNAME}" "${YAY_BUILD}"
if sudo -Hu "${USERNAME}" git clone --depth 1 https://aur.archlinux.org/yay.git "${YAY_BUILD}/yay" >/dev/null 2>&1 \
&& sudo -Hu "${USERNAME}" bash -c "cd '${YAY_BUILD}/yay' && makepkg --noconfirm" >/dev/null 2>&1; then
pacman -U --needed --noconfirm "${YAY_BUILD}"/yay/yay-[0-9]*.pkg.tar.zst
else
log "could not build yay, skipping it. Later, as ${USERNAME}: git clone https://aur.archlinux.org/yay.git && cd yay && makepkg -si"
fi
rm -rf "${YAY_BUILD}"
fi
# SSHd config.
log "hardening sshd (keys only, no root, AllowUsers ${USERNAME})"
install -d -m755 /etc/ssh/sshd_config.d
cat > /etc/ssh/sshd_config.d/99-hardening.conf <<CONF
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
MaxAuthTries 3
LoginGraceTime 20
X11Forwarding no
AllowAgentForwarding no
ClientAliveInterval 60
ClientAliveCountMax 5
UseDNS no
AllowUsers ${USERNAME}
CONF
sshd -t || { log "sshd config test failed, not restarting sshd (your current session is still fine)"; exit 1; }
systemctl enable sshd >/dev/null 2>&1 || true
systemctl restart sshd
log "sshd restarted and validated"
# Nftables config.
log "enabling the nftables firewall (inbound: SSH, ICMP, CTF ports 8850-8899)"
cat > /etc/nftables.conf <<'NFT'
#!/usr/bin/nft -f
# Replace just our own table instead of `flush ruleset`. A flush would also
# throw away the table sshguard keeps for its blocklist. Declaring the table
# before deleting it makes the delete safe even on the very first load.
table inet filter
delete table inet filter
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
ct state invalid drop
ct state established,related accept
iif lo accept
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
tcp dport 22 ct state new limit rate 10/minute burst 20 packets accept
# CTF work: payload hosting and reverse-shell catchers. Open to the
# internet, no rate limit, TCP and UDP. Narrow this range if you stop
# needing it.
tcp dport 8850-8899 accept
udp dport 8850-8899 accept
}
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
# Docker: traffic leaving a container (DNS lookups, package downloads,
# anything a build does) crosses this chain on its way out. Without
# these two rules the drop policy eats it. docker0 is the default
# bridge, br-* are the networks docker-compose creates.
iifname "docker0" accept
iifname "br-*" accept
# Let forwarded traffic (e.g. a Docker container publishing into this
# range) reach the same CTF ports.
tcp dport 8850-8899 accept
udp dport 8850-8899 accept
}
chain output { type filter hook output priority 0; policy accept; }
}
NFT
if enable_svc nftables; then
systemctl start nftables >/dev/null 2>&1 || true
nft -f /etc/nftables.conf
fi
# SSHGuard config.
log "configuring sshguard (auto-blocks repeated SSH auth failures)"
SG_BACKEND="$(ls /usr/lib/sshguard/sshguard-nft /usr/libexec/sshguard/sshguard-nft 2>/dev/null | head -1 || true)"
if [ -n "${SG_BACKEND}" ]; then
cat > /etc/sshguard.conf <<CONF
BACKEND="${SG_BACKEND}"
LOGREADER="LANG=C /usr/bin/journalctl -afb -p info -n1 -t sshd -o cat"
THRESHOLD=30
BLOCK_TIME=900
DETECTION_TIME=3600
CONF
# Make sure sshguard comes up after nftables at boot, otherwise loading our
# table can race sshguard rebuilding its own. After= doesn't pull nftables
# in by itself; the nftables enable above already handles that.
install -d -m755 /etc/systemd/system/sshguard.service.d
cat > /etc/systemd/system/sshguard.service.d/10-after-nftables.conf <<'DROP'
[Unit]
After=nftables.service
DROP
systemctl daemon-reload >/dev/null 2>&1 || true
if enable_svc sshguard; then
systemctl start sshguard >/dev/null 2>&1 || log "sshguard did not start, check: journalctl -u sshguard"
fi
else
log "sshguard nftables backend not found, skipping it (SSH is still key-only)"
fi
# Docker config.
log "configuring Docker (published ports bind to 127.0.0.1 by default)"
usermod -aG docker "${USERNAME}"
install -d -m755 /etc/docker
cat > /etc/docker/daemon.json <<'JSON'
{
"ip": "127.0.0.1",
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "3" },
"live-restore": true
}
JSON
if enable_svc docker; then
systemctl start docker
fi
# Done.
IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}')"
printf '\n'
log "base configuration complete"
cat <<DONE
user: ${USERNAME} (sudo via wheel, SSH key-only login)
console: $([ -n "${CONSOLE_PASSWORD}" ] && echo 'password set (VNC recovery ok)' || echo 'locked (reinstall-only recovery)')
hostname: ${HOSTNAME_}
timezone: ${TIMEZONE} (systemd-timesyncd)
firewall: nftables (inbound: SSH + CTF ports 8850-8899 tcp/udp)
sshguard: $(systemctl is-active sshguard 2>/dev/null || echo inactive)
docker: $(systemctl is-active docker 2>/dev/null || echo inactive) (ports bind to 127.0.0.1)
yay: $(command -v yay >/dev/null 2>&1 && echo installed || echo missing)
DONE
if [ "${KERNEL_STALE}" = 1 ]; then
log "the kernel was upgraded during this run. The firewall, sshguard and Docker"
log "services are enabled at boot but not running yet. Reboot, then run this"
log "script again (it is safe to re-run) to bring them up."
fi
log "before you close this root session, check the new user works:"
cat <<DONE
From another device:
ssh [-i path/to/key] ${USERNAME}@${IP:-YOUR_IP}
sudo whoami # should print: root
Root login is disabled now, so if that fails, come back to this session and
fix /etc/ssh/sshd_config.d/99-hardening.conf.
DONE