Compare commits
10 Commits
0d4050c58c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79279595ac | ||
|
|
5197f92685 | ||
|
|
bcdc0c6cef | ||
|
|
6a54777c5c | ||
|
|
66b0588f52 | ||
|
|
9b83d56932 | ||
|
|
bbeecde448 | ||
|
|
3a9e3a7916 | ||
|
|
ab050fddd7 | ||
|
|
a9e063867a |
11
CLAUDE.md
11
CLAUDE.md
@@ -22,6 +22,17 @@ CSS. The design should feel minimal, typographic, and monospaced-first.
|
||||
|
||||
DigitalOcean droplet, Sydney region, Ubuntu 24.04 LTS.
|
||||
|
||||
### What's on the server
|
||||
|
||||
- **Hugo static site** — built locally, rsynced to `/var/www/monotrope`
|
||||
- **Caddy** — reverse proxy and TLS for all services
|
||||
- **Miniflux** — RSS reader (Docker, PostgreSQL)
|
||||
- **Gitea** — self-hosted git server (Docker, PostgreSQL, SSH on port 2222)
|
||||
- **GoatCounter** — privacy-friendly analytics (native binary, SQLite)
|
||||
- **Hermes Agent** — Nous Research's LLM agent (`nousresearch/hermes-agent`),
|
||||
exposed via Telegram bot. Routes through OpenRouter. Used as a personal
|
||||
assistant reachable from mobile. Docker, config in `infra/hermes/`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All shell scripts use `set -euo pipefail`
|
||||
|
||||
43
Makefile
43
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: build serve deploy ssh setup miniflux gitea goatcounter enrich
|
||||
.PHONY: build serve deploy ssh setup miniflux gitea goatcounter hermes hermes-sync hermes-chat enrich wireguard calibre calibre-sync
|
||||
|
||||
# Load .env if it exists
|
||||
-include .env
|
||||
@@ -37,5 +37,46 @@ goatcounter:
|
||||
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
|
||||
ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml --tags goatcounter
|
||||
|
||||
hermes: hermes-sync
|
||||
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
|
||||
ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml --tags hermes
|
||||
|
||||
hermes-sync:
|
||||
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
|
||||
@echo "Checking for remote config changes..."
|
||||
@ssh root@$(MONOTROPE_HOST) docker cp hermes:/opt/data/config.yaml - 2>/dev/null | tar -xO > /tmp/hermes-remote-config.yaml || true
|
||||
@if ! diff -q infra/hermes/config.yaml /tmp/hermes-remote-config.yaml >/dev/null 2>&1; then \
|
||||
echo ""; \
|
||||
echo "Remote config.yaml differs from local:"; \
|
||||
echo "─────────────────────────────────────"; \
|
||||
diff -u infra/hermes/config.yaml /tmp/hermes-remote-config.yaml || true; \
|
||||
echo "─────────────────────────────────────"; \
|
||||
echo ""; \
|
||||
read -p "Overwrite remote with local? [y/N] " ans; \
|
||||
if [ "$$ans" != "y" ] && [ "$$ans" != "Y" ]; then \
|
||||
echo "Aborting. Merge remote changes into infra/hermes/config.yaml first."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
else \
|
||||
echo "Config in sync."; \
|
||||
fi
|
||||
|
||||
hermes-chat:
|
||||
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
|
||||
ssh -t root@$(MONOTROPE_HOST) docker exec -it hermes hermes chat
|
||||
|
||||
|
||||
wireguard:
|
||||
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
|
||||
ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml --tags wireguard
|
||||
|
||||
calibre:
|
||||
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
|
||||
ansible-playbook -i "$(MONOTROPE_HOST)," -u root infra/ansible/playbook.yml --tags calibre
|
||||
|
||||
calibre-sync:
|
||||
@test -n "$(MONOTROPE_HOST)" || (echo "Error: MONOTROPE_HOST is not set"; exit 1)
|
||||
ssh root@$(MONOTROPE_HOST) /opt/calibre/sync.sh
|
||||
|
||||
enrich:
|
||||
uv run enrich.py
|
||||
|
||||
@@ -2,6 +2,14 @@ monotrope.au {
|
||||
root * /var/www/monotrope
|
||||
file_server
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
}
|
||||
|
||||
# Compression
|
||||
encode zstd gzip
|
||||
|
||||
@@ -16,6 +24,8 @@ monotrope.au {
|
||||
path *.html / /posts/ /posts/*
|
||||
}
|
||||
header @html Cache-Control "public, max-age=0, must-revalidate"
|
||||
|
||||
|
||||
}
|
||||
|
||||
# Redirect www to apex
|
||||
@@ -27,6 +37,13 @@ www.monotrope.au {
|
||||
reader.monotrope.au {
|
||||
reverse_proxy localhost:8080
|
||||
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
}
|
||||
|
||||
encode zstd gzip
|
||||
}
|
||||
|
||||
@@ -34,6 +51,26 @@ reader.monotrope.au {
|
||||
git.monotrope.au {
|
||||
reverse_proxy localhost:3000
|
||||
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
}
|
||||
|
||||
encode zstd gzip
|
||||
}
|
||||
|
||||
# Calibre-web
|
||||
books.monotrope.au {
|
||||
reverse_proxy localhost:8083
|
||||
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
}
|
||||
|
||||
encode zstd gzip
|
||||
}
|
||||
|
||||
@@ -41,5 +78,12 @@ git.monotrope.au {
|
||||
stats.monotrope.au {
|
||||
reverse_proxy localhost:8081
|
||||
|
||||
header {
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
}
|
||||
|
||||
encode zstd gzip
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
goatcounter_version: "2.7.0"
|
||||
goatcounter_admin_email: "{{ lookup('env', 'GOATCOUNTER_ADMIN_EMAIL') }}"
|
||||
goatcounter_admin_password: "{{ lookup('env', 'GOATCOUNTER_ADMIN_PASSWORD') }}"
|
||||
hermes_openrouter_api_key: "{{ lookup('env', 'HERMES_OPENROUTER_API_KEY') }}"
|
||||
hermes_telegram_bot_token: "{{ lookup('env', 'HERMES_TELEGRAM_BOT_TOKEN') }}"
|
||||
hermes_telegram_allowed_users: "{{ lookup('env', 'HERMES_TELEGRAM_ALLOWED_USERS') }}"
|
||||
hermes_miniflux_api_key: "{{ lookup('env', 'HERMES_MINIFLUX_API_KEY') }}"
|
||||
wg_client_pubkey: "{{ lookup('env', 'WG_CLIENT_PUBKEY') }}"
|
||||
|
||||
tasks:
|
||||
|
||||
@@ -33,8 +38,35 @@
|
||||
- apt-transport-https
|
||||
- curl
|
||||
- ufw
|
||||
- unattended-upgrades
|
||||
state: present
|
||||
|
||||
- name: Configure unattended-upgrades
|
||||
copy:
|
||||
dest: /etc/apt/apt.conf.d/50unattended-upgrades
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
content: |
|
||||
Unattended-Upgrade::Allowed-Origins {
|
||||
"${distro_id}:${distro_codename}-security";
|
||||
"${distro_id}ESMApps:${distro_codename}-apps-security";
|
||||
"${distro_id}ESM:${distro_codename}-infra-security";
|
||||
};
|
||||
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
|
||||
Unattended-Upgrade::Remove-Unused-Dependencies "true";
|
||||
Unattended-Upgrade::Automatic-Reboot "false";
|
||||
|
||||
- name: Enable automatic updates
|
||||
copy:
|
||||
dest: /etc/apt/apt.conf.d/20auto-upgrades
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
content: |
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
|
||||
# ── Caddy ───────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Add Caddy GPG key
|
||||
@@ -69,6 +101,7 @@
|
||||
- miniflux
|
||||
- gitea
|
||||
- goatcounter
|
||||
- calibre
|
||||
|
||||
- name: Enable and start Caddy
|
||||
systemd:
|
||||
@@ -86,6 +119,48 @@
|
||||
shell: /usr/sbin/nologin
|
||||
state: present
|
||||
|
||||
# ── SSH hardening ───────────────────────────────────────────────────────
|
||||
|
||||
- name: Harden SSH configuration
|
||||
copy:
|
||||
dest: /etc/ssh/sshd_config.d/hardening.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
content: |
|
||||
PasswordAuthentication no
|
||||
PermitRootLogin prohibit-password
|
||||
MaxAuthTries 3
|
||||
notify: Restart sshd
|
||||
|
||||
# ── Fail2ban ────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Install fail2ban
|
||||
apt:
|
||||
name: fail2ban
|
||||
state: present
|
||||
|
||||
- name: Configure fail2ban SSH jail
|
||||
copy:
|
||||
dest: /etc/fail2ban/jail.local
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
content: |
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
maxretry = 3
|
||||
bantime = 3600
|
||||
findtime = 600
|
||||
notify: Restart fail2ban
|
||||
|
||||
- name: Enable and start fail2ban
|
||||
systemd:
|
||||
name: fail2ban
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# ── UFW ─────────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Set UFW default incoming policy to deny
|
||||
@@ -125,6 +200,75 @@
|
||||
ufw:
|
||||
state: enabled
|
||||
|
||||
# ── WireGuard ───────────────────────────────────────────────────────────
|
||||
|
||||
- name: Install WireGuard
|
||||
apt:
|
||||
name: wireguard
|
||||
state: present
|
||||
tags: wireguard
|
||||
|
||||
- name: Generate WireGuard server private key
|
||||
shell: wg genkey > /etc/wireguard/server_privatekey && chmod 600 /etc/wireguard/server_privatekey
|
||||
args:
|
||||
creates: /etc/wireguard/server_privatekey
|
||||
tags: wireguard
|
||||
|
||||
- name: Generate WireGuard server public key
|
||||
shell: cat /etc/wireguard/server_privatekey | wg pubkey > /etc/wireguard/server_publickey
|
||||
args:
|
||||
creates: /etc/wireguard/server_publickey
|
||||
tags: wireguard
|
||||
|
||||
- name: Read server private key
|
||||
slurp:
|
||||
src: /etc/wireguard/server_privatekey
|
||||
register: wg_server_privkey
|
||||
tags: wireguard
|
||||
|
||||
- name: Read server public key
|
||||
slurp:
|
||||
src: /etc/wireguard/server_publickey
|
||||
register: wg_server_pubkey
|
||||
tags: wireguard
|
||||
|
||||
- name: Write WireGuard config
|
||||
copy:
|
||||
dest: /etc/wireguard/wg0.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0600'
|
||||
content: |
|
||||
[Interface]
|
||||
PrivateKey = {{ wg_server_privkey.content | b64decode | trim }}
|
||||
Address = 10.100.0.1/24
|
||||
ListenPort = 51820
|
||||
|
||||
[Peer]
|
||||
PublicKey = {{ wg_client_pubkey }}
|
||||
AllowedIPs = 10.100.0.2/32
|
||||
notify: Restart WireGuard
|
||||
tags: wireguard
|
||||
|
||||
- name: Allow WireGuard UDP port
|
||||
ufw:
|
||||
rule: allow
|
||||
port: '51820'
|
||||
proto: udp
|
||||
tags: wireguard
|
||||
|
||||
- name: Enable and start WireGuard
|
||||
systemd:
|
||||
name: wg-quick@wg0
|
||||
enabled: true
|
||||
state: started
|
||||
tags: wireguard
|
||||
|
||||
- name: Display server public key
|
||||
debug:
|
||||
msg: "WireGuard server public key: {{ wg_server_pubkey.content | b64decode | trim }}"
|
||||
tags: wireguard
|
||||
|
||||
# ── Docker ──────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Create Docker keyring directory
|
||||
@@ -166,6 +310,15 @@
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Create shared Docker network
|
||||
command: docker network create monotrope
|
||||
register: docker_net
|
||||
changed_when: docker_net.rc == 0
|
||||
failed_when: docker_net.rc != 0 and 'already exists' not in docker_net.stderr
|
||||
tags:
|
||||
- miniflux
|
||||
- hermes
|
||||
|
||||
# ── Miniflux ────────────────────────────────────────────────────────────
|
||||
|
||||
- name: Create Miniflux directory
|
||||
@@ -242,6 +395,164 @@
|
||||
chdir: /opt/gitea
|
||||
tags: gitea
|
||||
|
||||
# ── Hermes Agent ────────────────────────────────────────────────────────
|
||||
|
||||
- name: Create Hermes directory
|
||||
file:
|
||||
path: /opt/hermes
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0750'
|
||||
tags: hermes
|
||||
|
||||
- name: Copy Hermes docker-compose.yml
|
||||
copy:
|
||||
src: ../hermes/docker-compose.yml
|
||||
dest: /opt/hermes/docker-compose.yml
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0640'
|
||||
tags: hermes
|
||||
|
||||
- name: Stage Hermes config.yaml
|
||||
copy:
|
||||
src: ../hermes/config.yaml
|
||||
dest: /opt/hermes/config.yaml
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0640'
|
||||
tags: hermes
|
||||
|
||||
- name: Copy config.yaml into Hermes volume
|
||||
command: docker cp /opt/hermes/config.yaml hermes:/opt/data/config.yaml
|
||||
notify: Restart Hermes
|
||||
tags: hermes
|
||||
|
||||
- name: Copy Hermes plugins
|
||||
copy:
|
||||
src: ../hermes/plugins/
|
||||
dest: /opt/hermes/plugins/
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0640'
|
||||
directory_mode: '0750'
|
||||
notify: Restart Hermes
|
||||
tags: hermes
|
||||
|
||||
- name: Write Miniflux plugin config
|
||||
copy:
|
||||
dest: /opt/hermes/plugins/miniflux/config.json
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0600'
|
||||
content: |
|
||||
{
|
||||
"base_url": "http://miniflux:8080",
|
||||
"api_key": "{{ hermes_miniflux_api_key }}"
|
||||
}
|
||||
no_log: true
|
||||
notify: Restart Hermes
|
||||
tags: hermes
|
||||
|
||||
- name: Write Hermes .env
|
||||
copy:
|
||||
dest: /opt/hermes/.env
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0600'
|
||||
content: |
|
||||
OPENROUTER_API_KEY={{ hermes_openrouter_api_key }}
|
||||
TELEGRAM_BOT_TOKEN={{ hermes_telegram_bot_token }}
|
||||
TELEGRAM_ALLOWED_USERS={{ hermes_telegram_allowed_users }}
|
||||
no_log: true
|
||||
tags: hermes
|
||||
|
||||
- name: Pull and start Hermes
|
||||
command: docker compose up -d --pull always
|
||||
args:
|
||||
chdir: /opt/hermes
|
||||
tags: hermes
|
||||
|
||||
|
||||
# ── Calibre (kobodl + calibre-web) ────────────────────────────────────
|
||||
|
||||
- name: Create Calibre directory
|
||||
file:
|
||||
path: /opt/calibre
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0750'
|
||||
tags: calibre
|
||||
|
||||
- name: Copy Calibre docker-compose.yml
|
||||
copy:
|
||||
src: ../calibre/docker-compose.yml
|
||||
dest: /opt/calibre/docker-compose.yml
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0640'
|
||||
tags: calibre
|
||||
|
||||
- name: Pull and start Calibre services
|
||||
command: docker compose up -d --pull always
|
||||
args:
|
||||
chdir: /opt/calibre
|
||||
tags: calibre
|
||||
|
||||
- name: Fix downloads volume ownership
|
||||
command: >
|
||||
docker compose exec -T kobodl
|
||||
chown 1000:1000 /downloads
|
||||
args:
|
||||
chdir: /opt/calibre
|
||||
tags: calibre
|
||||
|
||||
- name: Check if Calibre library exists
|
||||
command: >
|
||||
docker compose exec -T calibre-web
|
||||
test -f /library/metadata.db
|
||||
args:
|
||||
chdir: /opt/calibre
|
||||
register: calibre_db_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
tags: calibre
|
||||
|
||||
- name: Initialise Calibre library
|
||||
command: >
|
||||
docker compose exec -T --user abc calibre-web
|
||||
calibredb add --empty --with-library /library/
|
||||
args:
|
||||
chdir: /opt/calibre
|
||||
when: calibre_db_check.rc != 0
|
||||
tags: calibre
|
||||
|
||||
- name: Install calibre-sync script
|
||||
copy:
|
||||
dest: /opt/calibre/sync.sh
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0755'
|
||||
content: |
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd /opt/calibre
|
||||
|
||||
# Download all books from Kobo
|
||||
docker compose exec -T kobodl kobodl --config /home/config/kobodl.json book get --get-all --output-dir /downloads
|
||||
|
||||
# Import any new EPUBs into Calibre library
|
||||
# Files are kept in /downloads so kobodl can skip them next run
|
||||
docker compose exec -T --user abc calibre-web sh -c '
|
||||
for f in /downloads/*.epub; do
|
||||
[ -f "$f" ] || continue
|
||||
calibredb add "$f" --with-library /library/ || true
|
||||
done
|
||||
'
|
||||
tags: calibre
|
||||
|
||||
# ── GoatCounter ─────────────────────────────────────────────────────────
|
||||
|
||||
- name: Create goatcounter system user
|
||||
@@ -267,6 +578,7 @@
|
||||
url: "https://github.com/arp242/goatcounter/releases/download/v{{ goatcounter_version }}/goatcounter-v{{ goatcounter_version }}-linux-amd64.gz"
|
||||
dest: /tmp/goatcounter.gz
|
||||
mode: '0644'
|
||||
checksum: "sha256:98d221cb9c8ef2bf76d8daa9cca647839f8d8b0bb5bc7400ff9337c5da834511"
|
||||
tags: goatcounter
|
||||
|
||||
- name: Decompress GoatCounter binary
|
||||
@@ -381,3 +693,23 @@
|
||||
systemd:
|
||||
name: goatcounter
|
||||
state: restarted
|
||||
|
||||
- name: Restart sshd
|
||||
systemd:
|
||||
name: ssh
|
||||
state: restarted
|
||||
|
||||
- name: Restart fail2ban
|
||||
systemd:
|
||||
name: fail2ban
|
||||
state: restarted
|
||||
|
||||
- name: Restart Hermes
|
||||
command: docker compose restart
|
||||
args:
|
||||
chdir: /opt/hermes
|
||||
|
||||
- name: Restart WireGuard
|
||||
systemd:
|
||||
name: wg-quick@wg0
|
||||
state: restarted
|
||||
|
||||
50
infra/calibre/docker-compose.yml
Normal file
50
infra/calibre/docker-compose.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
services:
|
||||
kobodl:
|
||||
image: ghcr.io/subdavis/kobodl
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
command: --config /home/config/kobodl.json serve --host 0.0.0.0 --output-dir /downloads
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
ports:
|
||||
- "10.100.0.1:5100:5000"
|
||||
volumes:
|
||||
- kobodl_config:/home/config
|
||||
- downloads:/downloads
|
||||
networks:
|
||||
- default
|
||||
- monotrope
|
||||
|
||||
calibre-web:
|
||||
image: lscr.io/linuxserver/calibre-web:latest
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
ports:
|
||||
- "127.0.0.1:8083:8083"
|
||||
volumes:
|
||||
- calibre_config:/config
|
||||
- library:/library
|
||||
- downloads:/downloads
|
||||
environment:
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
TZ: "Australia/Sydney"
|
||||
DOCKER_MODS: "linuxserver/mods:universal-calibre"
|
||||
|
||||
networks:
|
||||
default:
|
||||
monotrope:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
kobodl_config:
|
||||
calibre_config:
|
||||
library:
|
||||
downloads:
|
||||
@@ -1,7 +1,12 @@
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:latest
|
||||
image: gitea/gitea:1.25
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -26,6 +31,11 @@ services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
volumes:
|
||||
- gitea_db:/var/lib/postgresql/data
|
||||
environment:
|
||||
|
||||
9
infra/hermes/config.yaml
Normal file
9
infra/hermes/config.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
model:
|
||||
provider: openrouter
|
||||
default: openrouter/auto
|
||||
memory:
|
||||
memory_enabled: true
|
||||
user_profile_enabled: true
|
||||
agent:
|
||||
max_turns: 70
|
||||
TELEGRAM_HOME_CHANNEL: '8455090116'
|
||||
29
infra/hermes/docker-compose.yml
Normal file
29
infra/hermes/docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
hermes:
|
||||
image: nousresearch/hermes-agent:latest
|
||||
container_name: hermes
|
||||
restart: unless-stopped
|
||||
command: gateway run
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- monotrope
|
||||
volumes:
|
||||
- hermes_data:/opt/data
|
||||
- ./plugins:/opt/data/plugins:ro
|
||||
environment:
|
||||
OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}"
|
||||
TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN}"
|
||||
TELEGRAM_ALLOWED_USERS: "${TELEGRAM_ALLOWED_USERS}"
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
networks:
|
||||
monotrope:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
hermes_data:
|
||||
40
infra/hermes/plugins/miniflux/__init__.py
Normal file
40
infra/hermes/plugins/miniflux/__init__.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from . import schemas, tools
|
||||
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_tool(
|
||||
name="list_feeds",
|
||||
toolset="miniflux",
|
||||
schema=schemas.LIST_FEEDS,
|
||||
handler=tools.list_feeds,
|
||||
)
|
||||
ctx.register_tool(
|
||||
name="get_unread_entries",
|
||||
toolset="miniflux",
|
||||
schema=schemas.GET_UNREAD_ENTRIES,
|
||||
handler=tools.get_unread_entries,
|
||||
)
|
||||
ctx.register_tool(
|
||||
name="get_entry",
|
||||
toolset="miniflux",
|
||||
schema=schemas.GET_ENTRY,
|
||||
handler=tools.get_entry,
|
||||
)
|
||||
ctx.register_tool(
|
||||
name="toggle_bookmark",
|
||||
toolset="miniflux",
|
||||
schema=schemas.TOGGLE_BOOKMARK,
|
||||
handler=tools.toggle_bookmark,
|
||||
)
|
||||
ctx.register_tool(
|
||||
name="update_feed_filters",
|
||||
toolset="miniflux",
|
||||
schema=schemas.UPDATE_FEED_FILTERS,
|
||||
handler=tools.update_feed_filters,
|
||||
)
|
||||
ctx.register_tool(
|
||||
name="mark_as_read",
|
||||
toolset="miniflux",
|
||||
schema=schemas.MARK_AS_READ,
|
||||
handler=tools.mark_as_read,
|
||||
)
|
||||
10
infra/hermes/plugins/miniflux/plugin.yaml
Normal file
10
infra/hermes/plugins/miniflux/plugin.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
name: miniflux
|
||||
version: 2.0.0
|
||||
description: Read and manage feeds and entries from the local Miniflux RSS reader
|
||||
provides_tools:
|
||||
- list_feeds
|
||||
- get_unread_entries
|
||||
- get_entry
|
||||
- toggle_bookmark
|
||||
- update_feed_filters
|
||||
- mark_as_read
|
||||
116
infra/hermes/plugins/miniflux/schemas.py
Normal file
116
infra/hermes/plugins/miniflux/schemas.py
Normal file
@@ -0,0 +1,116 @@
|
||||
LIST_FEEDS = {
|
||||
"name": "list_feeds",
|
||||
"description": (
|
||||
"List all subscribed RSS feeds from Miniflux. "
|
||||
"Returns feed titles, URLs, and unread counts."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
GET_UNREAD_ENTRIES = {
|
||||
"name": "get_unread_entries",
|
||||
"description": (
|
||||
"Get unread entries from Miniflux. "
|
||||
"Optionally filter by feed ID and limit the number of results."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"feed_id": {
|
||||
"type": "integer",
|
||||
"description": "Filter to a specific feed. Omit for all feeds.",
|
||||
},
|
||||
"category_id": {
|
||||
"type": "integer",
|
||||
"description": "Filter to a specific category. Omit for all categories.",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of entries to return. Defaults to 20.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
GET_ENTRY = {
|
||||
"name": "get_entry",
|
||||
"description": (
|
||||
"Get a single entry from Miniflux by ID, including its full content. "
|
||||
"Use this to read an article's text."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entry_id": {
|
||||
"type": "integer",
|
||||
"description": "The entry ID to retrieve.",
|
||||
},
|
||||
},
|
||||
"required": ["entry_id"],
|
||||
},
|
||||
}
|
||||
|
||||
TOGGLE_BOOKMARK = {
|
||||
"name": "toggle_bookmark",
|
||||
"description": "Toggle the bookmark/star status of a Miniflux entry.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entry_id": {
|
||||
"type": "integer",
|
||||
"description": "The entry ID to bookmark or unbookmark.",
|
||||
},
|
||||
},
|
||||
"required": ["entry_id"],
|
||||
},
|
||||
}
|
||||
|
||||
UPDATE_FEED_FILTERS = {
|
||||
"name": "update_feed_filters",
|
||||
"description": (
|
||||
"Update the keep or block filter rules on a Miniflux feed. "
|
||||
"Rules are case-insensitive regexes matched against entry titles and URLs. "
|
||||
"keeplist_rules: only entries matching are kept. "
|
||||
"blocklist_rules: entries matching are excluded. "
|
||||
"Pass an empty string to clear a rule."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"feed_id": {
|
||||
"type": "integer",
|
||||
"description": "The feed ID to update.",
|
||||
},
|
||||
"keeplist_rules": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern. Only matching entries are kept. Omit to leave unchanged.",
|
||||
},
|
||||
"blocklist_rules": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern. Matching entries are excluded. Omit to leave unchanged.",
|
||||
},
|
||||
},
|
||||
"required": ["feed_id"],
|
||||
},
|
||||
}
|
||||
|
||||
MARK_AS_READ = {
|
||||
"name": "mark_as_read",
|
||||
"description": "Mark one or more Miniflux entries as read.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entry_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
"description": "List of entry IDs to mark as read.",
|
||||
},
|
||||
},
|
||||
"required": ["entry_ids"],
|
||||
},
|
||||
}
|
||||
144
infra/hermes/plugins/miniflux/tools.py
Normal file
144
infra/hermes/plugins/miniflux/tools.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
_PLUGIN_DIR = Path(__file__).parent
|
||||
with open(_PLUGIN_DIR / "config.json") as _f:
|
||||
_CONFIG = json.loads(_f.read())
|
||||
|
||||
_BASE = _CONFIG.get("base_url", "http://miniflux:8080").rstrip("/")
|
||||
_HEADERS = {"X-Auth-Token": _CONFIG.get("api_key", "")}
|
||||
|
||||
|
||||
def _get(path, **params):
|
||||
resp = requests.get(f"{_BASE}/v1{path}", headers=_HEADERS, params=params, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _put(path, body):
|
||||
resp = requests.put(f"{_BASE}/v1{path}", headers=_HEADERS, json=body, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
|
||||
|
||||
def list_feeds(args: dict, **kwargs) -> str:
|
||||
try:
|
||||
feeds = _get("/feeds")
|
||||
counters = _get("/feeds/counters")
|
||||
unreads = counters.get("unreads", {})
|
||||
|
||||
result = []
|
||||
for f in feeds:
|
||||
result.append({
|
||||
"id": f["id"],
|
||||
"title": f["title"],
|
||||
"site_url": f.get("site_url", ""),
|
||||
"category": f.get("category", {}).get("title", ""),
|
||||
"unread": unreads.get(str(f["id"]), 0),
|
||||
})
|
||||
result.sort(key=lambda x: x["unread"], reverse=True)
|
||||
return json.dumps({"feeds": result, "total": len(result)})
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
def get_unread_entries(args: dict, **kwargs) -> str:
|
||||
try:
|
||||
params = {
|
||||
"status": "unread",
|
||||
"limit": args.get("limit", 20),
|
||||
"direction": "desc",
|
||||
"order": "published_at",
|
||||
}
|
||||
if args.get("feed_id"):
|
||||
path = f"/feeds/{args['feed_id']}/entries"
|
||||
elif args.get("category_id"):
|
||||
path = f"/categories/{args['category_id']}/entries"
|
||||
else:
|
||||
path = "/entries"
|
||||
|
||||
data = _get(path, **params)
|
||||
entries = []
|
||||
for e in data.get("entries", []):
|
||||
entries.append({
|
||||
"id": e["id"],
|
||||
"title": e["title"],
|
||||
"url": e.get("url", ""),
|
||||
"feed": e.get("feed", {}).get("title", ""),
|
||||
"category": e.get("feed", {}).get("category", {}).get("title", ""),
|
||||
"author": e.get("author", ""),
|
||||
"published_at": e.get("published_at", ""),
|
||||
"reading_time": e.get("reading_time", 0),
|
||||
})
|
||||
return json.dumps({
|
||||
"entries": entries,
|
||||
"total": data.get("total", len(entries)),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
def get_entry(args: dict, **kwargs) -> str:
|
||||
try:
|
||||
entry = _get(f"/entries/{args['entry_id']}")
|
||||
return json.dumps({
|
||||
"id": entry["id"],
|
||||
"title": entry["title"],
|
||||
"url": entry.get("url", ""),
|
||||
"author": entry.get("author", ""),
|
||||
"feed": entry.get("feed", {}).get("title", ""),
|
||||
"category": entry.get("feed", {}).get("category", {}).get("title", ""),
|
||||
"published_at": entry.get("published_at", ""),
|
||||
"reading_time": entry.get("reading_time", 0),
|
||||
"content": entry.get("content", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
def toggle_bookmark(args: dict, **kwargs) -> str:
|
||||
try:
|
||||
_put(f"/entries/{args['entry_id']}/bookmark", {})
|
||||
return json.dumps({"ok": True, "entry_id": args["entry_id"]})
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
def update_feed_filters(args: dict, **kwargs) -> str:
|
||||
try:
|
||||
feed_id = args["feed_id"]
|
||||
body = {}
|
||||
if "keeplist_rules" in args:
|
||||
body["keeplist_rules"] = args["keeplist_rules"]
|
||||
if "blocklist_rules" in args:
|
||||
body["blocklist_rules"] = args["blocklist_rules"]
|
||||
if not body:
|
||||
return json.dumps({"error": "Provide keeplist_rules and/or blocklist_rules"})
|
||||
resp = requests.put(
|
||||
f"{_BASE}/v1/feeds/{feed_id}",
|
||||
headers=_HEADERS, json=body, timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
feed = resp.json()
|
||||
return json.dumps({
|
||||
"ok": True,
|
||||
"feed_id": feed["id"],
|
||||
"title": feed["title"],
|
||||
"keeplist_rules": feed.get("keeplist_rules", ""),
|
||||
"blocklist_rules": feed.get("blocklist_rules", ""),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
def mark_as_read(args: dict, **kwargs) -> str:
|
||||
try:
|
||||
entry_ids = args.get("entry_ids", [])
|
||||
if not entry_ids:
|
||||
return json.dumps({"error": "No entry_ids provided"})
|
||||
_put("/entries", {"entry_ids": entry_ids, "status": "read"})
|
||||
return json.dumps({"ok": True, "marked_read": entry_ids})
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
@@ -1,12 +1,20 @@
|
||||
services:
|
||||
miniflux:
|
||||
image: miniflux/miniflux:latest
|
||||
image: miniflux/miniflux:2.2.19
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
networks:
|
||||
- default
|
||||
- monotrope
|
||||
environment:
|
||||
DATABASE_URL: "postgres://miniflux:${MINIFLUX_DB_PASSWORD}@db/miniflux?sslmode=disable"
|
||||
RUN_MIGRATIONS: "1"
|
||||
@@ -20,6 +28,11 @@ services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
volumes:
|
||||
- miniflux_db:/var/lib/postgresql/data
|
||||
environment:
|
||||
@@ -34,5 +47,10 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
default:
|
||||
monotrope:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
miniflux_db:
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# setup.sh — Provision a fresh Ubuntu 24.04 droplet for monotrope.au
|
||||
# Run as root via: ssh root@<DROPLET_IP> 'bash -s' < infra/setup.sh
|
||||
|
||||
DEPLOY_USER="deploy"
|
||||
SITE_DIR="/var/www/monotrope"
|
||||
DEPLOY_PUBKEY="${DEPLOY_PUBKEY:-}" # Set this env var before running, or edit below
|
||||
|
||||
echo "==> Updating packages"
|
||||
apt-get update -y
|
||||
apt-get upgrade -y
|
||||
|
||||
# ── Caddy ─────────────────────────────────────────────────────────────────
|
||||
echo "==> Installing Caddy"
|
||||
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||
| tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
|
||||
apt-get update -y
|
||||
apt-get install -y caddy
|
||||
|
||||
# ── Site directory ─────────────────────────────────────────────────────────
|
||||
echo "==> Creating www user and site directory"
|
||||
id -u www &>/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin www
|
||||
mkdir -p "$SITE_DIR"
|
||||
chown www:www "$SITE_DIR"
|
||||
chmod 755 "$SITE_DIR"
|
||||
|
||||
# ── Caddyfile ──────────────────────────────────────────────────────────────
|
||||
echo "==> Installing Caddyfile"
|
||||
cp "$(dirname "$0")/Caddyfile" /etc/caddy/Caddyfile
|
||||
chown root:caddy /etc/caddy/Caddyfile
|
||||
chmod 640 /etc/caddy/Caddyfile
|
||||
|
||||
systemctl enable caddy
|
||||
systemctl restart caddy
|
||||
|
||||
# ── UFW ────────────────────────────────────────────────────────────────────
|
||||
echo "==> Configuring UFW"
|
||||
apt-get install -y ufw
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow ssh
|
||||
ufw allow http
|
||||
ufw allow https
|
||||
ufw --force enable
|
||||
|
||||
# ── Docker ────────────────────────────────────────────────────────────────
|
||||
echo "==> Installing Docker"
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||
| tee /etc/apt/sources.list.d/docker.list
|
||||
|
||||
apt-get update -y
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
systemctl enable docker
|
||||
|
||||
# ── Deploy user ───────────────────────────────────────────────────────────
|
||||
echo "==> Creating deploy user"
|
||||
id -u "$DEPLOY_USER" &>/dev/null || useradd --create-home --shell /bin/bash "$DEPLOY_USER"
|
||||
|
||||
# Give deploy user write access to the site directory
|
||||
chown -R "$DEPLOY_USER":www "$SITE_DIR"
|
||||
chmod 775 "$SITE_DIR"
|
||||
|
||||
# Set up SSH key auth
|
||||
DEPLOY_HOME="/home/$DEPLOY_USER"
|
||||
mkdir -p "$DEPLOY_HOME/.ssh"
|
||||
chmod 700 "$DEPLOY_HOME/.ssh"
|
||||
touch "$DEPLOY_HOME/.ssh/authorized_keys"
|
||||
chmod 600 "$DEPLOY_HOME/.ssh/authorized_keys"
|
||||
chown -R "$DEPLOY_USER":"$DEPLOY_USER" "$DEPLOY_HOME/.ssh"
|
||||
|
||||
if [[ -n "$DEPLOY_PUBKEY" ]]; then
|
||||
echo "$DEPLOY_PUBKEY" >> "$DEPLOY_HOME/.ssh/authorized_keys"
|
||||
echo "==> Deploy public key installed"
|
||||
else
|
||||
echo "WARNING: DEPLOY_PUBKEY not set. Add your public key to $DEPLOY_HOME/.ssh/authorized_keys manually."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> Done. Checklist:"
|
||||
echo " - Point DNS A records for monotrope.au and www.monotrope.au to this server's IP"
|
||||
echo " - If DEPLOY_PUBKEY was not set, add your key to $DEPLOY_HOME/.ssh/authorized_keys"
|
||||
echo " - Run 'make deploy' from your local machine to push the site"
|
||||
19
site/content/posts/self-hosting.md
Normal file
19
site/content/posts/self-hosting.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: "An Experiment in Self-Hosting"
|
||||
date: 2026-04-10T00:00:00+10:00
|
||||
draft: false
|
||||
---
|
||||
|
||||
One of the things I wanted to do with this site is to see how much tooling I could self-host on a small VPS, in particular with the acceleration afforded by AI coding through Claude Code.
|
||||
|
||||
So far I have:
|
||||
|
||||
* A [Hugo](https://gohugo.io/) static site
|
||||
* [Caddy](https://caddyserver.com/) webserver
|
||||
* Self-hosted feed reader with [Miniflux](https://miniflux.app/)
|
||||
* Analytics using [Goatcounter](https://www.goatcounter.com/)
|
||||
* Git server using [Gitea](https://about.gitea.com/) (you can check out the source for the whole project, inception-style, at [git.monotrope.au/louis/monotrope](https://git.monotrope.au/louis/monotrope))
|
||||
|
||||
The only external dependency for the whole setup is the server itself (a DigitalOcean droplet), and I'm sure I'll come up with more tools I can add to the server over time.
|
||||
|
||||
All of this I would estimate took less than 4 hours to set up and deploy. I think previously even the small amount of effort required to deploy a static blog would have pushed me towards free platforms like Medium or GitHub Pages. This mode of production has the potential be a great thing for the Web if more tinkerers can build and host their own stuff instead of relying on centralised platforms where you are the product.
|
||||
@@ -6,9 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ if not .IsHome }}{{ .Title }} · {{ end }}{{ .Site.Title }}</title>
|
||||
<meta name="description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="preload" href="/fonts/jetbrains-mono-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/spectral-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
{{ range .AlternativeOutputFormats -}}
|
||||
|
||||
@@ -1,5 +1,96 @@
|
||||
/* ── Fonts ─────────────────────────────────────── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Spectral:ital,wght@0,400;0,600;1,400&display=swap');
|
||||
|
||||
/* JetBrains Mono — latin-ext */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 300 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/jetbrains-mono-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* JetBrains Mono — latin */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 300 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/jetbrains-mono-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* JetBrains Mono italic — latin-ext */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/jetbrains-mono-italic-400-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* JetBrains Mono italic — latin */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/jetbrains-mono-italic-400-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Spectral — latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Spectral';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/spectral-400-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* Spectral — latin */
|
||||
@font-face {
|
||||
font-family: 'Spectral';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/spectral-400-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* Spectral 600 — latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Spectral';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/spectral-600-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* Spectral 600 — latin */
|
||||
@font-face {
|
||||
font-family: 'Spectral';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/spectral-600-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* Spectral italic — latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Spectral';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/spectral-italic-400-latin-ext.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* Spectral italic — latin */
|
||||
@font-face {
|
||||
font-family: 'Spectral';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/spectral-italic-400-latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* ── Reset ─────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
|
||||
BIN
site/static/fonts/jetbrains-mono-italic-400-latin-ext.woff2
Normal file
BIN
site/static/fonts/jetbrains-mono-italic-400-latin-ext.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/jetbrains-mono-italic-400-latin.woff2
Normal file
BIN
site/static/fonts/jetbrains-mono-italic-400-latin.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/jetbrains-mono-latin-ext.woff2
Normal file
BIN
site/static/fonts/jetbrains-mono-latin-ext.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/jetbrains-mono-latin.woff2
Normal file
BIN
site/static/fonts/jetbrains-mono-latin.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-400-latin-ext.woff2
Normal file
BIN
site/static/fonts/spectral-400-latin-ext.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-400-latin.woff2
Normal file
BIN
site/static/fonts/spectral-400-latin.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-600-latin-ext.woff2
Normal file
BIN
site/static/fonts/spectral-600-latin-ext.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-600-latin.woff2
Normal file
BIN
site/static/fonts/spectral-600-latin.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-italic-400-latin-ext.woff2
Normal file
BIN
site/static/fonts/spectral-italic-400-latin-ext.woff2
Normal file
Binary file not shown.
BIN
site/static/fonts/spectral-italic-400-latin.woff2
Normal file
BIN
site/static/fonts/spectral-italic-400-latin.woff2
Normal file
Binary file not shown.
Reference in New Issue
Block a user