Skip to content

Commit 8765a62

Browse files
committed
feat: zentryx-status v0.1.0 - enterprise dogfood of sealed-env
0 parents  commit 8765a62

23 files changed

Lines changed: 1548 additions & 0 deletions

.env.example

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# zentryx-status secrets template.
2+
# Copy to .env in src/main/resources/, fill in real values, then run:
3+
#
4+
# mvn sealed-env:encrypt -Dsealed-env.input=src/main/resources/.env
5+
# # or with the CLI directly if installed:
6+
# sealed-env encrypt src/main/resources/.env -o src/main/resources/.env.sealed
7+
#
8+
# That produces .env.sealed (which IS safe to commit). Delete the
9+
# plaintext .env afterwards.
10+
11+
# H2 file-mode database password. Used to lock the local H2 file so a
12+
# second JVM cannot accidentally open it. Doesn't have to be strong —
13+
# the file lives in /data inside the container with restrictive perms.
14+
H2_PASSWORD=change-me
15+
16+
# Resend API key for the Monday weekly digest email.
17+
RESEND_API_KEY=re_REPLACE_ME
18+
19+
# Where to send the weekly digest.
20+
DIGEST_TO=david@zentryxnet.lat
21+
22+
# Discord webhook for state-change alerts (target goes down or recovers).
23+
# Get one at: Server Settings → Integrations → Webhooks → New Webhook.
24+
ALERT_DISCORD_WEBHOOK=https://discord.com/api/webhooks/REPLACE_ME

.github/workflows/ci.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
build:
11+
name: Build & test
12+
runs-on: ubuntu-latest
13+
timeout-minutes: 10
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: actions/setup-java@v4
18+
with:
19+
distribution: 'temurin'
20+
java-version: '21'
21+
cache: 'maven'
22+
23+
- name: Compile
24+
run: mvn -B -q compile
25+
26+
- name: Test
27+
run: mvn -B -q test
28+
env:
29+
# CI doesn't have a real .env.sealed yet — the test profile
30+
# uses an in-memory H2 with placeholder values, so sealed-env
31+
# auto-config falls back to plain @Value defaults.
32+
SEALED_ENV_KEY: ci-placeholder-not-used
33+
34+
docker:
35+
name: Docker build
36+
runs-on: ubuntu-latest
37+
needs: build
38+
if: github.event_name == 'push'
39+
steps:
40+
- uses: actions/checkout@v4
41+
- uses: docker/setup-buildx-action@v3
42+
- name: Build image (no push)
43+
uses: docker/build-push-action@v6
44+
with:
45+
context: .
46+
push: false
47+
tags: zentryx-status:ci
48+
cache-from: type=gha
49+
cache-to: type=gha,mode=max

.gitignore

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Plaintext secrets — NEVER commit
2+
.env
3+
.env.local
4+
.env.*.local
5+
src/main/resources/.env
6+
src/main/resources/.env.local
7+
8+
# OK to commit (encrypted)
9+
!.env.sealed
10+
!.env.example
11+
!src/main/resources/.env.sealed
12+
13+
# Maven
14+
target/
15+
*.jar
16+
*.war
17+
.mvn/
18+
19+
# IDE
20+
.idea/
21+
*.iml
22+
.vscode/
23+
.settings/
24+
.classpath
25+
.project
26+
27+
# OS
28+
.DS_Store
29+
Thumbs.db
30+
31+
# Local DB
32+
data/
33+
*.h2.db
34+
*.mv.db
35+
*.trace.db
36+
37+
# Added by sealed-env
38+
.env.local
39+
.env
40+
41+
# Added by sealed-env
42+
.env.local
43+
.env

