Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ Havenhold/
- npm 10+
- Docker Desktop

Optional for infrastructure provisioning (`feat/H-005` script path):
- AWS CLI v2 (`aws`)

### 1. Install dependencies

```bash
Expand Down Expand Up @@ -174,6 +177,20 @@ Frontend runs at `http://localhost:8080`, API runs at `http://localhost:3001`.
- `npm run db:seed` - seed demo data
- `npm run db:studio` - open Prisma Studio

## Infrastructure Provisioning (Optional)

For Lightsail host setup in `feat/H-005`, you can use either:
- AWS Dashboard UI flow (no local AWS CLI dependency)
- CLI helper scripts in `infra/` (requires configured AWS CLI)

CLI helper example:

```bash
KEY_PAIR_NAME=<lightsail-key-name> ./infra/create-instance.sh
```

See `docs/runbook/H-005-lightsail-provision.md` for the full runbook and safety checks.

## Security and Privacy Notes

This project handles health-related data and should be treated carefully, even as an MVP.
Expand Down
126 changes: 126 additions & 0 deletions docs/runbook/H-005-lightsail-provision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# H-005 Runbook: Lightsail Provision + Host Hardening

## Purpose
Provision a single Havenhold MVP Lightsail host and apply baseline hardening to satisfy deploy gate `D0`.

## Security Handling
- This runbook is git-tracked and must stay sanitized.
- Do not commit secrets, private keys, account tokens, or operator-only CIDRs if sensitive.
- Store key material in approved team secret storage.

## Prerequisites
- AWS account access to Lightsail in `us-east-1`
- Existing Lightsail-compatible SSH key pair name (public key registered)
- Local tools if using helper script: `aws` CLI configured
- Repo checked out locally

## Resource Naming
- Instance: `havenhold-app-01`
- Static IP: `havenhold-app-ip`
- Admin user: `havenadmin`

## Provision Path A (Recommended for this ticket): AWS Dashboard UI
1. In Lightsail (`us-east-1`), create instance:
- Platform: Linux/Unix
- Blueprint: Ubuntu `24.04 LTS`
- Plan: `small_3_0`
- Name: `havenhold-app-01`
- SSH key pair: select existing team key pair

2. Allocate and attach static IP:
- Name: `havenhold-app-ip`
- Attach to `havenhold-app-01`

3. Configure Lightsail firewall inbound rules:
- SSH: `22/tcp` (prefer operator CIDR allowlist)
- HTTP: `80/tcp` from internet
- HTTPS: `443/tcp` from internet

## Provision Path B (Optional helper script)
From repo root:

```bash
KEY_PAIR_NAME=<lightsail-key-name> ./infra/create-instance.sh
```

Optional custom SSH port:

```bash
KEY_PAIR_NAME=<lightsail-key-name> SSH_PORT=2222 ./infra/create-instance.sh
```

## Hardening Step (Remote)
Copy and execute once on the VM:

```bash
scp -i <key.pem> ./infra/provision.sh ubuntu@<STATIC_IP>:/tmp/provision.sh
ssh -i <key.pem> ubuntu@<STATIC_IP> 'bash /tmp/provision.sh'
```

After script completion, set a local sudo password for the hardened admin user:

```bash
ssh -t -i <key.pem> ubuntu@<STATIC_IP> 'sudo passwd havenadmin'
```

Optional custom SSH port on host:

```bash
ssh -i <key.pem> ubuntu@<STATIC_IP> 'SSH_PORT=2222 bash /tmp/provision.sh'
```

## Critical Safety Sequence (Avoid Lockout)
1. Keep current SSH session open.
2. If SSH port changed, confirm Lightsail firewall includes new port first.
3. From local machine, open a second SSH session with hardened user:

```bash
ssh -i <key.pem> -p <SSH_PORT> havenadmin@<STATIC_IP>
```

4. Only close original session after second session succeeds.

## D0 Verification Checklist
- [ ] Lightsail instance exists in `us-east-1` with static IP attached.
- [ ] Lightsail inbound rules expose only required ports (`22/80/443` or `2222/80/443`).
- [ ] `ssh -p <SSH_PORT> havenadmin@<STATIC_IP>` works with key auth.
- [ ] Root SSH login is rejected.
- [ ] Password SSH auth is rejected.
- [ ] UFW enabled with default deny inbound and only required ports allowed.
- [ ] `fail2ban` active with `sshd` jail loaded.
- [ ] `unattended-upgrades` enabled and healthy.
- [ ] This runbook + ticket plan are committed.

## Evidence Commands
Run on host unless noted:

