Initial Archive status bot
This commit is contained in:
commit
c415e82500
12 changed files with 2264 additions and 0 deletions
9
.dockerignore
Normal file
9
.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.env
|
||||
.git
|
||||
.gitignore
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
state/
|
||||
services.json
|
||||
compose.local.yaml
|
||||
preview.html
|
||||
14
.env.deploy.example
Normal file
14
.env.deploy.example
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
|
||||
DISCORD_CHANNEL_ID=1504278732070981683
|
||||
ARCHIVE_STATUS_CONFIG=services.json
|
||||
ARCHIVE_STATUS_STATE=state/status-message.json
|
||||
CHECK_INTERVAL_SECONDS=60
|
||||
HTTP_USER_AGENT=ArchiveStatusBot/1.0
|
||||
DISCORD_DRY_RUN=false
|
||||
DASHBOARD_ENABLED=true
|
||||
DASHBOARD_HOST=0.0.0.0
|
||||
DASHBOARD_PORT=8787
|
||||
DASHBOARD_USERNAME=admin
|
||||
DASHBOARD_PASSWORD_HASH=replace-with-generated-pbkdf2-hash
|
||||
DASHBOARD_SESSION_TTL_SECONDS=28800
|
||||
DASHBOARD_COOKIE_SECURE=true
|
||||
14
.env.example
Normal file
14
.env.example
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
|
||||
DISCORD_CHANNEL_ID=1504278732070981683
|
||||
ARCHIVE_STATUS_CONFIG=services.json
|
||||
ARCHIVE_STATUS_STATE=state/status-message.json
|
||||
CHECK_INTERVAL_SECONDS=60
|
||||
HTTP_USER_AGENT=ArchiveStatusBot/1.0
|
||||
DISCORD_DRY_RUN=false
|
||||
DASHBOARD_ENABLED=true
|
||||
DASHBOARD_HOST=0.0.0.0
|
||||
DASHBOARD_PORT=8787
|
||||
DASHBOARD_USERNAME=admin
|
||||
DASHBOARD_PASSWORD_HASH=replace-with-generated-pbkdf2-hash
|
||||
DASHBOARD_SESSION_TTL_SECONDS=28800
|
||||
DASHBOARD_COOKIE_SECURE=false
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.env
|
||||
services.json
|
||||
state/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.agents/
|
||||
.codex/
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM python:3.12-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY status_bot.py /app/status_bot.py
|
||||
COPY dashboard.html /app/dashboard.html
|
||||
COPY services.example.json /app/services.json
|
||||
|
||||
RUN adduser -D -u 1000 -h /app archive-status
|
||||
RUN mkdir -p /app/state && chown -R archive-status:archive-status /app
|
||||
|
||||
USER archive-status
|
||||
|
||||
CMD ["python", "/app/status_bot.py"]
|
||||
178
README.md
Normal file
178
README.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# Archive Bot
|
||||
|
||||
An AIO Discord bot foundation for The Mithral Archive. The first module is status monitoring: it checks configured URLs and keeps one live status message updated in the Discord `status` channel.
|
||||
|
||||
The message uses a summary embed plus one small embed per service, so each service gets its own green or red Discord color bar.
|
||||
|
||||
It does not need Discord gateway intents or slash commands for the status module. It only needs a bot token, the `status` channel ID, and permission to send/edit its own messages.
|
||||
|
||||
The bot also includes a small web dashboard for editing monitored services and forcing immediate Discord refreshes.
|
||||
|
||||
## Discord Bot Setup
|
||||
|
||||
Create a Discord application and bot in the Discord Developer Portal, then invite it with:
|
||||
|
||||
```text
|
||||
https://discord.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=84992&scope=bot
|
||||
```
|
||||
|
||||
Required channel permissions:
|
||||
|
||||
- View Channel
|
||||
- Send Messages
|
||||
- Embed Links
|
||||
- Read Message History
|
||||
|
||||
The current `status` channel ID is:
|
||||
|
||||
```text
|
||||
1504278732070981683
|
||||
```
|
||||
|
||||
## Local Setup
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
cp services.example.json services.json
|
||||
```
|
||||
|
||||
Edit `.env` and set:
|
||||
|
||||
- `DISCORD_BOT_TOKEN`
|
||||
- `DASHBOARD_USERNAME`
|
||||
- `DASHBOARD_PASSWORD_HASH`
|
||||
|
||||
Generate the dashboard password hash:
|
||||
|
||||
```sh
|
||||
python3 status_bot.py --hash-password
|
||||
```
|
||||
|
||||
Paste the output into `DASHBOARD_PASSWORD_HASH`. The dashboard does not store a reusable browser token; it uses an HttpOnly session cookie and CSRF token after login.
|
||||
|
||||
Dashboard auth includes:
|
||||
|
||||
- PBKDF2-SHA256 password hashing
|
||||
- HttpOnly `SameSite=Strict` session cookie
|
||||
- CSRF token required for write actions
|
||||
- basic failed-login throttling
|
||||
|
||||
Edit `services.json` with the URLs you want displayed. Keep private/internal URLs out of Git if they should not be shared.
|
||||
|
||||
Preview the Discord embed payload:
|
||||
|
||||
```sh
|
||||
ARCHIVE_STATUS_CONFIG=services.json python3 status_bot.py --preview
|
||||
```
|
||||
|
||||
Open `preview.html` in a browser to see an approximate Discord-style render. Paste the JSON from `--preview` into the textarea and render it to test changes before sending anything to Discord.
|
||||
|
||||
Run it:
|
||||
|
||||
```sh
|
||||
python3 status_bot.py
|
||||
```
|
||||
|
||||
Local runs automatically read `.env` from the current directory.
|
||||
|
||||
For dashboard testing without touching Discord, set:
|
||||
|
||||
```env
|
||||
DISCORD_DRY_RUN=true
|
||||
ARCHIVE_STATUS_STATE=state/status-message.json
|
||||
```
|
||||
|
||||
Open the dashboard:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8787
|
||||
```
|
||||
|
||||
Sign in with `DASHBOARD_USERNAME` and the password you used when generating `DASHBOARD_PASSWORD_HASH`.
|
||||
|
||||
## Docker Setup
|
||||
|
||||
```sh
|
||||
cp .env.deploy.example .env
|
||||
cp services.example.json services.json
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The bot stores the Discord message ID in `state/status-message.json`. Keep that file mounted so the bot edits the same message after restarts.
|
||||
|
||||
The deploy compose joins your existing reverse-proxy network:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
mediaserver_default:
|
||||
external: true
|
||||
```
|
||||
|
||||
If that network does not already exist on the deploy host, create it once:
|
||||
|
||||
```sh
|
||||
docker network create mediaserver_default
|
||||
```
|
||||
|
||||
The dashboard is exposed inside Docker on port `8787` for your reverse proxy. It is not published directly to the host by default.
|
||||
|
||||
Use this target from your proxy:
|
||||
|
||||
```text
|
||||
http://archive-status-bot:8787
|
||||
```
|
||||
|
||||
For HTTPS behind a reverse proxy, set:
|
||||
|
||||
```env
|
||||
DASHBOARD_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
Leave it `false` only for direct localhost HTTP testing.
|
||||
|
||||
For direct local Docker testing without a proxy:
|
||||
|
||||
```sh
|
||||
docker compose -f compose.yaml -f compose.local.yaml up -d --build
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8787
|
||||
```
|
||||
|
||||
## Dashboard
|
||||
|
||||
The dashboard currently supports:
|
||||
|
||||
- viewing monitored services
|
||||
- adding/removing service rows
|
||||
- editing check URL, display URL, expected statuses, timeout, and keyword
|
||||
- saving `services.json`
|
||||
- forcing an immediate check and Discord message update
|
||||
|
||||
The sidebar leaves room for future modules like polls, webhooks, automations, and service integrations without changing the bot shape later.
|
||||
|
||||
## Service Config
|
||||
|
||||
Each service supports:
|
||||
|
||||
- `name`: label shown in Discord
|
||||
- `url`: URL checked by the bot
|
||||
- `displayUrl`: URL linked in the embed
|
||||
- `method`: optional, defaults to `GET`
|
||||
- `timeoutSeconds`: optional, defaults to `10`
|
||||
- `expectedStatuses`: optional list such as `["200-399"]` or `[200, 204]`
|
||||
- `keyword`: optional text that must appear in the response body
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Jellyfin",
|
||||
"url": "https://jellyfin.mithraic.cloud/health",
|
||||
"displayUrl": "https://jellyfin.mithraic.cloud",
|
||||
"expectedStatuses": ["200-399"]
|
||||
}
|
||||
```
|
||||
4
compose.local.yaml
Normal file
4
compose.local.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
services:
|
||||
archive-status-bot:
|
||||
ports:
|
||||
- 127.0.0.1:8787:8787
|
||||
18
compose.yaml
Normal file
18
compose.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
services:
|
||||
archive-status-bot:
|
||||
build: .
|
||||
container_name: archive-status-bot
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
expose:
|
||||
- "8787"
|
||||
volumes:
|
||||
- ./services.json:/app/services.json
|
||||
- ./state:/app/state
|
||||
networks:
|
||||
- mediaserver_default
|
||||
|
||||
networks:
|
||||
mediaserver_default:
|
||||
external: true
|
||||
648
dashboard.html
Normal file
648
dashboard.html
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Archive Bot Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #111214;
|
||||
--panel: #1b1d21;
|
||||
--panel-2: #202328;
|
||||
--line: #30343b;
|
||||
--line-strong: #3d424b;
|
||||
--text: #f1f2f4;
|
||||
--muted: #a2a8b3;
|
||||
--faint: #757d8a;
|
||||
--ok: #10b981;
|
||||
--warn: #f59e0b;
|
||||
--bad: #ef4444;
|
||||
--action: #e7e9ed;
|
||||
--action-text: #15171a;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: #5a606b;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--action);
|
||||
border-color: var(--action);
|
||||
color: var(--action-text);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #14161a;
|
||||
color: var(--text);
|
||||
padding: 8px 9px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: 2px solid #5f6875;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
}
|
||||
|
||||
aside {
|
||||
border-right: 1px solid var(--line);
|
||||
background: #15171a;
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0 0 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-radius: 6px;
|
||||
padding: 8px 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.disabled {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
main {
|
||||
min-width: 0;
|
||||
padding: 22px 24px 36px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
background: var(--panel);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.summary-item span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.summary-item strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.service-list {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.service-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 0.8fr) minmax(220px, 1.5fr) minmax(180px, 1.2fr) 116px 88px 120px 40px;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
background: var(--panel);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.row-status {
|
||||
align-self: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.state.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.state.bad {
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 12px;
|
||||
min-height: 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.token-screen {
|
||||
max-width: 420px;
|
||||
margin: 80px auto;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.token-screen p {
|
||||
color: var(--muted);
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.token-screen input + input {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.token-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
aside {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
nav {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.service-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
main {
|
||||
padding: 18px 14px 28px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.panel-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
nav,
|
||||
.service-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="login" class="token-screen panel" hidden>
|
||||
<h1>Archive Bot</h1>
|
||||
<p>Sign in with the dashboard account configured on the bot.</p>
|
||||
<input id="username" type="text" autocomplete="username" placeholder="Username">
|
||||
<input id="password" type="password" autocomplete="current-password" placeholder="Password">
|
||||
<div class="token-actions">
|
||||
<button id="loginButton" class="primary" type="button">Sign in</button>
|
||||
</div>
|
||||
<div id="loginMessage" class="message error"></div>
|
||||
</div>
|
||||
|
||||
<div id="app" class="app" hidden>
|
||||
<aside>
|
||||
<div class="brand">Archive Bot</div>
|
||||
<nav aria-label="Bot modules">
|
||||
<div class="nav-item active"><span>Status</span><span>Ready</span></div>
|
||||
<div class="nav-item disabled"><span>Polls</span><span>Later</span></div>
|
||||
<div class="nav-item disabled"><span>Webhooks</span><span>Later</span></div>
|
||||
<div class="nav-item disabled"><span>Automations</span><span>Later</span></div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="topbar">
|
||||
<h1>Status Services</h1>
|
||||
<div class="actions">
|
||||
<button id="refresh" type="button">Refresh</button>
|
||||
<button id="checkNow" type="button">Check now</button>
|
||||
<button id="addService" type="button">Add service</button>
|
||||
<button id="save" class="primary" type="button">Save and update Discord</button>
|
||||
<button id="logout" type="button">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="summary panel" aria-label="Current status summary">
|
||||
<div class="summary-item">
|
||||
<span>Services</span>
|
||||
<strong id="serviceCount">0</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>Online</span>
|
||||
<strong id="onlineCount">0</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>Issues</span>
|
||||
<strong id="issueCount">0</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>Last check</span>
|
||||
<strong id="lastCheck">Never</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">Monitored Links</div>
|
||||
<div id="message" class="message"></div>
|
||||
</div>
|
||||
<div id="services" class="service-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const appEl = document.querySelector("#app");
|
||||
const loginEl = document.querySelector("#login");
|
||||
const servicesEl = document.querySelector("#services");
|
||||
const messageEl = document.querySelector("#message");
|
||||
const loginMessageEl = document.querySelector("#loginMessage");
|
||||
|
||||
let services = [];
|
||||
let results = new Map();
|
||||
let csrfToken = "";
|
||||
|
||||
function headers(method = "GET") {
|
||||
const base = { "Content-Type": "application/json" };
|
||||
if (method !== "GET") {
|
||||
base["X-CSRF-Token"] = csrfToken;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const method = options.method || "GET";
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
credentials: "same-origin",
|
||||
headers: { ...headers(method), ...(options.headers || {}) }
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.error || `Request failed: ${response.status}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function setMessage(text, isError = false) {
|
||||
messageEl.textContent = text;
|
||||
messageEl.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
function showLogin(message = "") {
|
||||
appEl.hidden = true;
|
||||
loginEl.hidden = false;
|
||||
loginMessageEl.textContent = message;
|
||||
document.querySelector("#password").value = "";
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
loginEl.hidden = true;
|
||||
appEl.hidden = false;
|
||||
}
|
||||
|
||||
function normalizeService(service = {}) {
|
||||
return {
|
||||
name: service.name || "",
|
||||
url: service.url || "",
|
||||
displayUrl: service.displayUrl || service.url || "",
|
||||
method: service.method || "GET",
|
||||
timeoutSeconds: service.timeoutSeconds || 10,
|
||||
expectedStatuses: Array.isArray(service.expectedStatuses) ? service.expectedStatuses : ["200-399"],
|
||||
keyword: service.keyword || ""
|
||||
};
|
||||
}
|
||||
|
||||
function statusFor(service) {
|
||||
return results.get(service.name) || null;
|
||||
}
|
||||
|
||||
function renderSummary(payload) {
|
||||
const online = payload.results.filter((result) => result.ok).length;
|
||||
document.querySelector("#serviceCount").textContent = payload.services.length;
|
||||
document.querySelector("#onlineCount").textContent = online;
|
||||
document.querySelector("#issueCount").textContent = Math.max(payload.results.length - online, 0);
|
||||
document.querySelector("#lastCheck").textContent = payload.lastCheckedAt
|
||||
? new Date(payload.lastCheckedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
: "Never";
|
||||
}
|
||||
|
||||
function serviceRow(service, index) {
|
||||
const result = statusFor(service);
|
||||
const row = document.createElement("div");
|
||||
row.className = "service-row";
|
||||
row.dataset.index = String(index);
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input data-key="name" value="${escapeAttr(service.name)}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Check URL</label>
|
||||
<input data-key="url" value="${escapeAttr(service.url)}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Display URL</label>
|
||||
<input data-key="displayUrl" value="${escapeAttr(service.displayUrl)}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Expected</label>
|
||||
<input data-key="expectedStatuses" value="${escapeAttr(service.expectedStatuses.join(", "))}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Timeout</label>
|
||||
<input data-key="timeoutSeconds" type="number" min="1" max="60" value="${escapeAttr(service.timeoutSeconds)}">
|
||||
</div>
|
||||
<div class="row-status">
|
||||
${renderResult(result)}
|
||||
</div>
|
||||
<button class="danger" type="button" data-remove="${index}" title="Remove service">Remove</button>
|
||||
`;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderResult(result) {
|
||||
if (!result) {
|
||||
return `<span class="state">Unchecked</span>`;
|
||||
}
|
||||
|
||||
const stateClass = result.ok ? "ok" : "bad";
|
||||
const label = result.ok ? "Online" : "Issue";
|
||||
const status = result.status ? `HTTP ${result.status}` : "No response";
|
||||
const latency = result.latencyMs == null ? "n/a" : `${result.latencyMs} ms`;
|
||||
return `<span class="state ${stateClass}">${label}</span><br>${status}<br>${latency}`;
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function renderServices() {
|
||||
servicesEl.innerHTML = "";
|
||||
services.forEach((service, index) => {
|
||||
servicesEl.append(serviceRow(service, index));
|
||||
});
|
||||
}
|
||||
|
||||
function collectServices() {
|
||||
return [...servicesEl.querySelectorAll(".service-row")].map((row) => {
|
||||
const item = {};
|
||||
row.querySelectorAll("[data-key]").forEach((input) => {
|
||||
const key = input.dataset.key;
|
||||
if (key === "expectedStatuses") {
|
||||
item[key] = input.value.split(",").map((part) => part.trim()).filter(Boolean);
|
||||
} else if (key === "timeoutSeconds") {
|
||||
item[key] = Number(input.value || 10);
|
||||
} else {
|
||||
item[key] = input.value.trim();
|
||||
}
|
||||
});
|
||||
item.method = "GET";
|
||||
if (!item.displayUrl) item.displayUrl = item.url;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const payload = await api("/api/status");
|
||||
services = payload.services.map(normalizeService);
|
||||
results = new Map(payload.results.map((result) => [result.name, result]));
|
||||
renderSummary(payload);
|
||||
renderServices();
|
||||
setMessage(payload.lastError ? `Last error: ${payload.lastError}` : "");
|
||||
showApp();
|
||||
}
|
||||
|
||||
async function loadSession() {
|
||||
const session = await api("/api/session");
|
||||
csrfToken = session.csrfToken || "";
|
||||
return session;
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const username = document.querySelector("#username").value.trim();
|
||||
const password = document.querySelector("#password").value;
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(data.error || `Login failed: ${response.status}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
csrfToken = data.csrfToken || "";
|
||||
await loadStatus();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api("/api/logout", { method: "POST", body: "{}" }).catch(() => {});
|
||||
csrfToken = "";
|
||||
showLogin();
|
||||
}
|
||||
|
||||
async function saveServices() {
|
||||
services = collectServices().map(normalizeService);
|
||||
const payload = await api("/api/services", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ services })
|
||||
});
|
||||
results = new Map(payload.results.map((result) => [result.name, result]));
|
||||
renderSummary({ services, results: payload.results, lastCheckedAt: new Date().toISOString() });
|
||||
renderServices();
|
||||
setMessage("Saved and updated Discord.");
|
||||
}
|
||||
|
||||
async function checkNow() {
|
||||
const payload = await api("/api/check", { method: "POST", body: "{}" });
|
||||
results = new Map(payload.results.map((result) => [result.name, result]));
|
||||
renderSummary({ services, results: payload.results, lastCheckedAt: new Date().toISOString() });
|
||||
renderServices();
|
||||
setMessage("Checked services and updated Discord.");
|
||||
}
|
||||
|
||||
document.querySelector("#loginButton").addEventListener("click", async () => {
|
||||
try {
|
||||
await login();
|
||||
} catch (error) {
|
||||
showLogin(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#password").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
document.querySelector("#loginButton").click();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#refresh").addEventListener("click", () => {
|
||||
loadStatus().catch((error) => setMessage(error.message, true));
|
||||
});
|
||||
|
||||
document.querySelector("#save").addEventListener("click", () => {
|
||||
saveServices().catch((error) => setMessage(error.message, true));
|
||||
});
|
||||
|
||||
document.querySelector("#checkNow").addEventListener("click", () => {
|
||||
checkNow().catch((error) => setMessage(error.message, true));
|
||||
});
|
||||
|
||||
document.querySelector("#logout").addEventListener("click", () => {
|
||||
logout();
|
||||
});
|
||||
|
||||
document.querySelector("#addService").addEventListener("click", () => {
|
||||
services.push(normalizeService({ name: "New Service", url: "https://example.com" }));
|
||||
renderServices();
|
||||
});
|
||||
|
||||
servicesEl.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-remove]");
|
||||
if (!button) return;
|
||||
services.splice(Number(button.dataset.remove), 1);
|
||||
renderServices();
|
||||
});
|
||||
|
||||
loadSession().then(loadStatus).catch((error) => {
|
||||
if (error.status === 401) {
|
||||
showLogin();
|
||||
} else {
|
||||
showLogin(error.message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
390
preview.html
Normal file
390
preview.html
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Archive Status Preview</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--page: #1e1f22;
|
||||
--channel: #313338;
|
||||
--message: #2b2d31;
|
||||
--text: #dbdee1;
|
||||
--muted: #949ba4;
|
||||
--link: #00a8fc;
|
||||
--border: #3f4147;
|
||||
--green: #10b981;
|
||||
--orange: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--gray: #6b7280;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--page);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.channel {
|
||||
background: var(--channel);
|
||||
border: 1px solid #26272b;
|
||||
border-radius: 8px;
|
||||
min-height: 560px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-header {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #26272b;
|
||||
color: #f2f3f5;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr;
|
||||
gap: 12px;
|
||||
padding: 20px 18px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #5865f2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.author {
|
||||
color: #f2f3f5;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.bot-tag {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: #5865f2;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.embeds {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.embed {
|
||||
position: relative;
|
||||
background: var(--message);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px 10px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embed::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: var(--embed-color, var(--gray));
|
||||
}
|
||||
|
||||
.embed-title {
|
||||
margin: 0 0 6px;
|
||||
color: #f2f3f5;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.embed-description {
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.embed-description a {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.embed-description a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.embed-footer {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.embed-fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.embed-field-name {
|
||||
color: #f2f3f5;
|
||||
font-weight: 650;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.embed-field-value {
|
||||
color: var(--text);
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.embed-field-value a {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.embed-field-value a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
label {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
resize: vertical;
|
||||
border: 1px solid #3f4147;
|
||||
border-radius: 6px;
|
||||
background: #232428;
|
||||
color: var(--text);
|
||||
padding: 10px;
|
||||
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
button {
|
||||
justify-self: start;
|
||||
border: 1px solid #4e5058;
|
||||
border-radius: 6px;
|
||||
background: #404249;
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #4e5058;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #fca5a5;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
main {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.channel {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.embeds,
|
||||
.controls {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Archive Status Preview</h1>
|
||||
|
||||
<section class="channel" aria-label="Discord status channel preview">
|
||||
<div class="channel-header"># status</div>
|
||||
<article class="message">
|
||||
<div class="avatar">A</div>
|
||||
<div>
|
||||
<div class="message-meta">
|
||||
<span class="author">Archive Status</span>
|
||||
<span class="bot-tag">BOT</span>
|
||||
<span class="time">Today at 9:03 PM</span>
|
||||
</div>
|
||||
<div id="embeds" class="embeds"></div>
|
||||
<div class="controls">
|
||||
<label for="payload">Preview payload</label>
|
||||
<textarea id="payload" spellcheck="false"></textarea>
|
||||
<button type="button" id="render">Render payload</button>
|
||||
<div id="error" class="error" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const samplePayload = {
|
||||
content: "",
|
||||
embeds: [
|
||||
{
|
||||
title: "The Mithral Archive",
|
||||
description: "🟡 Degraded · 5/6 online · 1 service needs attention",
|
||||
color: 16096779,
|
||||
fields: [
|
||||
{
|
||||
name: "Service",
|
||||
value: "**Portal**\n**Jellyfin**\n**Navidrome**\n**Voting Hub**\n**Forgejo**\n**Support**",
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Status",
|
||||
value: "🟢 Online\n🟢 Online\n🟢 Online\n🟢 Online\n🟢 Online\n🔴 Issue",
|
||||
inline: true
|
||||
}
|
||||
],
|
||||
footer: { text: "Refreshes every 60s • Last checked 2026-05-14 01:56:29 UTC" }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const embedsEl = document.querySelector("#embeds");
|
||||
const payloadEl = document.querySelector("#payload");
|
||||
const errorEl = document.querySelector("#error");
|
||||
|
||||
function colorToHex(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return "#6b7280";
|
||||
return `#${number.toString(16).padStart(6, "0").slice(-6)}`;
|
||||
}
|
||||
|
||||
function renderMarkdownLinks(text) {
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
return escaped.replace(/\[([^\]]+)]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||||
}
|
||||
|
||||
function render(payload) {
|
||||
embedsEl.innerHTML = "";
|
||||
const embeds = Array.isArray(payload.embeds) ? payload.embeds : [];
|
||||
|
||||
embeds.forEach((embed) => {
|
||||
const article = document.createElement("section");
|
||||
article.className = "embed";
|
||||
article.style.setProperty("--embed-color", colorToHex(embed.color));
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.className = "embed-title";
|
||||
title.textContent = embed.title || "Untitled";
|
||||
article.append(title);
|
||||
|
||||
if (embed.description) {
|
||||
const description = document.createElement("p");
|
||||
description.className = "embed-description";
|
||||
description.innerHTML = renderMarkdownLinks(String(embed.description));
|
||||
article.append(description);
|
||||
}
|
||||
|
||||
if (Array.isArray(embed.fields) && embed.fields.length) {
|
||||
const fields = document.createElement("div");
|
||||
fields.className = "embed-fields";
|
||||
embed.fields.forEach((field) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "embed-field";
|
||||
const name = document.createElement("div");
|
||||
name.className = "embed-field-name";
|
||||
name.textContent = field.name || "\u200b";
|
||||
const value = document.createElement("div");
|
||||
value.className = "embed-field-value";
|
||||
value.innerHTML = renderMarkdownLinks(String(field.value || "\u200b"));
|
||||
item.append(name, value);
|
||||
fields.append(item);
|
||||
});
|
||||
article.append(fields);
|
||||
}
|
||||
|
||||
if (embed.footer?.text) {
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "embed-footer";
|
||||
footer.textContent = embed.footer.text;
|
||||
article.append(footer);
|
||||
}
|
||||
|
||||
embedsEl.append(article);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector("#render").addEventListener("click", () => {
|
||||
try {
|
||||
const payload = JSON.parse(payloadEl.value);
|
||||
errorEl.textContent = "";
|
||||
render(payload);
|
||||
} catch (error) {
|
||||
errorEl.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
payloadEl.value = JSON.stringify(samplePayload, null, 2);
|
||||
render(samplePayload);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
40
services.example.json
Normal file
40
services.example.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"services": [
|
||||
{
|
||||
"name": "Portal",
|
||||
"url": "https://mithraic.cloud",
|
||||
"displayUrl": "https://mithraic.cloud",
|
||||
"expectedStatuses": ["200-399"]
|
||||
},
|
||||
{
|
||||
"name": "Jellyfin",
|
||||
"url": "https://jellyfin.mithraic.cloud/health",
|
||||
"displayUrl": "https://jellyfin.mithraic.cloud",
|
||||
"expectedStatuses": ["200-399"]
|
||||
},
|
||||
{
|
||||
"name": "Navidrome",
|
||||
"url": "https://listen.mithraic.cloud",
|
||||
"displayUrl": "https://listen.mithraic.cloud",
|
||||
"expectedStatuses": ["200-399"]
|
||||
},
|
||||
{
|
||||
"name": "Voting Hub",
|
||||
"url": "https://vote.mithraic.cloud",
|
||||
"displayUrl": "https://vote.mithraic.cloud",
|
||||
"expectedStatuses": ["200-399"]
|
||||
},
|
||||
{
|
||||
"name": "Forgejo",
|
||||
"url": "https://git.mithraic.cloud",
|
||||
"displayUrl": "https://git.mithraic.cloud",
|
||||
"expectedStatuses": ["200-399"]
|
||||
},
|
||||
{
|
||||
"name": "Support",
|
||||
"url": "https://support.mithraic.cloud",
|
||||
"displayUrl": "https://support.mithraic.cloud",
|
||||
"expectedStatuses": ["200-399"]
|
||||
}
|
||||
]
|
||||
}
|
||||
928
status_bot.py
Normal file
928
status_bot.py
Normal file
|
|
@ -0,0 +1,928 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Live Discord status message for The Mithral Archive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from http.cookies import SimpleCookie
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DISCORD_API = "https://discord.com/api/v10"
|
||||
DEFAULT_INTERVAL_SECONDS = 60
|
||||
DEFAULT_TIMEOUT_SECONDS = 10
|
||||
MAX_DISCORD_EMBEDS = 10
|
||||
MAX_REQUEST_BYTES = 1_000_000
|
||||
SESSION_COOKIE = "archive_bot_session"
|
||||
PBKDF2_ITERATIONS = 390_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Service:
|
||||
name: str
|
||||
url: str
|
||||
display_url: str
|
||||
method: str
|
||||
timeout: float
|
||||
expected_statuses: set[int]
|
||||
expected_min: int
|
||||
expected_max: int
|
||||
keyword: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CheckResult:
|
||||
service: Service
|
||||
ok: bool
|
||||
status: int | None
|
||||
latency_ms: int | None
|
||||
error: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DashboardAuthConfig:
|
||||
username: str
|
||||
password_hash: str
|
||||
session_ttl_seconds: int
|
||||
cookie_secure: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class DashboardSession:
|
||||
username: str
|
||||
csrf_token: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
class DashboardAuth:
|
||||
def __init__(self, config: DashboardAuthConfig) -> None:
|
||||
self.config = config
|
||||
self.lock = threading.Lock()
|
||||
self.sessions: dict[str, DashboardSession] = {}
|
||||
self.failed_logins: dict[str, list[float]] = {}
|
||||
|
||||
def login_allowed(self, key: str) -> bool:
|
||||
now = time.time()
|
||||
window_start = now - 900
|
||||
with self.lock:
|
||||
attempts = [attempt for attempt in self.failed_logins.get(key, []) if attempt >= window_start]
|
||||
self.failed_logins[key] = attempts
|
||||
return len(attempts) < 10
|
||||
|
||||
def record_failed_login(self, key: str) -> None:
|
||||
now = time.time()
|
||||
with self.lock:
|
||||
self.failed_logins.setdefault(key, []).append(now)
|
||||
|
||||
def clear_failed_login(self, key: str) -> None:
|
||||
with self.lock:
|
||||
self.failed_logins.pop(key, None)
|
||||
|
||||
def login(self, username: str, password: str) -> tuple[str, DashboardSession] | None:
|
||||
if not hmac.compare_digest(username, self.config.username):
|
||||
return None
|
||||
if not verify_password_hash(self.config.password_hash, password):
|
||||
return None
|
||||
|
||||
session_id = secrets.token_urlsafe(32)
|
||||
session = DashboardSession(
|
||||
username=username,
|
||||
csrf_token=secrets.token_urlsafe(32),
|
||||
expires_at=time.time() + self.config.session_ttl_seconds,
|
||||
)
|
||||
with self.lock:
|
||||
self.sessions[session_id] = session
|
||||
return session_id, session
|
||||
|
||||
def session_from_cookie(self, cookie_header: str | None) -> tuple[str, DashboardSession] | None:
|
||||
if not cookie_header:
|
||||
return None
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(cookie_header)
|
||||
morsel = cookie.get(SESSION_COOKIE)
|
||||
if morsel is None:
|
||||
return None
|
||||
|
||||
session_id = morsel.value
|
||||
now = time.time()
|
||||
with self.lock:
|
||||
session = self.sessions.get(session_id)
|
||||
if session is None:
|
||||
return None
|
||||
if session.expires_at <= now:
|
||||
self.sessions.pop(session_id, None)
|
||||
return None
|
||||
session.expires_at = now + self.config.session_ttl_seconds
|
||||
return session_id, session
|
||||
|
||||
def logout(self, session_id: str) -> None:
|
||||
with self.lock:
|
||||
self.sessions.pop(session_id, None)
|
||||
|
||||
|
||||
class BotRuntime:
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
channel_id: str,
|
||||
config_path: Path,
|
||||
state_path: Path,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
self.token = token
|
||||
self.channel_id = channel_id
|
||||
self.config_path = config_path
|
||||
self.state_path = state_path
|
||||
self.dry_run = dry_run
|
||||
self.lock = threading.Lock()
|
||||
self.last_results: list[CheckResult] = []
|
||||
self.last_error: str | None = None
|
||||
self.last_message_id: str | None = None
|
||||
self.last_checked_at: datetime | None = None
|
||||
|
||||
|
||||
def env(name: str, default: str | None = None) -> str:
|
||||
value = os.getenv(name, default)
|
||||
if value is None or not value.strip():
|
||||
raise SystemExit(f"Missing required environment variable: {name}")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def load_dotenv(path: Path = Path(".env")) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
|
||||
key, value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip("\"'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except FileNotFoundError as exc:
|
||||
raise ValueError(f"Config file not found: {path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Config must be a JSON object: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def parse_expected_statuses(raw: Any) -> tuple[set[int], int, int]:
|
||||
if raw is None:
|
||||
return set(), 200, 399
|
||||
|
||||
if isinstance(raw, str):
|
||||
raw = [part.strip() for part in raw.split(",") if part.strip()]
|
||||
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError("expectedStatuses must be a list or comma-separated string")
|
||||
|
||||
exact: set[int] = set()
|
||||
min_status = 999
|
||||
max_status = 0
|
||||
|
||||
for item in raw:
|
||||
if isinstance(item, int):
|
||||
exact.add(item)
|
||||
continue
|
||||
if not isinstance(item, str):
|
||||
raise ValueError("expectedStatuses entries must be integers or ranges")
|
||||
if "-" in item:
|
||||
left, right = item.split("-", 1)
|
||||
try:
|
||||
min_status = min(min_status, int(left))
|
||||
max_status = max(max_status, int(right))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid expected status range: {item}") from exc
|
||||
continue
|
||||
try:
|
||||
exact.add(int(item))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid expected status value: {item}") from exc
|
||||
|
||||
if min_status == 999 and max_status == 0:
|
||||
min_status, max_status = 0, -1
|
||||
return exact, min_status, max_status
|
||||
|
||||
|
||||
def services_from_data(data: dict[str, Any]) -> list[Service]:
|
||||
raw_services = data.get("services")
|
||||
if not isinstance(raw_services, list) or not raw_services:
|
||||
raise ValueError("Config must include a non-empty services array")
|
||||
|
||||
services: list[Service] = []
|
||||
for index, item in enumerate(raw_services, start=1):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"Service #{index} must be an object")
|
||||
|
||||
name = str(item.get("name", "")).strip()
|
||||
url = str(item.get("url", "")).strip()
|
||||
if not name or not url:
|
||||
raise ValueError(f"Service #{index} must include name and url")
|
||||
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError(f"Service {name} has an invalid http(s) URL")
|
||||
|
||||
exact, minimum, maximum = parse_expected_statuses(item.get("expectedStatuses"))
|
||||
services.append(
|
||||
Service(
|
||||
name=name,
|
||||
url=url,
|
||||
display_url=str(item.get("displayUrl", url)).strip() or url,
|
||||
method=str(item.get("method", "GET")).strip().upper(),
|
||||
timeout=float(item.get("timeoutSeconds", DEFAULT_TIMEOUT_SECONDS)),
|
||||
expected_statuses=exact,
|
||||
expected_min=minimum,
|
||||
expected_max=maximum,
|
||||
keyword=(str(item["keyword"]).strip() if item.get("keyword") else None),
|
||||
)
|
||||
)
|
||||
|
||||
return services
|
||||
|
||||
|
||||
def load_services(path: Path) -> list[Service]:
|
||||
return services_from_data(load_json(path))
|
||||
|
||||
|
||||
def save_services_config(path: Path, data: dict[str, Any]) -> None:
|
||||
services_from_data(data)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(f"{path.suffix}.tmp")
|
||||
with temporary.open("w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
handle.write("\n")
|
||||
try:
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
handle.write("\n")
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def services_to_jsonable(services: list[Service]) -> list[dict[str, Any]]:
|
||||
output: list[dict[str, Any]] = []
|
||||
for service in services:
|
||||
expected: list[int | str] = sorted(service.expected_statuses)
|
||||
if service.expected_min <= service.expected_max:
|
||||
expected.append(f"{service.expected_min}-{service.expected_max}")
|
||||
item: dict[str, Any] = {
|
||||
"name": service.name,
|
||||
"url": service.url,
|
||||
"displayUrl": service.display_url,
|
||||
"method": service.method,
|
||||
"timeoutSeconds": service.timeout,
|
||||
"expectedStatuses": expected or ["200-399"],
|
||||
}
|
||||
if service.keyword:
|
||||
item["keyword"] = service.keyword
|
||||
output.append(item)
|
||||
return output
|
||||
|
||||
|
||||
def status_expected(service: Service, status: int) -> bool:
|
||||
if status in service.expected_statuses:
|
||||
return True
|
||||
return service.expected_min <= status <= service.expected_max
|
||||
|
||||
|
||||
def check_service(service: Service) -> CheckResult:
|
||||
started = time.monotonic()
|
||||
headers = {"User-Agent": env("HTTP_USER_AGENT", "ArchiveStatusBot/1.0")}
|
||||
request = urllib.request.Request(service.url, headers=headers, method=service.method)
|
||||
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
with urllib.request.urlopen(request, timeout=service.timeout, context=context) as response:
|
||||
body = response.read(1_000_000) if service.keyword else b""
|
||||
status = int(response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
status = int(exc.code)
|
||||
latency_ms = int((time.monotonic() - started) * 1000)
|
||||
ok = status_expected(service, status)
|
||||
return CheckResult(service, ok, status, latency_ms, None if ok else f"HTTP {status}")
|
||||
except (urllib.error.URLError, TimeoutError, socket.timeout, ssl.SSLError) as exc:
|
||||
latency_ms = int((time.monotonic() - started) * 1000)
|
||||
return CheckResult(service, False, None, latency_ms, clean_error(exc))
|
||||
|
||||
latency_ms = int((time.monotonic() - started) * 1000)
|
||||
ok = status_expected(service, status)
|
||||
|
||||
if ok and service.keyword:
|
||||
try:
|
||||
text = body.decode("utf-8", errors="ignore")
|
||||
except UnicodeDecodeError:
|
||||
text = ""
|
||||
if service.keyword not in text:
|
||||
ok = False
|
||||
return CheckResult(service, False, status, latency_ms, "keyword missing")
|
||||
|
||||
return CheckResult(service, ok, status, latency_ms, None if ok else f"HTTP {status}")
|
||||
|
||||
|
||||
def clean_error(exc: BaseException) -> str:
|
||||
reason = getattr(exc, "reason", None)
|
||||
if reason:
|
||||
return str(reason)[:120]
|
||||
return str(exc)[:120] or exc.__class__.__name__
|
||||
|
||||
|
||||
def discord_request(
|
||||
method: str,
|
||||
token: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
body = None
|
||||
headers = {
|
||||
"Authorization": f"Bot {token}",
|
||||
"User-Agent": "ArchiveStatusBot/1.0",
|
||||
}
|
||||
|
||||
if payload is not None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
request = urllib.request.Request(
|
||||
f"{DISCORD_API}{path}",
|
||||
data=body,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
data = response.read()
|
||||
if not data:
|
||||
return {}
|
||||
return json.loads(data.decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="ignore")
|
||||
raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc
|
||||
|
||||
|
||||
def discord_bot_identity(token: str) -> dict[str, Any]:
|
||||
return discord_request("GET", token, "/users/@me")
|
||||
|
||||
|
||||
def load_state(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def save_state(path: Path, state: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(f"{path.suffix}.tmp")
|
||||
with temporary.open("w", encoding="utf-8") as handle:
|
||||
json.dump(state, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def render_embeds(results: list[CheckResult]) -> list[dict[str, Any]]:
|
||||
checked_at = datetime.now(timezone.utc)
|
||||
online = sum(1 for result in results if result.ok)
|
||||
total = len(results)
|
||||
degraded = 0 < online < total
|
||||
|
||||
if online == total:
|
||||
color = 0x10B981
|
||||
summary = f"🟢 Operational · {online}/{total} online"
|
||||
elif degraded:
|
||||
color = 0xF59E0B
|
||||
offline = total - online
|
||||
attention = "1 service needs attention" if offline == 1 else f"{offline} services need attention"
|
||||
summary = f"🟡 Degraded · {online}/{total} online · {attention}"
|
||||
else:
|
||||
color = 0xEF4444
|
||||
summary = f"🔴 Outage · {online}/{total} online"
|
||||
|
||||
service_lines = []
|
||||
state_lines = []
|
||||
for result in results:
|
||||
icon = "🟢" if result.ok else "🔴"
|
||||
label = "Online" if result.ok else "Issue"
|
||||
|
||||
service_lines.append(f"**{result.service.name}**")
|
||||
state_lines.append(f"{icon} {label}")
|
||||
|
||||
interval = os.getenv("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS)).strip()
|
||||
return [
|
||||
{
|
||||
"title": "The Mithral Archive",
|
||||
"description": summary,
|
||||
"color": color,
|
||||
"fields": [
|
||||
{
|
||||
"name": "Service",
|
||||
"value": "\n".join(service_lines)[:1024] or "No services configured.",
|
||||
"inline": True,
|
||||
},
|
||||
{
|
||||
"name": "Status",
|
||||
"value": "\n".join(state_lines)[:1024] or "n/a",
|
||||
"inline": True,
|
||||
}
|
||||
],
|
||||
"footer": {"text": f"Refreshes every {interval}s • Last checked {checked_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"},
|
||||
"timestamp": checked_at.isoformat(),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def upsert_status_message(
|
||||
token: str,
|
||||
channel_id: str,
|
||||
state_path: Path,
|
||||
results: list[CheckResult],
|
||||
) -> str:
|
||||
state = load_state(state_path)
|
||||
message_id = str(state.get("message_id", "")).strip()
|
||||
payload = {"content": "", "embeds": render_embeds(results)}
|
||||
|
||||
if message_id:
|
||||
try:
|
||||
discord_request("PATCH", token, f"/channels/{channel_id}/messages/{message_id}", payload)
|
||||
return message_id
|
||||
except RuntimeError as exc:
|
||||
print(f"Could not edit existing status message, creating a new one: {exc}", file=sys.stderr)
|
||||
|
||||
message = discord_request("POST", token, f"/channels/{channel_id}/messages", payload)
|
||||
new_id = str(message.get("id", "")).strip()
|
||||
if not new_id:
|
||||
raise RuntimeError("Discord did not return a message id")
|
||||
|
||||
save_state(state_path, {"message_id": new_id})
|
||||
return new_id
|
||||
|
||||
|
||||
def fake_preview_results(services: list[Service]) -> list[CheckResult]:
|
||||
results: list[CheckResult] = []
|
||||
for index, service in enumerate(services):
|
||||
results.append(
|
||||
CheckResult(
|
||||
service=service,
|
||||
ok=index != len(services) - 1,
|
||||
status=200 if index != len(services) - 1 else 502,
|
||||
latency_ms=42 + (index * 31),
|
||||
error=None if index != len(services) - 1 else "HTTP 502",
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def print_preview(services: list[Service]) -> None:
|
||||
payload = {"content": "", "embeds": render_embeds(fake_preview_results(services))}
|
||||
print(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
def result_to_jsonable(result: CheckResult) -> dict[str, Any]:
|
||||
return {
|
||||
"name": result.service.name,
|
||||
"url": result.service.url,
|
||||
"displayUrl": result.service.display_url,
|
||||
"ok": result.ok,
|
||||
"status": result.status,
|
||||
"latencyMs": result.latency_ms,
|
||||
"error": result.error,
|
||||
}
|
||||
|
||||
|
||||
def run_check_cycle(runtime: BotRuntime) -> tuple[str, list[CheckResult]]:
|
||||
services = load_services(runtime.config_path)
|
||||
results = [check_service(service) for service in services]
|
||||
if runtime.dry_run:
|
||||
message_id = "dry-run"
|
||||
else:
|
||||
message_id = upsert_status_message(runtime.token, runtime.channel_id, runtime.state_path, results)
|
||||
|
||||
with runtime.lock:
|
||||
runtime.last_results = results
|
||||
runtime.last_message_id = message_id
|
||||
runtime.last_checked_at = datetime.now(timezone.utc)
|
||||
runtime.last_error = None
|
||||
|
||||
return message_id, results
|
||||
|
||||
|
||||
def runtime_status(runtime: BotRuntime) -> dict[str, Any]:
|
||||
services = load_services(runtime.config_path)
|
||||
with runtime.lock:
|
||||
results = list(runtime.last_results)
|
||||
return {
|
||||
"services": services_to_jsonable(services),
|
||||
"results": [result_to_jsonable(result) for result in results],
|
||||
"lastError": runtime.last_error,
|
||||
"lastMessageId": runtime.last_message_id,
|
||||
"lastCheckedAt": runtime.last_checked_at.isoformat() if runtime.last_checked_at else None,
|
||||
"channelId": runtime.channel_id,
|
||||
}
|
||||
|
||||
|
||||
def bool_env(name: str, default: bool = False) -> bool:
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def password_hash(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS)
|
||||
salt_text = base64.urlsafe_b64encode(salt).decode("ascii").rstrip("=")
|
||||
digest_text = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt_text}${digest_text}"
|
||||
|
||||
|
||||
def decode_urlsafe_base64(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(value + padding)
|
||||
|
||||
|
||||
def verify_password_hash(encoded: str, password: str) -> bool:
|
||||
try:
|
||||
algorithm, iterations, salt_text, digest_text = encoded.split("$", 3)
|
||||
if algorithm != "pbkdf2_sha256":
|
||||
return False
|
||||
salt = decode_urlsafe_base64(salt_text)
|
||||
expected = decode_urlsafe_base64(digest_text)
|
||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
def dashboard_auth_from_env() -> DashboardAuth | None:
|
||||
if bool_env("DASHBOARD_AUTH_DISABLED", False):
|
||||
return None
|
||||
|
||||
username = env("DASHBOARD_USERNAME")
|
||||
encoded_hash = env("DASHBOARD_PASSWORD_HASH")
|
||||
ttl = int(os.getenv("DASHBOARD_SESSION_TTL_SECONDS", "28800"))
|
||||
secure = bool_env("DASHBOARD_COOKIE_SECURE", False)
|
||||
return DashboardAuth(
|
||||
DashboardAuthConfig(
|
||||
username=username,
|
||||
password_hash=encoded_hash,
|
||||
session_ttl_seconds=ttl,
|
||||
cookie_secure=secure,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def print_password_hash() -> None:
|
||||
import getpass
|
||||
|
||||
first = getpass.getpass("Dashboard password: ")
|
||||
second = getpass.getpass("Confirm password: ")
|
||||
if first != second:
|
||||
raise SystemExit("Passwords did not match")
|
||||
if len(first) < 12:
|
||||
raise SystemExit("Use at least 12 characters")
|
||||
print(password_hash(first))
|
||||
|
||||
|
||||
def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> type[BaseHTTPRequestHandler]:
|
||||
dashboard_path = Path(__file__).with_name("dashboard.html")
|
||||
|
||||
class DashboardHandler(BaseHTTPRequestHandler):
|
||||
server_version = "ArchiveStatusDashboard/1.0"
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
print(f"[dashboard] {self.address_string()} - {format % args}", flush=True)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path in {"/", "/dashboard"}:
|
||||
self.send_dashboard()
|
||||
return
|
||||
if self.path == "/favicon.ico":
|
||||
self.send_response(HTTPStatus.NO_CONTENT)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/api/session":
|
||||
session = self.require_auth()
|
||||
if session is None:
|
||||
return
|
||||
_session_id, data = session
|
||||
self.send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"username": data.username,
|
||||
"csrfToken": data.csrf_token,
|
||||
},
|
||||
)
|
||||
return
|
||||
if self.path == "/api/status":
|
||||
if self.require_auth() is None:
|
||||
return
|
||||
self.send_json(HTTPStatus.OK, runtime_status(runtime))
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path == "/api/login":
|
||||
self.handle_login()
|
||||
return
|
||||
session = self.require_auth(require_csrf=True)
|
||||
if session is None:
|
||||
return
|
||||
if self.path == "/api/logout":
|
||||
self.handle_logout(session[0])
|
||||
return
|
||||
if self.path == "/api/check":
|
||||
self.handle_check()
|
||||
return
|
||||
if self.path == "/api/services":
|
||||
self.handle_services()
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def require_auth(self, require_csrf: bool = False) -> tuple[str, DashboardSession] | None:
|
||||
if auth is None:
|
||||
return "disabled", DashboardSession("local", "disabled", time.time() + 3600)
|
||||
|
||||
session = auth.session_from_cookie(self.headers.get("Cookie"))
|
||||
if session is None:
|
||||
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Login required"})
|
||||
return None
|
||||
|
||||
if require_csrf:
|
||||
csrf = self.headers.get("X-CSRF-Token", "")
|
||||
if not hmac.compare_digest(csrf, session[1].csrf_token):
|
||||
self.send_json(HTTPStatus.FORBIDDEN, {"error": "CSRF token mismatch"})
|
||||
return None
|
||||
|
||||
return session
|
||||
|
||||
def cookie_attributes(self, max_age: int) -> str:
|
||||
attrs = [
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
f"Max-Age={max_age}",
|
||||
]
|
||||
if auth is not None and auth.config.cookie_secure:
|
||||
attrs.append("Secure")
|
||||
return "; ".join(attrs)
|
||||
|
||||
def set_session_cookie(self, session_id: str) -> None:
|
||||
ttl = auth.config.session_ttl_seconds if auth is not None else 3600
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
f"{SESSION_COOKIE}={session_id}; {self.cookie_attributes(ttl)}",
|
||||
)
|
||||
|
||||
def clear_session_cookie(self) -> None:
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
f"{SESSION_COOKIE}=; {self.cookie_attributes(0)}",
|
||||
)
|
||||
|
||||
def send_dashboard(self) -> None:
|
||||
try:
|
||||
body = dashboard_path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
self.send_error(HTTPStatus.INTERNAL_SERVER_ERROR, "dashboard.html missing")
|
||||
return
|
||||
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def read_json(self) -> dict[str, Any]:
|
||||
raw_length = self.headers.get("Content-Length", "0")
|
||||
try:
|
||||
length = int(raw_length)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid Content-Length") from exc
|
||||
if length > MAX_REQUEST_BYTES:
|
||||
raise ValueError("Request body is too large")
|
||||
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
data = json.loads(body.decode("utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("JSON body must be an object")
|
||||
return data
|
||||
|
||||
def send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
|
||||
body = json.dumps(payload, indent=2).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def handle_login(self) -> None:
|
||||
if auth is None:
|
||||
self.send_json(HTTPStatus.OK, {"username": "local", "csrfToken": "disabled"})
|
||||
return
|
||||
|
||||
try:
|
||||
data = self.read_json()
|
||||
except ValueError as exc:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
||||
return
|
||||
|
||||
username = str(data.get("username", ""))
|
||||
password = str(data.get("password", ""))
|
||||
throttle_key = f"{self.client_address[0]}:{username}"
|
||||
if not auth.login_allowed(throttle_key):
|
||||
self.send_json(HTTPStatus.TOO_MANY_REQUESTS, {"error": "Too many login attempts. Try again later."})
|
||||
return
|
||||
|
||||
login = auth.login(username, password)
|
||||
if login is None:
|
||||
auth.record_failed_login(throttle_key)
|
||||
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Invalid username or password"})
|
||||
return
|
||||
|
||||
auth.clear_failed_login(throttle_key)
|
||||
session_id, session = login
|
||||
payload = {
|
||||
"username": session.username,
|
||||
"csrfToken": session.csrf_token,
|
||||
}
|
||||
body = json.dumps(payload, indent=2).encode("utf-8")
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.set_session_cookie(session_id)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def handle_logout(self, session_id: str) -> None:
|
||||
if auth is not None:
|
||||
auth.logout(session_id)
|
||||
body = b'{\n "ok": true\n}'
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.clear_session_cookie()
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def handle_check(self) -> None:
|
||||
try:
|
||||
message_id, results = run_check_cycle(runtime)
|
||||
except Exception as exc:
|
||||
with runtime.lock:
|
||||
runtime.last_error = str(exc)
|
||||
self.send_json(HTTPStatus.BAD_GATEWAY, {"error": str(exc)})
|
||||
return
|
||||
|
||||
self.send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"messageId": message_id,
|
||||
"results": [result_to_jsonable(result) for result in results],
|
||||
},
|
||||
)
|
||||
|
||||
def handle_services(self) -> None:
|
||||
try:
|
||||
data = self.read_json()
|
||||
services_from_data(data)
|
||||
save_services_config(runtime.config_path, data)
|
||||
message_id, results = run_check_cycle(runtime)
|
||||
except Exception as exc:
|
||||
with runtime.lock:
|
||||
runtime.last_error = str(exc)
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
||||
return
|
||||
|
||||
self.send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
"messageId": message_id,
|
||||
"services": data.get("services", []),
|
||||
"results": [result_to_jsonable(result) for result in results],
|
||||
},
|
||||
)
|
||||
|
||||
return DashboardHandler
|
||||
|
||||
|
||||
def maybe_start_dashboard(runtime: BotRuntime) -> ThreadingHTTPServer | None:
|
||||
if not bool_env("DASHBOARD_ENABLED", False):
|
||||
return None
|
||||
|
||||
host = os.getenv("DASHBOARD_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
||||
port = int(os.getenv("DASHBOARD_PORT", "8787"))
|
||||
auth = dashboard_auth_from_env()
|
||||
server = ThreadingHTTPServer((host, port), make_dashboard_handler(runtime, auth))
|
||||
thread = threading.Thread(target=server.serve_forever, name="dashboard", daemon=True)
|
||||
thread.start()
|
||||
auth_note = "without auth" if auth is None else "with password sessions"
|
||||
print(f"Dashboard running at http://{host}:{port} ({auth_note})", flush=True)
|
||||
return server
|
||||
|
||||
|
||||
def main() -> int:
|
||||
load_dotenv()
|
||||
|
||||
if "--hash-password" in sys.argv:
|
||||
print_password_hash()
|
||||
return 0
|
||||
|
||||
if "--preview" in sys.argv:
|
||||
config_path = Path(os.getenv("ARCHIVE_STATUS_CONFIG", "services.json"))
|
||||
print_preview(load_services(config_path))
|
||||
return 0
|
||||
|
||||
token = env("DISCORD_BOT_TOKEN")
|
||||
channel_id = env("DISCORD_CHANNEL_ID")
|
||||
config_path = Path(env("ARCHIVE_STATUS_CONFIG", "services.json"))
|
||||
state_path = Path(env("ARCHIVE_STATUS_STATE", "state/status-message.json"))
|
||||
interval = int(env("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS)))
|
||||
runtime = BotRuntime(token, channel_id, config_path, state_path, dry_run=bool_env("DISCORD_DRY_RUN", False))
|
||||
dashboard = maybe_start_dashboard(runtime)
|
||||
|
||||
if runtime.dry_run:
|
||||
print("Discord dry run is enabled; no Discord messages will be sent or edited.", flush=True)
|
||||
else:
|
||||
try:
|
||||
identity = discord_bot_identity(token)
|
||||
username = identity.get("username", "unknown")
|
||||
bot_id = identity.get("id", "unknown")
|
||||
print(f"Discord token authenticated as {username} ({bot_id})", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"Could not verify Discord bot identity: {exc}", file=sys.stderr, flush=True)
|
||||
|
||||
stopped = False
|
||||
|
||||
def stop(_signum: int, _frame: Any) -> None:
|
||||
nonlocal stopped
|
||||
stopped = True
|
||||
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
|
||||
while not stopped:
|
||||
try:
|
||||
message_id, results = run_check_cycle(runtime)
|
||||
online = sum(1 for result in results if result.ok)
|
||||
print(f"Updated Discord status message {message_id}: {online}/{len(results)} online", flush=True)
|
||||
except Exception as exc:
|
||||
with runtime.lock:
|
||||
runtime.last_error = str(exc)
|
||||
print(f"Status update failed: {exc}", file=sys.stderr, flush=True)
|
||||
|
||||
for _ in range(interval):
|
||||
if stopped:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
if dashboard is not None:
|
||||
dashboard.shutdown()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Reference in a new issue