Compare commits
30 Commits
fcc720ee7b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f951281d5 | |||
| f093a66580 | |||
| f48ad0e21e | |||
| 1f41f51430 | |||
| 50ee47917c | |||
| a303e55de6 | |||
| bc41880155 | |||
| 59f0a367ef | |||
| 7366f11323 | |||
| 3678b37133 | |||
| 37ad2405b3 | |||
| 6a0792e1fd | |||
| 1f3ddc8024 | |||
| 514061936b | |||
| 5e85de9130 | |||
| 900900a95b | |||
| 0869be8814 | |||
| ba04116fb2 | |||
| ff828ecc44 | |||
| 449b763621 | |||
| 553b77788d | |||
| f5f08c48ac | |||
| cf92394e3f | |||
| a8a3acff84 | |||
| ee0c58ad28 | |||
| 4b32e358a1 | |||
| 79996d02a4 | |||
| 54d6c03bd1 | |||
| 0656d245d5 | |||
| d2b9d0bce9 |
@@ -13,6 +13,7 @@ jobs:
|
|||||||
- name: Deploy via SSH
|
- name: Deploy via SSH
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ vars.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts
|
||||||
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-beszel
|
ssh -o StrictHostKeyChecking=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-beszel
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: Deploy carolpreschool.com
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'docker/carolpreschool.com/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Deploy via SSH
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ vars.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts
|
||||||
|
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh -o StrictHostKeyChecking=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-carolpreschool.com
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: Deploy goatcounter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'docker/goatcounter/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Deploy via SSH
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ vars.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts
|
||||||
|
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh -o StrictHostKeyChecking=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-goatcounter
|
||||||
@@ -15,19 +15,8 @@ jobs:
|
|||||||
- name: Deploy via SSH
|
- name: Deploy via SSH
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ vars.DEPLOY_HOST_KEY }}" >> ~/.ssh/known_hosts
|
echo "${{ vars.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts
|
||||||
chmod 644 ~/.ssh/known_hosts
|
|
||||||
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=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-nginx
|
ssh -o StrictHostKeyChecking=yes -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
|
|
||||||
|
|||||||
+21
-2
@@ -92,7 +92,7 @@ parses it to determine the action:
|
|||||||
|
|
||||||
- `deploy-<app>` — deploy a specific app
|
- `deploy-<app>` — deploy a specific app
|
||||||
- `deploy-nginx` — deploy nginx (fails if certs are missing)
|
- `deploy-nginx` — deploy nginx (fails if certs are missing)
|
||||||
- `cert-<domain>` — issue a cert only (via `certbot --webroot`, zero downtime)
|
- `cert-<domain>` — issue a cert only (via `certbot --webroot`, zero downtime; subdomains like `garage.hantim.net` skip the `www.` variant)
|
||||||
- `provision` — run `configure.sh`
|
- `provision` — run `configure.sh`
|
||||||
|
|
||||||
## SSL certificate handling
|
## SSL certificate handling
|
||||||
@@ -105,7 +105,12 @@ cert as SANs.
|
|||||||
1. Run `app.sh cert <domain>` (uses `certbot --webroot`, zero downtime)
|
1. Run `app.sh cert <domain>` (uses `certbot --webroot`, zero downtime)
|
||||||
2. Deploy nginx config — `deploy.sh` verifies certs exist, fails if missing
|
2. Deploy nginx config — `deploy.sh` verifies certs exist, fails if missing
|
||||||
|
|
||||||
**Renewal:** certbot timer/cron runs daily, deploy hook reloads nginx.
|
**Renewal:** certbot timer/cron runs daily, deploy hook reloads nginx. All certs
|
||||||
|
must use the `webroot` authenticator so nginx can keep port 80; certs issued by
|
||||||
|
the old `--standalone` flow (removed in `de1d60b`) can never renew because nginx
|
||||||
|
holds port 80. `configure.sh` self-heals this on every `provision`: it scans
|
||||||
|
`/etc/letsencrypt/renewal/*.conf` and reissues any `standalone` cert via the
|
||||||
|
`cert-<domain>` webroot path (best-effort; warns and exits non-zero on failure).
|
||||||
|
|
||||||
## `.env.keys` mechanism
|
## `.env.keys` mechanism
|
||||||
|
|
||||||
@@ -133,6 +138,19 @@ If any secret is missing, deploy fails with an error (not a warning).
|
|||||||
- **One domain per conf file** — `deploy.sh` derives the cert name from the
|
- **One domain per conf file** — `deploy.sh` derives the cert name from the
|
||||||
filename and collects all `server_name` values for that cert
|
filename and collects all `server_name` values for that cert
|
||||||
|
|
||||||
|
## GoatCounter (analytics)
|
||||||
|
|
||||||
|
GoatCounter runs at `goatcounter.hantim.net` for privacy-friendly web analytics.
|
||||||
|
|
||||||
|
**Script injection:** nginx injects the GoatCounter tracking script into all
|
||||||
|
HTML responses via `sub_filter`. The shared include `conf.d/goatcounter.inc`
|
||||||
|
is added to each site's main HTTPS server block. New sites get it automatically
|
||||||
|
via `app.sh`.
|
||||||
|
|
||||||
|
**Config:** `docker/goatcounter/compose.yaml` uses the `baethon/goatcounter`
|
||||||
|
image. Password is stored in Bitwarden (`hantim-goatcounter-password`) and
|
||||||
|
injected via the `.env.keys` mechanism.
|
||||||
|
|
||||||
## URL routing
|
## URL routing
|
||||||
|
|
||||||
- All HTTP → HTTPS redirect
|
- All HTTP → HTTPS redirect
|
||||||
@@ -175,6 +193,7 @@ Dockerfile # Copies static/ into nginx:alpine, applies nginx.conf
|
|||||||
nginx.conf # Per-app HTTP config (port 80, routing, cache headers)
|
nginx.conf # Per-app HTTP config (port 80, routing, cache headers)
|
||||||
static/ # HTML/CSS/JS content
|
static/ # HTML/CSS/JS content
|
||||||
static/media/ # Local media files (gitignored)
|
static/media/ # Local media files (gitignored)
|
||||||
|
media.sh # Sync media with Garage (pull/push)
|
||||||
.gitea/workflows/build.yml # Build image, push to registry, SSH deploy
|
.gitea/workflows/build.yml # Build image, push to registry, SSH deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ for technical deep-dives, USECASES.md for expected behaviors.
|
|||||||
- `scripts/bootstrap.sh` -- First-time server setup (manual, uses Bitwarden)
|
- `scripts/bootstrap.sh` -- First-time server setup (manual, uses Bitwarden)
|
||||||
- `scripts/configure.sh` -- Idempotent server config (firewall, certbot, start services; CI-safe)
|
- `scripts/configure.sh` -- Idempotent server config (firewall, certbot, start services; CI-safe)
|
||||||
- `scripts/deploy.sh` -- SSH-triggered deploy + cert issuance + provision (deploy-*, cert-*, provision commands)
|
- `scripts/deploy.sh` -- SSH-triggered deploy + cert issuance + provision (deploy-*, cert-*, provision commands)
|
||||||
- `tools/app.sh` -- App provisioning by subcommand (dns, repo, files, cert, garage, build, verify, all)
|
- `tools/app.sh` -- App provisioning by subcommand (dns, repo, files, cert, garage, build, verify, monitor, all)
|
||||||
- `tools/service.sh` -- Manage Docker services by subcommand (dns, files, all)
|
- `tools/service.sh` -- Manage Docker services by subcommand (dns, files, nginx, cert, all)
|
||||||
- `tools/remove-app.sh` -- Remove a static site (local files + Gitea repo)
|
- `tools/remove-app.sh` -- Remove a static site (local files + Gitea repo)
|
||||||
- `docker/nginx/conf.d/` -- Per-app nginx server blocks
|
- `docker/nginx/conf.d/` -- Per-app nginx server blocks
|
||||||
- `docker/<domain>/compose.yml` -- Per-app compose files
|
- `docker/<domain>/compose.yml` -- Per-app compose files
|
||||||
|
- `docker/goatcounter/` -- GoatCounter analytics (compose.yaml, .env.keys)
|
||||||
- `docker/garage/` -- Garage S3 object storage (Dockerfile, garage.toml, .env.keys)
|
- `docker/garage/` -- Garage S3 object storage (Dockerfile, garage.toml, .env.keys)
|
||||||
- `.gitea/workflows/` -- Per-app deploy workflows + provision.yml
|
- `.gitea/workflows/` -- Per-app deploy workflows + provision.yml
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ scripts/
|
|||||||
deploy.sh # Deploy + provision (called via SSH)
|
deploy.sh # Deploy + provision (called via SSH)
|
||||||
tools/
|
tools/
|
||||||
app.sh # Add a new static site (run from dev machine)
|
app.sh # Add a new static site (run from dev machine)
|
||||||
service.sh # Manage Docker services by subcommand (dns, files, all)
|
service.sh # Manage Docker services by subcommand (dns, files, nginx, cert, all)
|
||||||
remove-app.sh # Remove a static site (run from dev machine)
|
remove-app.sh # Remove a static site (run from dev machine)
|
||||||
docker/
|
docker/
|
||||||
nginx/ # Reverse proxy (nginx:alpine) + SSL config
|
nginx/ # Reverse proxy (nginx:alpine) + SSL config
|
||||||
@@ -38,6 +38,7 @@ docker/
|
|||||||
compose.yml
|
compose.yml
|
||||||
<domain>/ # Per-app compose files (e.g. example.com)
|
<domain>/ # Per-app compose files (e.g. example.com)
|
||||||
compose.yml
|
compose.yml
|
||||||
|
goatcounter/ # GoatCounter analytics
|
||||||
garage/ # Garage S3-compatible object storage
|
garage/ # Garage S3-compatible object storage
|
||||||
compose.yml
|
compose.yml
|
||||||
Dockerfile
|
Dockerfile
|
||||||
@@ -163,19 +164,55 @@ reconnects automatically.
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests live in `tests/` and run with pytest:
|
Tests live in `tests/` and run with [uv](https://docs.astral.sh/uv/):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install pytest
|
# Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
pytest tests/ -v
|
|
||||||
|
# Live site tests (security headers, TLS, default server)
|
||||||
|
uvx pytest tests/ -v --ignore=tests/test_app_e2e.py
|
||||||
|
|
||||||
|
# E2E tests for tools/app.sh (creates real resources)
|
||||||
|
uvx pytest tests/test_app_e2e.py -v -x
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests auto-discover sites from `docker/nginx/conf.d/*.conf` — no list to
|
### Live site tests
|
||||||
maintain when adding new sites.
|
|
||||||
|
|
||||||
The `deploy-nginx.yml` workflow runs tests automatically after each nginx
|
These run against live sites and verify infrastructure invariants:
|
||||||
|
|
||||||
|
- **`test_security_headers.py`** — Checks that all sites including
|
||||||
|
`security-headers.inc` return the expected headers (HSTS, X-Frame-Options,
|
||||||
|
etc.). Sites are auto-discovered from `docker/nginx/conf.d/*.conf`.
|
||||||
|
- **`test_tls.py`** — Verifies TLS 1.3 and 1.2 work, TLS 1.1 is rejected.
|
||||||
|
- **`test_default_server.py`** — Verifies unknown Host headers get dropped
|
||||||
|
(connection reset) on both HTTP and HTTPS.
|
||||||
|
|
||||||
|
The `deploy-nginx.yml` workflow runs these automatically after each nginx
|
||||||
deploy. If tests fail, Gitea sends an email notification.
|
deploy. If tests fail, Gitea sends an email notification.
|
||||||
|
|
||||||
|
### E2E tests
|
||||||
|
|
||||||
|
`test_app_e2e.py` tests `tools/app.sh` against real services (Vultr, Gitea,
|
||||||
|
Garage, UptimeRobot). Uses `kgfamily.com` as a throwaway test domain.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- CLI tools: `bws`, `jq`, `dig`, `ssh`, `curl`, `git`
|
||||||
|
- `BWS_ACCESS_TOKEN` env var or token at `~/.config/hantim/bws-token`
|
||||||
|
- Vultr API key must allow requests from the machine's IP
|
||||||
|
(check https://my.vultr.com/settings/#settingsapi)
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
|
||||||
|
1. **TestAppSubcommands** — Runs each `app.sh` subcommand individually
|
||||||
|
(dns, repo, files, cert, garage, build, monitor), verifies side effects
|
||||||
|
via API, then cleans up.
|
||||||
|
2. **TestAppAll** — Runs `app.sh all kgfamily.com`, verifies the full flow
|
||||||
|
including site liveness and git commit, then cleans up.
|
||||||
|
|
||||||
|
Cleanup runs before and after each phase via APIs (Vultr, Gitea, Garage,
|
||||||
|
UptimeRobot) + local file deletion. Does not use `remove-app.sh` to avoid
|
||||||
|
coupling.
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
### Bitwarden Secrets Manager
|
### Bitwarden Secrets Manager
|
||||||
@@ -195,6 +232,7 @@ Project: `hantim`. Fetched via `bws` CLI on the server.
|
|||||||
| `hantim-garage-media-key-id` | S3 access key ID for media uploads | `app.sh`, `aws` CLI |
|
| `hantim-garage-media-key-id` | S3 access key ID for media uploads | `app.sh`, `aws` CLI |
|
||||||
| `hantim-garage-media-secret-key` | S3 secret key for media uploads | `aws` CLI |
|
| `hantim-garage-media-secret-key` | S3 secret key for media uploads | `aws` CLI |
|
||||||
| `hantim-uptimerobot-api-key` | UptimeRobot API key for monitors | `app.sh` |
|
| `hantim-uptimerobot-api-key` | UptimeRobot API key for monitors | `app.sh` |
|
||||||
|
| `hantim-goatcounter-password` | GoatCounter admin password | `deploy.sh` |
|
||||||
|
|
||||||
Machine accounts: `hantim-server` (token at `/etc/bws-token`), `hantim-ci` (reserved).
|
Machine accounts: `hantim-server` (token at `/etc/bws-token`), `hantim-ci` (reserved).
|
||||||
|
|
||||||
|
|||||||
+34
-3
@@ -89,12 +89,43 @@ for stateless static sites.
|
|||||||
- Existing certs skipped
|
- Existing certs skipped
|
||||||
- Services restarted
|
- Services restarted
|
||||||
|
|
||||||
## 8. Upload media files
|
## 8. Add a new Docker service
|
||||||
|
|
||||||
**Trigger:** `aws s3 cp` or `aws s3 sync` to `s3://example.com/`.
|
**Trigger:** `./tools/service.sh all <name> <port>` from dev machine.
|
||||||
|
|
||||||
|
**Prerequisites:** `bws`/`jq`/`dig` installed.
|
||||||
|
|
||||||
|
**Expected outcome:**
|
||||||
|
- DNS A record created for `<name>.hantim.net`
|
||||||
|
- Compose file and deploy workflow scaffolded
|
||||||
|
- Nginx conf created proxying `<name>.hantim.net` to port `<port>`
|
||||||
|
- SSL cert issued for `<name>.hantim.net` (no www variant)
|
||||||
|
- Service ready for customization (edit compose.yml image, add `.env.keys` if needed)
|
||||||
|
|
||||||
|
## 9. Analytics tracking
|
||||||
|
|
||||||
|
**Trigger:** automatic — nginx injects the GoatCounter script into all HTML responses.
|
||||||
|
|
||||||
|
**Expected outcome:**
|
||||||
|
- Every site includes the GoatCounter tracking script (injected via `sub_filter`)
|
||||||
|
- Page views are recorded at `goatcounter.hantim.net`
|
||||||
|
- New sites provisioned with `app.sh` include tracking automatically
|
||||||
|
- GoatCounter dashboard itself is not tracked
|
||||||
|
|
||||||
|
## 10. Upload media files
|
||||||
|
|
||||||
|
**Trigger:** `aws s3 cp` or `aws s3 sync` to `s3://example.com/`, or `./media.sh push` from a site repo.
|
||||||
|
|
||||||
**Expected outcome:**
|
**Expected outcome:**
|
||||||
- File uploaded via S3 API (`s3.hantim.net`)
|
- File uploaded via S3 API (`s3.hantim.net`)
|
||||||
- Replicated to both Garage nodes (hantim + argento)
|
- Replicated to both Garage nodes (hantim + argento)
|
||||||
- Accessible at `https://www.example.com/media/<path>`
|
- Accessible at `https://www.example.com/media/<path>`
|
||||||
- Local development: `static/media/` (gitignored) serves at `/media/`
|
|
||||||
|
## 11. Local development with media files
|
||||||
|
|
||||||
|
**Trigger:** `./media.sh pull` from a site repo.
|
||||||
|
|
||||||
|
**Expected outcome:**
|
||||||
|
- Media files downloaded from Garage to `static/media/` (gitignored)
|
||||||
|
- Files served locally at `/media/` by the app container's nginx
|
||||||
|
- To upload local changes back to Garage: `./media.sh push`
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# hantim-beszel-public-key
|
||||||
|
KEY=1b5b742b-42db-4a03-a8ec-b42600150be4
|
||||||
|
# hantim-beszel-token
|
||||||
|
TOKEN=6666398f-b936-4781-954b-b426001528cb
|
||||||
@@ -2,4 +2,6 @@
|
|||||||
beszel_data/
|
beszel_data/
|
||||||
# !create
|
# !create
|
||||||
beszel_socket/
|
beszel_socket/
|
||||||
|
# !create
|
||||||
|
beszel_agent_data/
|
||||||
|
|
||||||
|
|||||||
+23
-19
@@ -3,27 +3,31 @@ services:
|
|||||||
image: henrygd/beszel
|
image: henrygd/beszel
|
||||||
container_name: beszel
|
container_name: beszel
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- 8090:8090
|
|
||||||
environment:
|
environment:
|
||||||
- APP_URL=http://localhost:8090
|
APP_URL: https://beszel.hantim.net
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8090:8090
|
||||||
volumes:
|
volumes:
|
||||||
- ./beszel_data:/beszel_data
|
- ./beszel_data:/beszel_data
|
||||||
- ./beszel_socket:/beszel_socket
|
- ./beszel_socket:/beszel_socket
|
||||||
|
networks:
|
||||||
|
- shared
|
||||||
|
|
||||||
|
beszel-agent:
|
||||||
|
image: henrygd/beszel-agent
|
||||||
|
container_name: beszel-agent
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- ./beszel_agent_data:/var/lib/beszel-agent
|
||||||
|
- ./beszel_socket:/beszel_socket
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
LISTEN: /beszel_socket/beszel.sock
|
||||||
|
HUB_URL: http://localhost:8090
|
||||||
|
|
||||||
|
networks:
|
||||||
|
shared:
|
||||||
|
external: true
|
||||||
|
|
||||||
# beszel-agent:
|
|
||||||
# image: henrygd/beszel-agent:latest
|
|
||||||
# container_name: beszel-agent
|
|
||||||
# restart: unless-stopped
|
|
||||||
# network:
|
|
||||||
# - shared
|
|
||||||
# network_mode: host
|
|
||||||
# volumes:
|
|
||||||
# - ./beszel_agent_data:/var/lib/beszel-agent
|
|
||||||
# - ./beszel_socket:/beszel_socket
|
|
||||||
# - /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
# environment:
|
|
||||||
# LISTEN: /beszel_socket/beszel.sock
|
|
||||||
# HUB_URL: http://beszel:8090
|
|
||||||
# TOKEN: <token>
|
|
||||||
# KEY: "<key>"
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: git.timothykim.net/hantim/carolpreschool.com:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
container_name: carolpreschool_com
|
||||||
|
networks:
|
||||||
|
- shared
|
||||||
|
|
||||||
|
networks:
|
||||||
|
shared:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# hantim-goatcounter-password
|
||||||
|
GOATCOUNTER_PASSWORD=1b02fe2e-7acb-4111-918a-b42701546299
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# !create
|
||||||
|
db/
|
||||||
|
.env
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
goatcounter:
|
||||||
|
image: baethon/goatcounter
|
||||||
|
restart: unless-stopped
|
||||||
|
container_name: goatcounter
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
GOATCOUNTER_DOMAIN: goatcounter.hantim.net
|
||||||
|
GOATCOUNTER_EMAIL: timothykim@fastmail.fm
|
||||||
|
volumes:
|
||||||
|
- ./db:/goatcounter/db
|
||||||
|
networks:
|
||||||
|
- shared
|
||||||
|
|
||||||
|
networks:
|
||||||
|
shared:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name beszel.hantim.net;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://beszel.hantim.net$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name beszel.hantim.net;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/beszel.hantim.net/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/beszel.hantim.net/privkey.pem;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://beszel:8090;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Redirect HTTP to HTTPS, bare domain to www
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name carolpreschool.com www.carolpreschool.com;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://www.carolpreschool.com$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect bare HTTPS domain to www
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name carolpreschool.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/carolpreschool.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/carolpreschool.com/privkey.pem;
|
||||||
|
|
||||||
|
return 301 https://www.carolpreschool.com$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main site
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name www.carolpreschool.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/carolpreschool.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/carolpreschool.com/privkey.pem;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
include /etc/nginx/conf.d/goatcounter.inc;
|
||||||
|
|
||||||
|
location /media/ {
|
||||||
|
proxy_pass http://garage:3902/;
|
||||||
|
proxy_set_header Host carolpreschool.com.web.garage;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://carolpreschool_com:80;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,5 +31,6 @@ server {
|
|||||||
deny all;
|
deny all;
|
||||||
|
|
||||||
proxy_pass http://garage:3903;
|
proxy_pass http://garage:3903;
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name goatcounter.hantim.net;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://goatcounter.hantim.net$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name goatcounter.hantim.net;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/goatcounter.hantim.net/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/goatcounter.hantim.net/privkey.pem;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://goatcounter:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
sub_filter '</body>' '<script>window.goatcounter={path:function(p){return location.host+p}}</script><script data-goatcounter="https://goatcounter.hantim.net/count" async src="//goatcounter.hantim.net/count.js"></script></body>';
|
||||||
|
sub_filter_once on;
|
||||||
@@ -37,6 +37,7 @@ server {
|
|||||||
ssl_certificate_key /etc/letsencrypt/live/haanmind.net/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/haanmind.net/privkey.pem;
|
||||||
|
|
||||||
include /etc/nginx/conf.d/security-headers.inc;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
include /etc/nginx/conf.d/goatcounter.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ server {
|
|||||||
ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.pem;
|
||||||
|
|
||||||
include /etc/nginx/conf.d/security-headers.inc;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
include /etc/nginx/conf.d/goatcounter.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ server {
|
|||||||
ssl_certificate_key /etc/letsencrypt/live/hcsuzuki.music/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/hcsuzuki.music/privkey.pem;
|
||||||
|
|
||||||
include /etc/nginx/conf.d/security-headers.inc;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
include /etc/nginx/conf.d/goatcounter.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ server {
|
|||||||
ssl_certificate_key /etc/letsencrypt/live/hcsuzuki.net/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/hcsuzuki.net/privkey.pem;
|
||||||
|
|
||||||
include /etc/nginx/conf.d/security-headers.inc;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
include /etc/nginx/conf.d/goatcounter.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ server {
|
|||||||
ssl_certificate_key /etc/letsencrypt/live/thekims.family/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/thekims.family/privkey.pem;
|
||||||
|
|
||||||
include /etc/nginx/conf.d/security-headers.inc;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
include /etc/nginx/conf.d/goatcounter.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ server {
|
|||||||
ssl_certificate_key /etc/letsencrypt/live/timothykim.net/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/timothykim.net/privkey.pem;
|
||||||
|
|
||||||
include /etc/nginx/conf.d/security-headers.inc;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
include /etc/nginx/conf.d/goatcounter.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ fi
|
|||||||
echo "==> Setting up deploy SSH key..."
|
echo "==> Setting up deploy SSH key..."
|
||||||
mkdir -p /home/deploy/.ssh
|
mkdir -p /home/deploy/.ssh
|
||||||
chmod 700 /home/deploy/.ssh
|
chmod 700 /home/deploy/.ssh
|
||||||
echo "command=\"sudo /opt/hantim/scripts/deploy.sh \$SSH_ORIGINAL_COMMAND\",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty $DEPLOY_PUBKEY" > /home/deploy/.ssh/authorized_keys
|
echo "command=\"sudo /opt/hantim/scripts/deploy.sh \\\"\$SSH_ORIGINAL_COMMAND\\\"\",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty $DEPLOY_PUBKEY" > /home/deploy/.ssh/authorized_keys
|
||||||
chmod 600 /home/deploy/.ssh/authorized_keys
|
chmod 600 /home/deploy/.ssh/authorized_keys
|
||||||
chown -R deploy:deploy /home/deploy/.ssh
|
chown -R deploy:deploy /home/deploy/.ssh
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ fi
|
|||||||
echo "==> Deploying services..."
|
echo "==> Deploying services..."
|
||||||
# Deploy nginx first (creates the shared network)
|
# Deploy nginx first (creates the shared network)
|
||||||
bash "$REPO_DIR/scripts/deploy.sh" deploy-nginx
|
bash "$REPO_DIR/scripts/deploy.sh" deploy-nginx
|
||||||
|
|
||||||
|
# Self-heal renewal config drift: certs issued by the old --standalone flow
|
||||||
|
# (removed in de1d60b) can never renew because nginx now holds port 80. Reissue
|
||||||
|
# any such cert via the canonical webroot path. nginx must be up (above) to
|
||||||
|
# serve the ACME challenge. Best-effort: a single failure warns but does not
|
||||||
|
# block the rest of the provision.
|
||||||
|
echo "==> Healing standalone cert renewal configs..."
|
||||||
|
heal_failed=false
|
||||||
|
for conf in /etc/letsencrypt/renewal/*.conf; do
|
||||||
|
[ -e "$conf" ] || continue
|
||||||
|
grep -q '^authenticator = standalone' "$conf" || continue
|
||||||
|
cert_name=$(basename "$conf" .conf)
|
||||||
|
echo " $cert_name uses standalone; reissuing via webroot..."
|
||||||
|
if ! bash "$REPO_DIR/scripts/deploy.sh" "cert-$cert_name"; then
|
||||||
|
echo " WARNING: failed to reissue $cert_name; leaving standalone config in place"
|
||||||
|
heal_failed=true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Deploy all other apps
|
# Deploy all other apps
|
||||||
for app in "$REPO_DIR"/docker/*/; do
|
for app in "$REPO_DIR"/docker/*/; do
|
||||||
app_name=$(basename "$app")
|
app_name=$(basename "$app")
|
||||||
@@ -46,3 +65,8 @@ for app in "$REPO_DIR"/docker/*/; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo "==> Done."
|
echo "==> Done."
|
||||||
|
|
||||||
|
if [ "$heal_failed" = true ]; then
|
||||||
|
echo "ERROR: one or more standalone certs could not be reissued (see warnings above)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
+13
-3
@@ -6,12 +6,21 @@ CMD="${SSH_ORIGINAL_COMMAND:-${1:-}}"
|
|||||||
if [[ "$CMD" =~ ^cert-[a-zA-Z0-9._-]+$ ]]; then
|
if [[ "$CMD" =~ ^cert-[a-zA-Z0-9._-]+$ ]]; then
|
||||||
APP="${CMD#cert-}"
|
APP="${CMD#cert-}"
|
||||||
|
|
||||||
|
# Subdomains (e.g., garage.hantim.net) don't have www variants
|
||||||
|
if [[ "$APP" == *.*.* ]]; then
|
||||||
|
CERT_DOMAINS="-d $APP"
|
||||||
|
SERVER_NAMES="$APP"
|
||||||
|
else
|
||||||
|
CERT_DOMAINS="-d $APP -d www.$APP"
|
||||||
|
SERVER_NAMES="$APP www.$APP"
|
||||||
|
fi
|
||||||
|
|
||||||
# Write temporary HTTP-only config so nginx can serve ACME challenges
|
# Write temporary HTTP-only config so nginx can serve ACME challenges
|
||||||
cat > "/opt/hantim/docker/nginx/conf.d/$APP.conf" <<NGINXCONF
|
cat > "/opt/hantim/docker/nginx/conf.d/$APP.conf" <<NGINXCONF
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name $APP www.$APP;
|
server_name $SERVER_NAMES;
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
location /.well-known/acme-challenge/ {
|
||||||
root /var/www/certbot;
|
root /var/www/certbot;
|
||||||
@@ -23,13 +32,14 @@ server {
|
|||||||
}
|
}
|
||||||
NGINXCONF
|
NGINXCONF
|
||||||
|
|
||||||
trap 'git -C /opt/hantim restore "docker/nginx/conf.d/$APP.conf" 2>/dev/null || rm -f "/opt/hantim/docker/nginx/conf.d/$APP.conf"' EXIT
|
trap 'git -C /opt/hantim restore "docker/nginx/conf.d/$APP.conf" 2>/dev/null || rm -f "/opt/hantim/docker/nginx/conf.d/$APP.conf"; docker exec nginx nginx -t && docker exec nginx nginx -s reload' EXIT
|
||||||
docker exec nginx nginx -t && docker exec nginx nginx -s reload
|
docker exec nginx nginx -t && docker exec nginx nginx -s reload
|
||||||
certbot certonly --webroot -w /opt/hantim/docker/nginx/certbot/www \
|
certbot certonly --webroot -w /opt/hantim/docker/nginx/certbot/www \
|
||||||
--non-interactive --agree-tos -m timothykim@fastmail.fm \
|
--non-interactive --agree-tos -m timothykim@fastmail.fm \
|
||||||
--cert-name "$APP" -d "$APP" -d "www.$APP"
|
--cert-name "$APP" $CERT_DOMAINS
|
||||||
git -C /opt/hantim restore "docker/nginx/conf.d/$APP.conf" 2>/dev/null || rm -f "/opt/hantim/docker/nginx/conf.d/$APP.conf"
|
git -C /opt/hantim restore "docker/nginx/conf.d/$APP.conf" 2>/dev/null || rm -f "/opt/hantim/docker/nginx/conf.d/$APP.conf"
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
|
docker exec nginx nginx -t && docker exec nginx nginx -s reload
|
||||||
echo "Certificate issued for $APP"
|
echo "Certificate issued for $APP"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
+18
-17
@@ -138,7 +138,7 @@ def cleanup(secrets, revert_git=False):
|
|||||||
headers=auth,
|
headers=auth,
|
||||||
)
|
)
|
||||||
if status == 200 and data:
|
if status == 200 and data:
|
||||||
for m in data.get("monitors", []):
|
for m in data.get("data", []):
|
||||||
if m.get("url") == f"https://www.{TEST_DOMAIN}":
|
if m.get("url") == f"https://www.{TEST_DOMAIN}":
|
||||||
api("DELETE", f"{UPTIMEROBOT_API}/monitors/{m['id']}", headers=auth)
|
api("DELETE", f"{UPTIMEROBOT_API}/monitors/{m['id']}", headers=auth)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -149,10 +149,10 @@ def cleanup(secrets, revert_git=False):
|
|||||||
token = secrets["hantim-garage-admin-token"]
|
token = secrets["hantim-garage-admin-token"]
|
||||||
auth = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
auth = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||||
status, data = api(
|
status, data = api(
|
||||||
"POST",
|
"GET",
|
||||||
f"{GARAGE_API}/v2/GetBucketInfo",
|
f"{GARAGE_API}/v2/GetBucketInfo?"
|
||||||
|
+ urllib.parse.urlencode({"globalAlias": TEST_DOMAIN}),
|
||||||
headers=auth,
|
headers=auth,
|
||||||
data={"globalAlias": TEST_DOMAIN},
|
|
||||||
)
|
)
|
||||||
if status == 200 and data and data.get("id"):
|
if status == 200 and data and data.get("id"):
|
||||||
bid = data["id"]
|
bid = data["id"]
|
||||||
@@ -169,14 +169,8 @@ def cleanup(secrets, revert_git=False):
|
|||||||
"permissions": {"read": True, "write": True, "owner": False},
|
"permissions": {"read": True, "write": True, "owner": False},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Remove global alias
|
# Delete bucket directly (Garage rejects alias removal when
|
||||||
api(
|
# it's the bucket's only alias, so skip RemoveBucketAlias)
|
||||||
"POST",
|
|
||||||
f"{GARAGE_API}/v2/RemoveBucketAlias",
|
|
||||||
headers=auth,
|
|
||||||
data={"bucketId": bid, "globalAlias": TEST_DOMAIN},
|
|
||||||
)
|
|
||||||
# Delete bucket (id is a query param, not body)
|
|
||||||
api(
|
api(
|
||||||
"POST",
|
"POST",
|
||||||
f"{GARAGE_API}/v2/DeleteBucket?"
|
f"{GARAGE_API}/v2/DeleteBucket?"
|
||||||
@@ -250,14 +244,21 @@ def cleanup(secrets, revert_git=False):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Local {path}: {e}")
|
errors.append(f"Local {path}: {e}")
|
||||||
|
|
||||||
# --- Vultr DNS zone ---
|
# --- Vultr DNS records (keep zone to avoid slow re-creation) ---
|
||||||
try:
|
try:
|
||||||
key = secrets["hantim-vultr-api-key"]
|
key = secrets["hantim-vultr-api-key"]
|
||||||
api(
|
auth = {"Authorization": f"Bearer {key}"}
|
||||||
"DELETE",
|
status, data = api(
|
||||||
f"{VULTR_API}/domains/{TEST_DOMAIN}",
|
"GET", f"{VULTR_API}/domains/{TEST_DOMAIN}/records", headers=auth
|
||||||
headers={"Authorization": f"Bearer {key}"},
|
|
||||||
)
|
)
|
||||||
|
if status == 200 and data:
|
||||||
|
for r in data.get("records", []):
|
||||||
|
if r["type"] == "A" and r["name"] in ("", "www"):
|
||||||
|
api(
|
||||||
|
"DELETE",
|
||||||
|
f"{VULTR_API}/domains/{TEST_DOMAIN}/records/{r['id']}",
|
||||||
|
headers=auth,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Vultr: {e}")
|
errors.append(f"Vultr: {e}")
|
||||||
|
|
||||||
|
|||||||
+48
-37
@@ -5,8 +5,8 @@ Run: uvx pytest tests/test_app_e2e.py -v -x
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import ssl
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -104,19 +104,17 @@ class TestAppSubcommands:
|
|||||||
|
|
||||||
auth = {
|
auth = {
|
||||||
"Authorization": f"Bearer {secrets['hantim-garage-admin-token']}",
|
"Authorization": f"Bearer {secrets['hantim-garage-admin-token']}",
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
}
|
||||||
status, data = api(
|
status, data = api(
|
||||||
"POST",
|
"GET",
|
||||||
f"{GARAGE_API}/v2/GetBucketInfo",
|
f"{GARAGE_API}/v2/GetBucketInfo?"
|
||||||
|
+ urllib.parse.urlencode({"globalAlias": TEST_DOMAIN}),
|
||||||
headers=auth,
|
headers=auth,
|
||||||
data={"globalAlias": TEST_DOMAIN},
|
|
||||||
)
|
)
|
||||||
assert status == 200, f"Bucket not found (HTTP {status})"
|
assert status == 200, f"Bucket not found (HTTP {status})"
|
||||||
assert data["id"], "Bucket has no ID"
|
assert data["id"], "Bucket has no ID"
|
||||||
assert TEST_DOMAIN in data.get("globalAliases", []), "Global alias not set"
|
assert TEST_DOMAIN in data.get("globalAliases", []), "Global alias not set"
|
||||||
website = data.get("websiteAccess", {})
|
assert data.get("websiteAccess") is True, f"Website access not enabled: {data.get('websiteAccess')}"
|
||||||
assert website.get("enabled") is True, f"Website access not enabled: {website}"
|
|
||||||
|
|
||||||
def test_build(self, secrets):
|
def test_build(self, secrets):
|
||||||
result = run_app("build", timeout=180)
|
result = run_app("build", timeout=180)
|
||||||
@@ -126,16 +124,24 @@ class TestAppSubcommands:
|
|||||||
result = run_app("monitor")
|
result = run_app("monitor")
|
||||||
assert result.returncode == 0, f"app.sh monitor failed:\n{result.stdout}\n{result.stderr}"
|
assert result.returncode == 0, f"app.sh monitor failed:\n{result.stdout}\n{result.stderr}"
|
||||||
|
|
||||||
|
# UptimeRobot API may have brief eventual consistency after create
|
||||||
auth = {"Authorization": f"Bearer {secrets['hantim-uptimerobot-api-key']}"}
|
auth = {"Authorization": f"Bearer {secrets['hantim-uptimerobot-api-key']}"}
|
||||||
status, data = api(
|
target = f"https://www.{TEST_DOMAIN}"
|
||||||
"GET",
|
for attempt in range(3):
|
||||||
f"{UPTIMEROBOT_API}/monitors?"
|
status, data = api(
|
||||||
+ urllib.parse.urlencode({"search": TEST_DOMAIN}),
|
"GET",
|
||||||
headers=auth,
|
f"{UPTIMEROBOT_API}/monitors",
|
||||||
|
headers=auth,
|
||||||
|
)
|
||||||
|
assert status == 200, f"UptimeRobot API returned {status}"
|
||||||
|
urls = [m["url"] for m in data.get("data", [])]
|
||||||
|
if target in urls:
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
assert target in urls, (
|
||||||
|
f"Monitor not found. URLs: {urls}\n"
|
||||||
|
f"app.sh output:\n{result.stdout}"
|
||||||
)
|
)
|
||||||
assert status == 200, f"UptimeRobot API returned {status}"
|
|
||||||
urls = [m["url"] for m in data.get("monitors", [])]
|
|
||||||
assert f"https://www.{TEST_DOMAIN}" in urls, f"Monitor not found. URLs: {urls}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestAppAll:
|
class TestAppAll:
|
||||||
@@ -191,35 +197,40 @@ class TestAppAll:
|
|||||||
# --- Garage bucket ---
|
# --- Garage bucket ---
|
||||||
garage_auth = {
|
garage_auth = {
|
||||||
"Authorization": f"Bearer {secrets['hantim-garage-admin-token']}",
|
"Authorization": f"Bearer {secrets['hantim-garage-admin-token']}",
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
}
|
||||||
status, data = api(
|
status, data = api(
|
||||||
"POST",
|
"GET",
|
||||||
f"{GARAGE_API}/v2/GetBucketInfo",
|
f"{GARAGE_API}/v2/GetBucketInfo?"
|
||||||
|
+ urllib.parse.urlencode({"globalAlias": TEST_DOMAIN}),
|
||||||
headers=garage_auth,
|
headers=garage_auth,
|
||||||
data={"globalAlias": TEST_DOMAIN},
|
|
||||||
)
|
)
|
||||||
assert status == 200, "Garage bucket not found"
|
assert status == 200, "Garage bucket not found"
|
||||||
|
|
||||||
# --- Site is live ---
|
# --- Site is live ---
|
||||||
ctx = ssl.create_default_context()
|
# Use server IP from verified DNS records to bypass negative cache
|
||||||
req = urllib.request.Request(f"https://www.{TEST_DOMAIN}", method="HEAD")
|
server_ip = next(
|
||||||
try:
|
r["data"] for r in records if r["type"] == "A" and r["name"] == ""
|
||||||
resp = urllib.request.urlopen(req, context=ctx, timeout=10)
|
)
|
||||||
assert resp.status in (200, 301, 302)
|
curl = subprocess.run(
|
||||||
except urllib.error.HTTPError:
|
["curl", "-sf", "--resolve", f"www.{TEST_DOMAIN}:443:{server_ip}",
|
||||||
pass # any HTTP response means the site is up
|
"-o", "/dev/null", "-w", "%{http_code}",
|
||||||
except urllib.error.URLError as e:
|
f"https://www.{TEST_DOMAIN}"],
|
||||||
pytest.fail(f"Site not reachable: {e}")
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
assert curl.returncode == 0, f"Site not reachable (HTTP {curl.stdout})"
|
||||||
|
|
||||||
# --- UptimeRobot monitor ---
|
# --- UptimeRobot monitor ---
|
||||||
ur_auth = {"Authorization": f"Bearer {secrets['hantim-uptimerobot-api-key']}"}
|
ur_auth = {"Authorization": f"Bearer {secrets['hantim-uptimerobot-api-key']}"}
|
||||||
status, data = api(
|
target = f"https://www.{TEST_DOMAIN}"
|
||||||
"GET",
|
for attempt in range(3):
|
||||||
f"{UPTIMEROBOT_API}/monitors?"
|
status, data = api(
|
||||||
+ urllib.parse.urlencode({"search": TEST_DOMAIN}),
|
"GET",
|
||||||
headers=ur_auth,
|
f"{UPTIMEROBOT_API}/monitors",
|
||||||
)
|
headers=ur_auth,
|
||||||
assert status == 200
|
)
|
||||||
urls = [m["url"] for m in data.get("monitors", [])]
|
assert status == 200
|
||||||
assert f"https://www.{TEST_DOMAIN}" in urls
|
urls = [m["url"] for m in data.get("data", [])]
|
||||||
|
if target in urls:
|
||||||
|
break
|
||||||
|
time.sleep(2)
|
||||||
|
assert target in urls
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ def test_tls12_works():
|
|||||||
assert s.version() == "TLSv1.2"
|
assert s.version() == "TLSv1.2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.filterwarnings("ignore::DeprecationWarning")
|
||||||
def test_tls11_rejected():
|
def test_tls11_rejected():
|
||||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_1
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_1
|
||||||
|
|||||||
+34
-20
@@ -253,16 +253,22 @@ cmd_repo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd_files() {
|
cmd_files() {
|
||||||
|
local all_exist=true
|
||||||
for f in "docker/$APP/compose.yml" ".gitea/workflows/deploy-$APP.yml" "docker/nginx/conf.d/$APP.conf"; do
|
for f in "docker/$APP/compose.yml" ".gitea/workflows/deploy-$APP.yml" "docker/nginx/conf.d/$APP.conf"; do
|
||||||
if [ -e "$REPO_ROOT/$f" ]; then
|
if [ ! -e "$REPO_ROOT/$f" ]; then
|
||||||
echo "Error: $f already exists."
|
all_exist=false
|
||||||
exit 1
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
if [ "$all_exist" = true ]; then
|
||||||
|
echo " All files already exist, skipping."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Creating docker/$APP/compose.yml..."
|
if [ ! -e "$REPO_ROOT/docker/$APP/compose.yml" ]; then
|
||||||
mkdir -p "$REPO_ROOT/docker/$APP"
|
echo "==> Creating docker/$APP/compose.yml..."
|
||||||
cat > "$REPO_ROOT/docker/$APP/compose.yml" <<EOF
|
mkdir -p "$REPO_ROOT/docker/$APP"
|
||||||
|
cat > "$REPO_ROOT/docker/$APP/compose.yml" <<EOF
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: git.timothykim.net/hantim/$APP:latest
|
image: git.timothykim.net/hantim/$APP:latest
|
||||||
@@ -275,10 +281,14 @@ networks:
|
|||||||
shared:
|
shared:
|
||||||
external: true
|
external: true
|
||||||
EOF
|
EOF
|
||||||
|
else
|
||||||
|
echo " docker/$APP/compose.yml already exists, skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Creating .gitea/workflows/deploy-$APP.yml..."
|
if [ ! -e "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml" ]; then
|
||||||
mkdir -p "$REPO_ROOT/.gitea/workflows"
|
echo "==> Creating .gitea/workflows/deploy-$APP.yml..."
|
||||||
cat > "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml" <<'OUTER'
|
mkdir -p "$REPO_ROOT/.gitea/workflows"
|
||||||
|
cat > "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml" <<'OUTER'
|
||||||
name: Deploy APP_PLACEHOLDER
|
name: Deploy APP_PLACEHOLDER
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -299,10 +309,14 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
ssh -o StrictHostKeyChecking=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-APP_PLACEHOLDER
|
ssh -o StrictHostKeyChecking=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-APP_PLACEHOLDER
|
||||||
OUTER
|
OUTER
|
||||||
sed -i "s/APP_PLACEHOLDER/$APP/g" "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml"
|
sed -i "s/APP_PLACEHOLDER/$APP/g" "$REPO_ROOT/.gitea/workflows/deploy-$APP.yml"
|
||||||
|
else
|
||||||
|
echo " .gitea/workflows/deploy-$APP.yml already exists, skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Creating docker/nginx/conf.d/$APP.conf..."
|
if [ ! -e "$REPO_ROOT/docker/nginx/conf.d/$APP.conf" ]; then
|
||||||
cat > "$REPO_ROOT/docker/nginx/conf.d/$APP.conf" <<NGINX
|
echo "==> Creating docker/nginx/conf.d/$APP.conf..."
|
||||||
|
cat > "$REPO_ROOT/docker/nginx/conf.d/$APP.conf" <<NGINX
|
||||||
# Redirect HTTP to HTTPS, bare domain to www
|
# Redirect HTTP to HTTPS, bare domain to www
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
@@ -342,6 +356,7 @@ server {
|
|||||||
ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.pem;
|
||||||
|
|
||||||
include /etc/nginx/conf.d/security-headers.inc;
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
include /etc/nginx/conf.d/goatcounter.inc;
|
||||||
|
|
||||||
location /media/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
@@ -357,8 +372,9 @@ server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
NGINX
|
NGINX
|
||||||
|
else
|
||||||
echo " Files created. Run 'git add' and commit when ready."
|
echo " docker/nginx/conf.d/$APP.conf already exists, skipping."
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_cert() {
|
cmd_cert() {
|
||||||
@@ -388,10 +404,8 @@ cmd_garage() {
|
|||||||
|
|
||||||
BUCKET_ID=$(echo "$BUCKET_RESP" | jq -r '.id')
|
BUCKET_ID=$(echo "$BUCKET_RESP" | jq -r '.id')
|
||||||
if [ -z "$BUCKET_ID" ] || [ "$BUCKET_ID" = "null" ]; then
|
if [ -z "$BUCKET_ID" ] || [ "$BUCKET_ID" = "null" ]; then
|
||||||
BUCKET_ID=$(curl -s -X POST "$GARAGE_API/v2/GetBucketInfo" \
|
BUCKET_ID=$(curl -s "$GARAGE_API/v2/GetBucketInfo?globalAlias=$APP" \
|
||||||
-H "$GARAGE_AUTH" \
|
-H "$GARAGE_AUTH" | jq -r '.id')
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"globalAlias\": \"$APP\"}" | jq -r '.id')
|
|
||||||
if [ -z "$BUCKET_ID" ] || [ "$BUCKET_ID" = "null" ]; then
|
if [ -z "$BUCKET_ID" ] || [ "$BUCKET_ID" = "null" ]; then
|
||||||
echo "Error: Failed to create or find bucket for $APP"
|
echo "Error: Failed to create or find bucket for $APP"
|
||||||
echo "$BUCKET_RESP"
|
echo "$BUCKET_RESP"
|
||||||
@@ -484,7 +498,7 @@ cmd_monitor() {
|
|||||||
--data-urlencode "search=$APP")
|
--data-urlencode "search=$APP")
|
||||||
|
|
||||||
MATCH=$(echo "$EXISTING" | jq -r --arg url "$MONITOR_URL" \
|
MATCH=$(echo "$EXISTING" | jq -r --arg url "$MONITOR_URL" \
|
||||||
'.monitors[]? | select(.url == $url) | .id')
|
'.data[]? | select(.url == $url) | .id')
|
||||||
if [ -n "$MATCH" ]; then
|
if [ -n "$MATCH" ]; then
|
||||||
echo " Monitor already exists (id: $MATCH), skipping."
|
echo " Monitor already exists (id: $MATCH), skipping."
|
||||||
return
|
return
|
||||||
@@ -513,6 +527,7 @@ cmd_monitor() {
|
|||||||
cmd_all() {
|
cmd_all() {
|
||||||
cmd_dns
|
cmd_dns
|
||||||
cmd_repo
|
cmd_repo
|
||||||
|
cmd_cert
|
||||||
cmd_files
|
cmd_files
|
||||||
|
|
||||||
echo "==> Committing and pushing hantim-server..."
|
echo "==> Committing and pushing hantim-server..."
|
||||||
@@ -525,7 +540,6 @@ cmd_all() {
|
|||||||
git push
|
git push
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cmd_cert
|
|
||||||
cmd_garage
|
cmd_garage
|
||||||
cmd_build
|
cmd_build
|
||||||
cmd_verify
|
cmd_verify
|
||||||
|
|||||||
+142
-18
@@ -5,16 +5,20 @@ usage() {
|
|||||||
echo "Usage: ./tools/service.sh <command> <name>"
|
echo "Usage: ./tools/service.sh <command> <name>"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Commands:"
|
echo "Commands:"
|
||||||
echo " dns <name> Create A record for <name>.hantim.net"
|
echo " dns <name> Create A record for <name>.hantim.net"
|
||||||
echo " files <name> Create compose.yml and deploy workflow"
|
echo " files <name> Create compose.yml and deploy workflow"
|
||||||
echo " all <name> Run dns + files"
|
echo " nginx <name> <port> Create nginx conf for <name>.hantim.net"
|
||||||
|
echo " cert <name> Issue SSL certificate for <name>.hantim.net"
|
||||||
|
echo " all <name> <port> Run dns + files + nginx + cert"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Examples:"
|
echo "Examples:"
|
||||||
echo " ./tools/service.sh files garage"
|
echo " ./tools/service.sh files garage"
|
||||||
echo " ./tools/service.sh dns garage # garage.hantim.net -> server IP"
|
echo " ./tools/service.sh dns garage # garage.hantim.net -> server IP"
|
||||||
echo " ./tools/service.sh all garage"
|
echo " ./tools/service.sh nginx garage 3903 # create nginx conf"
|
||||||
|
echo " ./tools/service.sh cert garage # issue SSL cert"
|
||||||
|
echo " ./tools/service.sh all garage 3903"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Requires: bws, jq, dig (for dns)"
|
echo "Requires: bws, jq, dig (for dns/cert)"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +84,25 @@ require_dig() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolve_server_ip() {
|
||||||
|
require_dig
|
||||||
|
SERVER_IP=$(dig +short hantim.net | head -1)
|
||||||
|
if [ -z "$SERVER_IP" ]; then
|
||||||
|
echo "Error: Could not resolve hantim.net to get server IP."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_deploy_key() {
|
||||||
|
require_bws
|
||||||
|
echo "==> Fetching deploy key..."
|
||||||
|
DEPLOY_KEY=$(bws_get "hantim-deploy-ssh-private-key")
|
||||||
|
DEPLOY_KEY_FILE=$(mktemp)
|
||||||
|
echo "$DEPLOY_KEY" > "$DEPLOY_KEY_FILE"
|
||||||
|
chmod 600 "$DEPLOY_KEY_FILE"
|
||||||
|
trap "rm -f $DEPLOY_KEY_FILE" EXIT
|
||||||
|
}
|
||||||
|
|
||||||
validate_name() {
|
validate_name() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
if [ -z "$name" ]; then
|
if [ -z "$name" ]; then
|
||||||
@@ -156,15 +179,23 @@ cmd_dns() {
|
|||||||
cmd_files() {
|
cmd_files() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
|
|
||||||
if [ -d "$REPO_ROOT/docker/$name" ]; then
|
local all_exist=true
|
||||||
echo "Error: docker/$name already exists."
|
for f in "docker/$name/compose.yml" ".gitea/workflows/deploy-$name.yml"; do
|
||||||
exit 1
|
if [ ! -e "$REPO_ROOT/$f" ]; then
|
||||||
|
all_exist=false
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$all_exist" = true ]; then
|
||||||
|
echo " All files already exist, skipping."
|
||||||
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Creating docker/$name/compose.yml..."
|
if [ ! -e "$REPO_ROOT/docker/$name/compose.yml" ]; then
|
||||||
mkdir -p "$REPO_ROOT/docker/$name"
|
echo "==> Creating docker/$name/compose.yml..."
|
||||||
touch "$REPO_ROOT/docker/$name/.gitignore"
|
mkdir -p "$REPO_ROOT/docker/$name"
|
||||||
cat > "$REPO_ROOT/docker/$name/compose.yml" <<EOF
|
touch "$REPO_ROOT/docker/$name/.gitignore"
|
||||||
|
cat > "$REPO_ROOT/docker/$name/compose.yml" <<EOF
|
||||||
services:
|
services:
|
||||||
$name:
|
$name:
|
||||||
image: TODO
|
image: TODO
|
||||||
@@ -177,10 +208,14 @@ networks:
|
|||||||
shared:
|
shared:
|
||||||
external: true
|
external: true
|
||||||
EOF
|
EOF
|
||||||
|
else
|
||||||
|
echo " docker/$name/compose.yml already exists, skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Creating .gitea/workflows/deploy-$name.yml..."
|
if [ ! -e "$REPO_ROOT/.gitea/workflows/deploy-$name.yml" ]; then
|
||||||
mkdir -p "$REPO_ROOT/.gitea/workflows"
|
echo "==> Creating .gitea/workflows/deploy-$name.yml..."
|
||||||
cat > "$REPO_ROOT/.gitea/workflows/deploy-$name.yml" <<'OUTER'
|
mkdir -p "$REPO_ROOT/.gitea/workflows"
|
||||||
|
cat > "$REPO_ROOT/.gitea/workflows/deploy-$name.yml" <<'OUTER'
|
||||||
name: Deploy APP_PLACEHOLDER
|
name: Deploy APP_PLACEHOLDER
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -201,7 +236,10 @@ jobs:
|
|||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
ssh -o StrictHostKeyChecking=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-APP_PLACEHOLDER
|
ssh -o StrictHostKeyChecking=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-APP_PLACEHOLDER
|
||||||
OUTER
|
OUTER
|
||||||
sed -i "s/APP_PLACEHOLDER/$name/g" "$REPO_ROOT/.gitea/workflows/deploy-$name.yml"
|
sed -i "s/APP_PLACEHOLDER/$name/g" "$REPO_ROOT/.gitea/workflows/deploy-$name.yml"
|
||||||
|
else
|
||||||
|
echo " .gitea/workflows/deploy-$name.yml already exists, skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Done. Edit docker/$name/compose.yml, then commit and push."
|
echo "Done. Edit docker/$name/compose.yml, then commit and push."
|
||||||
@@ -213,11 +251,79 @@ OUTER
|
|||||||
echo " configure.sh will generate the .env file on the server automatically."
|
echo " configure.sh will generate the .env file on the server automatically."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd_nginx() {
|
||||||
|
local name="$1"
|
||||||
|
local port="$2"
|
||||||
|
local fqdn="$name.hantim.net"
|
||||||
|
|
||||||
|
if ! [[ "$port" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "Error: port must be a number."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -e "$REPO_ROOT/docker/nginx/conf.d/$fqdn.conf" ]; then
|
||||||
|
echo " docker/nginx/conf.d/$fqdn.conf already exists, skipping."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Creating docker/nginx/conf.d/$fqdn.conf..."
|
||||||
|
cat > "$REPO_ROOT/docker/nginx/conf.d/$fqdn.conf" <<NGINX
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name $fqdn;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$fqdn\$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name $fqdn;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/$fqdn/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/$fqdn/privkey.pem;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/security-headers.inc;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://$name:$port;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_hide_header X-Frame-Options;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_cert() {
|
||||||
|
local name="$1"
|
||||||
|
local fqdn="$name.hantim.net"
|
||||||
|
|
||||||
|
resolve_server_ip
|
||||||
|
setup_deploy_key
|
||||||
|
|
||||||
|
echo "==> Issuing SSL certificate for $fqdn via deploy user..."
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new -i "$DEPLOY_KEY_FILE" deploy@"$SERVER_IP" "cert-$fqdn"
|
||||||
|
echo " Certificate issued."
|
||||||
|
}
|
||||||
|
|
||||||
cmd_all() {
|
cmd_all() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
|
local port="$2"
|
||||||
|
|
||||||
cmd_dns "$name"
|
cmd_dns "$name"
|
||||||
cmd_files "$name"
|
cmd_files "$name"
|
||||||
|
cmd_nginx "$name" "$port"
|
||||||
|
cmd_cert "$name"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Dispatch ---
|
# --- Dispatch ---
|
||||||
@@ -231,9 +337,27 @@ case "$COMMAND" in
|
|||||||
validate_name "${2:-}"
|
validate_name "${2:-}"
|
||||||
cmd_files "${2:-}"
|
cmd_files "${2:-}"
|
||||||
;;
|
;;
|
||||||
|
nginx)
|
||||||
|
validate_name "${2:-}"
|
||||||
|
if [ -z "${3:-}" ]; then
|
||||||
|
echo "Error: port is required for nginx command."
|
||||||
|
echo " Usage: ./tools/service.sh nginx <name> <port>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cmd_nginx "${2:-}" "${3:-}"
|
||||||
|
;;
|
||||||
|
cert)
|
||||||
|
validate_name "${2:-}"
|
||||||
|
cmd_cert "${2:-}"
|
||||||
|
;;
|
||||||
all)
|
all)
|
||||||
validate_name "${2:-}"
|
validate_name "${2:-}"
|
||||||
cmd_all "${2:-}"
|
if [ -z "${3:-}" ]; then
|
||||||
|
echo "Error: port is required for all command."
|
||||||
|
echo " Usage: ./tools/service.sh all <name> <port>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cmd_all "${2:-}" "${3:-}"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown command: $COMMAND"
|
echo "Unknown command: $COMMAND"
|
||||||
|
|||||||
Reference in New Issue
Block a user