```bash
# Firewall and listening state
sudo ufw status verbose
sudo ss -tulpen

# SSH hardening
sudo sshd -T | egrep 'port|permitrootlogin|passwordauthentication|pubkeyauthentication|allowusers'

# Update posture
sudo unattended-upgrade --dry-run --debug
systemctl status unattended-upgrades --no-pager

# Abuse protection
sudo fail2ban-client status sshd
```

Optional from local machine:

```bash
nmap -p <SSH_PORT>,80,443 <STATIC_IP>
```

## Rollback / Recovery
- Take a Lightsail snapshot before major SSH/firewall changes.
- If locked out, use Lightsail browser-based access (if available) to restore SSH/UFW config.
- Re-open `22/tcp` temporarily in Lightsail firewall if custom port migration fails.

## Notes
- Non-default SSH port reduces scan noise but does not replace strong key-only auth.
- Dual-layer controls are required: Lightsail firewall and UFW.
94 changes: 94 additions & 0 deletions infra/create-instance.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail

# Optional helper for H-005.
# This script uses AWS CLI to provision a Lightsail instance with a static IP and minimal public ports.
# Keep docs/runbook/H-005-lightsail-provision.md as source of truth.

INSTANCE_NAME="${INSTANCE_NAME:-havenhold-app-01}"
STATIC_IP_NAME="${STATIC_IP_NAME:-havenhold-app-ip}"
REGION="${REGION:-us-east-1}"
AZ="${AZ:-us-east-1a}"
BLUEPRINT_ID="${BLUEPRINT_ID:-ubuntu_24_04}"
BUNDLE_ID="${BUNDLE_ID:-small_3_0}"
KEY_PAIR_NAME="${KEY_PAIR_NAME:-}"
SSH_PORT="${SSH_PORT:-22}"
SSH_CIDR="${SSH_CIDR:-0.0.0.0/0}"

if ! command -v aws >/dev/null 2>&1; then
echo "ERROR: aws CLI not found. Install and configure it first."
exit 1
fi

if [[ -z "${KEY_PAIR_NAME}" ]]; then
echo "ERROR: KEY_PAIR_NAME is required."
echo "Example: KEY_PAIR_NAME=havenhold-app-key ./infra/create-instance.sh"
exit 1
fi

echo "[1/6] Creating instance ${INSTANCE_NAME} in ${AZ}..."
aws lightsail create-instances \
--region "${REGION}" \
--instance-names "${INSTANCE_NAME}" \
--availability-zone "${AZ}" \
--blueprint-id "${BLUEPRINT_ID}" \
--bundle-id "${BUNDLE_ID}" \
--key-pair-name "${KEY_PAIR_NAME}" >/dev/null

echo "[2/6] Waiting for instance state=running..."
for _ in $(seq 1 60); do
state="$(aws lightsail get-instance-state \
--region "${REGION}" \
--instance-name "${INSTANCE_NAME}" \
--query 'state.name' \
--output text 2>/dev/null || true)"
if [[ "${state}" == "running" ]]; then
break
fi
sleep 5
done

if [[ "${state:-}" != "running" ]]; then
echo "ERROR: instance did not reach running state in time."
exit 1
fi

echo "[3/6] Allocating static IP ${STATIC_IP_NAME} (if needed)..."
if ! aws lightsail get-static-ip --region "${REGION}" --static-ip-name "${STATIC_IP_NAME}" >/dev/null 2>&1; then
aws lightsail allocate-static-ip \
--region "${REGION}" \
--static-ip-name "${STATIC_IP_NAME}" >/dev/null
fi

echo "[4/6] Attaching static IP..."
aws lightsail attach-static-ip \
--region "${REGION}" \
--static-ip-name "${STATIC_IP_NAME}" \
--instance-name "${INSTANCE_NAME}" >/dev/null

echo "[5/6] Configuring public ports (Lightsail firewall)..."
# Use put-instance-public-ports so we enforce an exact allowlist and close any stale open ports.
aws lightsail put-instance-public-ports \
--region "${REGION}" \
--instance-name "${INSTANCE_NAME}" \
--port-infos \
"fromPort=${SSH_PORT},toPort=${SSH_PORT},protocol=TCP,cidrs=${SSH_CIDR}" \
"fromPort=80,toPort=80,protocol=TCP,cidrs=0.0.0.0/0" \
"fromPort=443,toPort=443,protocol=TCP,cidrs=0.0.0.0/0" >/dev/null

STATIC_IP="$(aws lightsail get-static-ip \
--region "${REGION}" \
--static-ip-name "${STATIC_IP_NAME}" \
--query 'staticIp.ipAddress' \
--output text)"

echo "[6/6] Complete"
echo "Instance: ${INSTANCE_NAME}"
echo "Static IP: ${STATIC_IP}"
echo "SSH port: ${SSH_PORT}"
echo

