Compare commits
11 Commits
7ba8921b24
...
9e3996a72b
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3996a72b | |||
| 163aefddee | |||
| 3a1ef3a167 | |||
| cce9b5c4c4 | |||
| 2bcf67dcc4 | |||
| b19e194c0b | |||
| 1a7f823a87 | |||
| 0ca8394f8a | |||
| e767ab3235 | |||
| 50ab425e8f | |||
| b7439b23e3 |
@@ -16,3 +16,14 @@ jobs:
|
|||||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
|
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
ssh -o StrictHostKeyChecking=accept-new -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-nginx
|
ssh -o StrictHostKeyChecking=accept-new -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-nginx
|
||||||
|
|
||||||
|
test:
|
||||||
|
needs: deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.x'
|
||||||
|
- run: pip install pytest
|
||||||
|
- run: pytest tests/ -v
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ docker/nginx/certs/
|
|||||||
docker/nginx/certbot/
|
docker/nginx/certbot/
|
||||||
docker/garage/data/
|
docker/garage/data/
|
||||||
docker/garage/meta/
|
docker/garage/meta/
|
||||||
|
SECURITY.md
|
||||||
|
__pycache__/
|
||||||
|
|||||||
+2
-1
@@ -119,7 +119,8 @@ cert as SANs.
|
|||||||
Services needing secrets declare them in `.env.keys`:
|
Services needing secrets declare them in `.env.keys`:
|
||||||
|
|
||||||
```
|
```
|
||||||
ENV_VAR=bws-secret-name
|
# bws-secret-name
|
||||||
|
ENV_VAR=bws-secret-uuid
|
||||||
```
|
```
|
||||||
|
|
||||||
During deploy, `deploy.sh`:
|
During deploy, `deploy.sh`:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ docker/
|
|||||||
Dockerfile
|
Dockerfile
|
||||||
garage.toml
|
garage.toml
|
||||||
.env.keys
|
.env.keys
|
||||||
|
tests/ # pytest integration tests (run post-deploy)
|
||||||
.gitea/workflows/ # Per-app deploy workflows + provision.yml
|
.gitea/workflows/ # Per-app deploy workflows + provision.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -160,6 +161,21 @@ docker exec garage /garage layout apply --version 1
|
|||||||
If only hantim is reprovisioned, argento retains the layout and hantim
|
If only hantim is reprovisioned, argento retains the layout and hantim
|
||||||
reconnects automatically.
|
reconnects automatically.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests live in `tests/` and run with pytest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pytest
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests auto-discover sites from `docker/nginx/conf.d/*.conf` — no list to
|
||||||
|
maintain when adding new sites.
|
||||||
|
|
||||||
|
The `deploy-nginx.yml` workflow runs tests automatically after each nginx
|
||||||
|
deploy. If tests fail, Gitea sends an email notification.
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
### Bitwarden Secrets Manager
|
### Bitwarden Secrets Manager
|
||||||
@@ -184,8 +200,9 @@ Machine accounts: `hantim-server` (token at `/etc/bws-token`), `hantim-ci` (rese
|
|||||||
### Service secrets (`.env.keys`)
|
### Service secrets (`.env.keys`)
|
||||||
|
|
||||||
Services that need secrets declare them in `.env.keys` (format:
|
Services that need secrets declare them in `.env.keys` (format:
|
||||||
`ENV_VAR=bws-secret-name`, one per line). `deploy.sh` fetches each secret
|
`ENV_VAR=bws-secret-uuid`, one per line, with `# secret-name` comments).
|
||||||
from bws and generates `.env` before starting the service.
|
`deploy.sh` fetches each secret by UUID from bws and generates `.env`
|
||||||
|
before starting the service.
|
||||||
|
|
||||||
### Gitea org-level secrets
|
### Gitea org-level secrets
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
RPC_SECRET=hantim-garage-rpc-secret
|
# hantim-garage-rpc-secret
|
||||||
ARGENTO_NODE_ID=hantim-garage-argento-node-id
|
RPC_SECRET=076a4798-4180-4190-9212-b41200ff08e3
|
||||||
ADMIN_TOKEN=hantim-garage-admin-token
|
# hantim-garage-argento-node-id
|
||||||
|
ARGENTO_NODE_ID=98454088-faf7-4d36-b98a-b41200ff52dd
|
||||||
|
# hantim-garage-admin-token
|
||||||
|
ADMIN_TOKEN=5565abc1-cd9f-451e-97a2-b412015dbb53
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Drop connections with unknown Host headers
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
server_name _;
|
||||||
|
return 444;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
listen [::]:443 ssl default_server;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/hantim.net/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.pem;
|
||||||
|
|
||||||
|
return 444;
|
||||||
|
}
|
||||||
@@ -22,9 +22,14 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/garage.hantim.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/garage.hantim.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/garage.hantim.net/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/garage.hantim.net/privkey.pem;
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000; preload" always;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
# restrict to argento (home IP)
|
||||||
|
# update if IP changes: dig +short argento.ddns.net
|
||||||
|
allow 173.79.207.76;
|
||||||
|
deny all;
|
||||||
|
|
||||||
proxy_pass http://garage:3903;
|
proxy_pass http://garage:3903;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/haanmind.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/haanmind.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/haanmind.net/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/haanmind.net/privkey.pem;
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000; preload" always;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/hantim.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/hantim.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.pem;
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000; preload" always;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/hcsuzuki.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/hcsuzuki.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/hcsuzuki.net/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/hcsuzuki.net/privkey.pem;
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000; preload" always;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/s3.hantim.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/s3.hantim.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/s3.hantim.net/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/s3.hantim.net/privkey.pem;
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000; preload" always;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
|
||||||
client_max_body_size 100M;
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
add_header Strict-Transport-Security "max-age=63072000; preload" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||||
@@ -36,7 +36,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/thekims.family/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/thekims.family/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/thekims.family/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/thekims.family/privkey.pem;
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000; preload" always;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/timothykim.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/timothykim.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/timothykim.net/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/timothykim.net/privkey.pem;
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000; preload" always;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
+10
-2
@@ -13,8 +13,16 @@ http {
|
|||||||
access_log /var/log/nginx/access.log;
|
access_log /var/log/nginx/access.log;
|
||||||
error_log /var/log/nginx/error.log;
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
add_header X-Content-Type-Options nosniff;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
add_header X-Frame-Options DENY;
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
ssl_stapling on;
|
||||||
|
ssl_stapling_verify on;
|
||||||
|
resolver 1.1.1.1 8.8.8.8 valid=300s;
|
||||||
|
resolver_timeout 5s;
|
||||||
|
|
||||||
include /etc/nginx/conf.d/*.conf;
|
include /etc/nginx/conf.d/*.conf;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ if ! id deploy &>/dev/null; then
|
|||||||
echo "==> Creating deploy user..."
|
echo "==> Creating deploy user..."
|
||||||
useradd -r -s /usr/sbin/nologin deploy
|
useradd -r -s /usr/sbin/nologin deploy
|
||||||
fi
|
fi
|
||||||
usermod -aG docker deploy
|
|
||||||
|
|
||||||
echo "==> Setting up deploy SSH key..."
|
echo "==> Setting up deploy SSH key..."
|
||||||
mkdir -p /home/deploy/.ssh
|
mkdir -p /home/deploy/.ssh
|
||||||
|
|||||||
+4
-3
@@ -57,11 +57,12 @@ if [ -f "docker/$APP/.env.keys" ] && [ -f /etc/bws-token ]; then
|
|||||||
env_content=""
|
env_content=""
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
[ -z "$line" ] && continue
|
[ -z "$line" ] && continue
|
||||||
|
[[ "$line" = \#* ]] && continue
|
||||||
var_name="${line%%=*}"
|
var_name="${line%%=*}"
|
||||||
secret_key="${line#*=}"
|
secret_id="${line#*=}"
|
||||||
value=$(bws secret list | jq -r --arg key "$secret_key" '.[] | select(.key == $key) | .value')
|
value=$(bws secret get "$secret_id" | jq -r .value)
|
||||||
if [ -z "$value" ]; then
|
if [ -z "$value" ]; then
|
||||||
echo "ERROR: Secret '$secret_key' not found in bws."
|
echo "ERROR: Secret '$secret_id' not found in bws."
|
||||||
echo " Check that the secret exists and the machine account has access."
|
echo " Check that the secret exists and the machine account has access."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,29 @@
|
|||||||
|
"""Test that unknown Host headers are dropped by the default server block."""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
HOST = "www.hantim.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_host_https_dropped():
|
||||||
|
"""HTTPS request with a fake Host header should be dropped (connection reset)."""
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
sock = socket.create_connection((HOST, 443))
|
||||||
|
ssock = ctx.wrap_socket(sock, server_hostname=HOST)
|
||||||
|
ssock.sendall(b"GET / HTTP/1.1\r\nHost: evil.example.com\r\n\r\n")
|
||||||
|
resp = ssock.recv(4096)
|
||||||
|
assert resp == b"", f"Expected empty response, got {resp[:100]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_host_http_dropped():
|
||||||
|
"""HTTP request with a fake Host header should be dropped (connection reset)."""
|
||||||
|
sock = socket.create_connection((HOST, 80))
|
||||||
|
sock.sendall(b"GET / HTTP/1.1\r\nHost: evil.example.com\r\n\r\n")
|
||||||
|
resp = sock.recv(4096)
|
||||||
|
assert resp == b"", f"Expected empty response, got {resp[:100]}"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Test that security headers are present on all HTTPS sites."""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
CONF_DIR = os.path.join(os.path.dirname(__file__), "..", "docker", "nginx", "conf.d")
|
||||||
|
|
||||||
|
|
||||||
|
def discover_sites():
|
||||||
|
"""Build HTTPS URLs from nginx conf files that include security-headers.inc."""
|
||||||
|
sites = []
|
||||||
|
for conf in sorted(glob.glob(os.path.join(CONF_DIR, "*.conf"))):
|
||||||
|
text = open(conf).read()
|
||||||
|
if "security-headers.inc" not in text:
|
||||||
|
continue
|
||||||
|
# Use the first server_name in the block that includes the headers
|
||||||
|
for block in re.split(r"(?=server\s*\{)", text):
|
||||||
|
if "security-headers.inc" in block:
|
||||||
|
m = re.search(r"server_name\s+([^;]+);", block)
|
||||||
|
if m:
|
||||||
|
sites.append(f"https://{m.group(1).split()[0]}")
|
||||||
|
break
|
||||||
|
return sites
|
||||||
|
|
||||||
|
|
||||||
|
SITES = discover_sites()
|
||||||
|
|
||||||
|
EXPECTED_HEADERS = {
|
||||||
|
"Strict-Transport-Security": "max-age=63072000; preload",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||||
|
"Permissions-Policy": "geolocation=(), microphone=(), camera=()",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_headers(url):
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
req = urllib.request.Request(url, method="HEAD")
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, context=ctx, timeout=10)
|
||||||
|
return dict(resp.headers)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return dict(e.headers)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", SITES, ids=lambda u: u.split("//")[1])
|
||||||
|
def test_security_headers(url):
|
||||||
|
headers = get_headers(url)
|
||||||
|
missing = []
|
||||||
|
for name, expected in EXPECTED_HEADERS.items():
|
||||||
|
actual = headers.get(name)
|
||||||
|
if actual != expected:
|
||||||
|
missing.append(f"{name}: expected '{expected}', got '{actual}'")
|
||||||
|
assert not missing, "\n".join(missing)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Test that TLS is properly hardened on all HTTPS sites."""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
HOST = "www.hantim.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls13_works():
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.load_default_certs()
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
|
||||||
|
ctx.maximum_version = ssl.TLSVersion.TLSv1_3
|
||||||
|
with ctx.wrap_socket(socket.create_connection((HOST, 443)), server_hostname=HOST) as s:
|
||||||
|
assert s.version() == "TLSv1.3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls12_works():
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.load_default_certs()
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||||
|
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
|
||||||
|
with ctx.wrap_socket(socket.create_connection((HOST, 443)), server_hostname=HOST) as s:
|
||||||
|
assert s.version() == "TLSv1.2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls11_rejected():
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_1
|
||||||
|
ctx.maximum_version = ssl.TLSVersion.TLSv1_1
|
||||||
|
with pytest.raises((ssl.SSLError, OSError)):
|
||||||
|
ctx.wrap_socket(socket.create_connection((HOST, 443)), server_hostname=HOST)
|
||||||
+1
-1
@@ -339,7 +339,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/$APP/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/$APP/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.pem;
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000; preload" always;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
Reference in New Issue
Block a user