Dockerfile

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# ─────────────────────────────────────────────────────────────────
2+
# zentryx-status · multi-stage Docker build
3+
#
4+
# Stage 1: build the fat jar with Eclipse Temurin 21 + Maven (cached
5+
# .m2 layer keeps incremental builds fast).
6+
# Stage 2: minimal JRE image, runs as a non-root user, exposes 8090.
7+
# ─────────────────────────────────────────────────────────────────
8+
9+
FROM eclipse-temurin:21-jdk-alpine AS build
10+
WORKDIR /workspace
11+
12+
# Cache the dependency resolution layer
13+
COPY pom.xml ./
14+
RUN apk add --no-cache maven && \
15+
mvn -B -q dependency:go-offline
16+
17+
COPY src ./src
18+
RUN mvn -B -q -DskipTests package && \
19+
cp target/zentryx-status-*.jar app.jar
20+
21+
# ── Runtime ──────────────────────────────────────────────────────
22+
FROM eclipse-temurin:21-jre-alpine
23+
24+
# Non-root user for the runtime — matches the rest of our security model
25+
RUN addgroup -S zentryx && \
26+
adduser -S -G zentryx -h /app zentryx && \
27+
mkdir -p /app/data && \
28+
chown -R zentryx:zentryx /app
29+
30+
USER zentryx
31+
WORKDIR /app
32+
33+
COPY --from=build --chown=zentryx:zentryx /workspace/app.jar app.jar
34+
35+
EXPOSE 8090
36+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
37+
CMD wget -qO- http://127.0.0.1:8090/actuator/health || exit 1
38+
39+
# Enterprise-mode unsealing: the deploy script mints a JWS via
40+
# `sealed-env unseal --totp <code> --deploy-id <sha> --ttl 60` and
41+
# injects it as SEALED_ENV_UNSEAL_TOKEN. Spring Boot reads it on
42+
# startup, validates signature + TTL + deploy_id binding, decrypts
43+
# .env.sealed, exposes vars to the Environment.
44+
#
45+
# If the token is missing, expired, or bound to a different deploy_id,
46+
# the JVM exits non-zero. Docker restart-policy will keep retrying —
47+
# operator must re-deploy with a fresh TOTP to recover. This is by
48+
# design: secrets are never re-decrypted without human approval.
49+
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-XX:+UseG1GC", "-jar", "app.jar"]

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Zentryx Network
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# zentryx-status
2+
3+
> Public status page service for Zentryx Network. Polls every critical
4+
> endpoint, persists checks in H2, exposes a small REST API, alerts on
5+
> state changes via Discord.
6+
7+
Built specifically as a **production dogfood** for [`sealed-env`](https://github.com/davidalmeidac/sealed-env)
8+
in **enterprise mode** — secrets are sealed behind a TOTP-bound JWS
9+
unseal token. Every deploy requires a fresh 6-digit code from the
10+
operator's authenticator app; the token is bound to a specific commit
11+
SHA and expires in 60 seconds.
12+
13+
If the JVM restarts (OOM, host reboot, etc.) the token is gone and
14+
the service crash-loops until a human re-runs `deploy.sh`. By design:
15+
secrets never re-decrypt without explicit human approval.
16+
17+
## Threat model
18+
19+
| Attacker has... | Can they decrypt secrets? |
20+
|---|---|
21+
| `.env.sealed` only | ❌ — needs master key |
22+
| Master key only | ❌ — needs current TOTP code |
23+
| TOTP secret (base32) only | ❌ — needs master key + signing key |
24+
| Stolen unseal token from CI logs | ❌ — bound to deploy_id, expired in 60s |
25+
| Master key + TOTP secret + signing key | ✅ — but that's 3 separate exfils |
26+
| Container memory dump while running | ✅ — same as any service, mitigated by host hardening |
27+
28+
## What it monitors (out of the box)
29+
30+
| Target | URL | Expects |
31+
|---|---|---|
32+
| `zentryxnet-web` | https://zentryxnet.lat/health | 200 |
33+
| `zentryxnet-blog` | https://zentryxnet.lat/blog | 200 |
34+
| `speedtest-ping` | https://speed.zentryxnet.lat/ping | 204 |
35+
| `mail-https` | https://mail.zentryxnet.lat/ | 200 |
36+
37+
Edit `application.yml → zentryx.status.targets` to add/remove. Every
38+
target gets one HTTP probe every 30s.
39+
40+
## Public API
41+
42+
| Endpoint | Description |
43+
|---|---|
44+
| `GET /api/v1/status` | Current snapshot — last known state per target |
45+
| `GET /api/v1/uptime?days=7` | Uptime % per target over N days (1-90) |
46+
| `GET /api/v1/history?target=X&hours=24` | Raw checks for a single target |
47+
48+
All three are public and CORS-friendly — embed them in dashboards,
49+
hit them from `curl`, scrape them with a bot.
50+
51+
## Local development (basic mode)
52+
53+
For day-to-day dev you don't want to type a TOTP every restart. Use
54+
basic mode locally:
55+
56+
```bash
57+
# 1. Switch to basic mode in dev
58+
sealed-env init # generates master key in .env.local
59+
cp .env.example src/main/resources/.env
60+
# edit src/main/resources/.env with real values
61+
62+
sealed-env encrypt src/main/resources/.env \
63+
-o src/main/resources/.env.sealed
64+
65+
# 2. application.yml: temporarily set sealed-env.mode=basic for dev
66+
# 3. Run
67+
mvn spring-boot:run
68+
```
69+
70+
Production stays in enterprise mode regardless.
71+
72+
## Production deploy (enterprise mode)
73+
74+
### One-time setup on `zentryx-web`
75+
76+
```bash
77+
ssh zentryx-web
78+
79+
# 1. Install sealed-env CLI
80+
sudo npm i -g sealed-env
81+
82+
# 2. Clone the repo
83+
sudo mkdir -p /opt/zentryx
84+
sudo git clone https://github.com/Zentryx-Network/zentryx-status.git \
85+
/opt/zentryx/status
86+
cd /opt/zentryx/status
87+
88+
# 3. Drop the master key + signing key + TOTP secret
89+
# These were created by `sealed-env init --mode enterprise`
90+
# on YOUR LAPTOP — copy that .env.local here.
91+
sudo install -m 600 -o root -g root /tmp/sealed.env.local .env.local
92+
rm /tmp/sealed.env.local
93+
```
94+
95+
### Every deploy
96+
97+
```bash
98+
ssh zentryx-web
99+
cd /opt/zentryx/status
100+
git pull --ff-only
101+
./deploy.sh
102+
# It asks for a 6-digit TOTP, mints a 60s token bound to the new
103+
# commit, builds Docker, starts container, tails logs, verifies
104+
# health. Total time ~25-40 seconds.
105+
```
106+
107+
The deploy script refuses to run on a dirty working tree — your
108+
working copy must be clean and the code that ships matches the
109+
deploy_id the token is bound to.
110+
111+
### External monitor (mandatory)
112+
113+
Because zentryx-status uses fail-safe crashloops on bad/expired
114+
tokens, its own AlertDispatcher can't tell you when it's down. The
115+
companion [`external-monitor/`](./external-monitor/) lives on
116+
zentryx-web (different host), runs every 60s via systemd timer,
117+
and posts to Discord after 5min of consecutive failures.
118+
119+
The Discord webhook for the canary is **NOT** inside `.env.sealed`
120+
— it's in plaintext at `/etc/zentryx/external-monitor.env` (chmod
121+
600, root-owned). Different threat model: this webhook is the one
122+
piece that keeps working when the sealed pipeline fails.
123+
124+
See [`external-monitor/README.md`](./external-monitor/README.md) for
125+
install instructions.
126+
127+
## How it differs from a generic Spring Boot service
128+
129+
- **`sealed-env-spring-boot-starter` in enterprise mode**: every
130+
deploy requires a fresh TOTP code from the operator. Token is
131+
bound to commit SHA; expires in 60 seconds. Same encrypted file
132+
format works in Node ([news-scout](https://github.com/Zentryx-Network/news-scout))
133+
and Spring Boot.
134+
- **Strict timeouts on every outbound request**: 4s connect + 8s
135+
read. A frozen target never holds up the next polling cycle.
136+
- **Append-only schema**: `health_check` is INSERT-only, no
137+
UPDATEs. Daily prune drops anything older than 30 days.
138+
- **Cooldown on alerts**: a flapping target generates one Discord
139+
ping every 15 minutes max, not one every 30 seconds.
140+
- **Bound to 127.0.0.1**: Spring app only listens on localhost.
141+
External access goes through Caddy/nginx, which terminates TLS.
142+
- **External canary**: a separate process (different host, different
143+
webhook, no shared deps) tells you when this service goes silent.
144+
145+
## License
146+
147+
MIT. See [LICENSE](./LICENSE).
148+
149+
---
150+
151+
Built by [Zentryx Network](https://zentryxnet.lat). Powered by
152+
[sealed-env](https://github.com/davidalmeidac/sealed-env) (enterprise mode).

0 commit comments

Comments
 (0)