echo "Next steps:"
echo "1) Copy provision script: scp -i <key.pem> ./infra/provision.sh ubuntu@${STATIC_IP}:/tmp/provision.sh"
echo "2) Run it: ssh -i <key.pem> -p ${SSH_PORT} ubuntu@${STATIC_IP} 'bash /tmp/provision.sh'"
echo "Tip: lock SSH source CIDR via SSH_CIDR=203.0.113.10/32 when possible."
116 changes: 116 additions & 0 deletions infra/provision.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail

# H-005 one-time host baseline hardening script for Ubuntu Lightsail.
# Run as: bash /tmp/provision.sh (as ubuntu user with sudo access)

DEPLOY_USER="${DEPLOY_USER:-havenadmin}"
SSH_PORT="${SSH_PORT:-22}"
ALLOW_UBUNTU_USER="${ALLOW_UBUNTU_USER:-true}"

if ! command -v sudo >/dev/null 2>&1; then
echo "ERROR: sudo is required."
exit 1
fi

if [[ "$(id -u)" -ne 0 ]]; then
exec sudo -E bash "$0" "$@"
fi

log() {
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"
}

log "Step 1/7: Updating OS packages"
apt-get update -y
DEBIAN_FRONTEND=noninteractive apt-get full-upgrade -y

log "Step 2/7: Installing security baseline packages"
DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades fail2ban ufw apt-listchanges

log "Step 3/7: Enabling unattended upgrades"
cat >/etc/apt/apt.conf.d/20auto-upgrades <<'AUTOPATCH'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
AUTOPATCH
systemctl enable unattended-upgrades >/dev/null 2>&1 || true
systemctl restart unattended-upgrades

log "Step 4/7: Creating admin user ${DEPLOY_USER}"
if ! id -u "${DEPLOY_USER}" >/dev/null 2>&1; then
adduser --disabled-password --gecos "" "${DEPLOY_USER}"
fi
usermod -aG sudo "${DEPLOY_USER}"
log "NOTE: ${DEPLOY_USER} is key-only for SSH and has no local sudo password yet."
log "After this script, set one with: sudo passwd ${DEPLOY_USER}"

install -d -m 700 -o "${DEPLOY_USER}" -g "${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh"
if [[ -f /home/ubuntu/.ssh/authorized_keys ]]; then
cp /home/ubuntu/.ssh/authorized_keys "/home/${DEPLOY_USER}/.ssh/authorized_keys"
chown "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh/authorized_keys"
chmod 600 "/home/${DEPLOY_USER}/.ssh/authorized_keys"
else
log "WARNING: /home/ubuntu/.ssh/authorized_keys not found; add keys manually for ${DEPLOY_USER}."
fi

log "Step 5/7: Hardening SSH config"
ALLOW_USERS="${DEPLOY_USER}"
if [[ "${ALLOW_UBUNTU_USER}" == "true" ]]; then
ALLOW_USERS="${ALLOW_USERS} ubuntu"
fi

cat >/etc/ssh/sshd_config.d/99-havenhold.conf <<EOF_SSH
Port ${SSH_PORT}
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
PubkeyAuthentication yes
X11Forwarding no
MaxAuthTries 3
AllowUsers ${ALLOW_USERS}
EOF_SSH

# Some fresh images can miss this runtime dir until ssh is restarted.
install -d -m 0755 /run/sshd
sshd -t
systemctl restart ssh

log "Step 6/7: Configuring UFW"
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow "${SSH_PORT}/tcp" comment 'SSH'
ufw allow 80/tcp comment 'HTTP'
ufw allow 443/tcp comment 'HTTPS'
ufw --force enable

log "Step 7/7: Configuring fail2ban"
cat >/etc/fail2ban/jail.d/sshd.conf <<EOF_JAIL
[sshd]
enabled = true
port = ${SSH_PORT}
maxretry = 3
bantime = 1h
findtime = 10m
EOF_JAIL

systemctl enable fail2ban >/dev/null 2>&1 || true
systemctl restart fail2ban

log "Hardening complete."
log "Validation commands:"
cat <<EOF_VALIDATE
sudo ufw status verbose
sudo ss -tulpen
sudo sshd -T | egrep 'port|permitrootlogin|passwordauthentication|pubkeyauthentication|allowusers'
systemctl status unattended-upgrades --no-pager
sudo fail2ban-client status sshd
EOF_VALIDATE

if [[ "${SSH_PORT}" != "22" ]]; then
log "IMPORTANT: open Lightsail firewall ${SSH_PORT}/tcp before closing your current session."
fi

log "Manual safety step: from your local machine, verify a NEW SSH login works before ending current session."
log "Post-step: if sudo prompts fail for ${DEPLOY_USER}, set local password: sudo passwd ${DEPLOY_USER}"
Loading