Compare commits

...

10 Commits

Author SHA1 Message Date
92b157eda4 CLAUDE.md: owner's standing server authorization; recovery keys copy is with the owner
What an agent may do on the servers without asking (read the password
file, SSH, deploy and restart on 216, LAN transfers), what still needs a
question (deleting data, changes on 249/247, /etc and .env, keys, firewall,
credit spend), and what is never allowed. SSH example fixed for zsh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:48:48 +03:30
4efe6e3f6b Cut-over to 216: docs, CLAUDE.md and nginx source describe 216 as the live server
Arvan now sends both domains to 216 (2026-10-18). The final 249 dump was
restored on 216 with equal row counts, 249's app is stopped and disabled,
its replication slot dropped and 247's 249-era units disabled. The 249-era
archive on 247 was age-encrypted to 216's key and the plaintext shredded.
qdrant.tadrisino2.ir broke at the same moment (Django 400 via Arvan) and is
recorded as waiting on the senior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:40:47 +03:30
43e02adf37 216 sshd: LoginGraceTime back to 2m (30s silently cut off people typing a long password)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:59:59 +03:30
e25ffb5575 CLAUDE.md: 216 password is the AdminUser-Password line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:48:23 +03:30
eb03b467d4 216: shared admin account mathapp-admin, temp removed, hostname mathapp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:50:17 +03:30
f2c57c1ad1 docs: step-by-step cut-over to 216 with rollback, what waits on others, after-cut-over list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:18:40 +03:30
f6d779255d docs: what was tested on 216 (end-to-end user flow, restore from the 247 encrypted copy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:08:38 +03:30
4cd859b5fa 216 backups encrypted on 216: pgBackRest (AES-256) + age-encrypted payment CSVs, pushed write-only to 247 with rrsync; health watches pg_stat_archiver and OFFSITE_SYNC_STATUS_FILE; 216 scripts/configs in ops/server216; plaintext pg216 pipeline removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:43:00 +03:30
dd98f16d12 Live backup of 216 on 247 (zareian-pg216-* units, Postgres 16 tools in our dir, SCRAM hash only on 216), backup scripts take PGB_ROOT/PGB_BIN/PGB_PORT with 249 defaults, anti-bot layers documented
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:22:56 +03:30
bd329f83ae Dedicated server 78.38.35.216: state and cut-over order in docs, CLAUDE.md entry, password snippet for two Password lines, archive-app-log.sh finds its project dir
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:06:04 +03:30
32 changed files with 646 additions and 175 deletions

View File

@@ -39,6 +39,9 @@ DB_PORT=5432
# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده می‌گیرد (ops/pgbackup/). روی سرور: zareian_247.
# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار می‌دهد. روی لپ‌تاپ خالی.
OFFSITE_WAL_SLOT=
# سرورِ اختصاصی (۲۱۶): فایلی که tadrisino-offsite-sync زمانِ آخرین ارسالِ بکاپِ رمزشده به ۲۴۷ را در آن می‌نویسد.
# روی ۲۱۶: /var/lib/tadrisino-backup/offsite-synced-at (بر OFFSITE_WAL_SLOT مقدم است). روی لپ‌تاپ خالی.
OFFSITE_SYNC_STATUS_FILE=
# ═══════════════════════════════════════════════════════════════════════
# ۲) دامنه‌ها — دو آدرس، بقیه از آن‌ها ساخته می‌شوند

152
CLAUDE.md
View File

@@ -27,7 +27,7 @@ answers from the same book. Two repos, one API contract: this Django + DRF backe
| Reading a textbook with a vision model (costs, model choice) | `docs/extraction-playbook.md`, `data/textbooks/README.md` |
| Tutor cost per question, subscription margins, guardrails | `docs/ai-cost-and-guardrails.md` |
| Measured model prices (append-only ledger: add a dated row, never overwrite) | `docs/model-prices.md` |
| Load testing (laptop and the shared server) | `tools/loadtest/README.md` |
| Load testing (laptop and the server) | `tools/loadtest/README.md` |
| Frontend structure and its deploy | `~/Desktop/MathApp_frontend/README.md` |
| Android build, signing, what the next APK must carry | `~/Desktop/MathApp_frontend/android/README.md` |
@@ -37,35 +37,65 @@ Before writing a new doc, check this table: the same fact in three places drifts
## ⛔ Working on the servers — read before every SSH session
The owner often says "put it on the server" / "deploy it" and expects you to do it yourself. You
may, but these machines are **shared company servers**, not ours. Our app is one tenant among many.
A careless command can take down the whole company's git, mail and other teams' production apps,
and there is no technical guard rail: our account has full root. **The only guard rail is you.**
The owner often says "put it on the server" / "deploy it" and expects you to do it yourself, and
"the server" means **216**. You may. 216 is ours alone, but it is production, and two other machines
we still hold files on (249, 247) are **shared company servers** where our account has full root and
a careless command can take down the whole company's git, mail and other teams' apps. **The only
guard rail is you.**
### Standing authorization (the owner's, permanent: do not ask again)
The owner does not want to repeat this each session. When a task needs a server, start working:
- **Granted without asking:** read the passwords from `~/Desktop/رمز سرور.txt` («The password» below),
SSH to 216/249/247, look at anything of ours (status, logs, disk, DB counts, nginx config); on **216**,
deploy backend and frontend, restart `zareian-mathapp`, reload nginx, run management commands, take
backups, write into our own directories, and install a package from the mirror when a task needs it;
move files between servers over the LAN through 216.
- **Ask first (one Persian line saying what and why, then wait):** anything that deletes or overwrites
data (dropping/restoring a DB, removing backups, `rm` of more than our own temp files), any change on
249 or 247 beyond reading, `.env` and `/etc` edits on 216 (after the backup of rule 5), SSH keys,
`sshd_config`, firewall, users, reboots, and anything that spends GapGPT/SMS credit beyond a single check.
- **Never, even if asked in passing:** touch anything listed as not ours on 249/247, power a machine off,
print a password or secret.
- Every command still follows «Announce before you act» and the rules below. The auto-mode classifier may
block a shared-server action even with this authorization; then stop, say what was blocked, and give the
owner the exact command or ask them to approve it.
### What is ours — and nothing else is
**`78.38.35.249` (`haghzad`, app server)**, account `administrator`:
**`78.38.35.216` (hostname `mathapp`, the live server)**: a dedicated VM, the whole machine is ours. Log in
as `zareian` (sudo); the senior and the company owner share `mathapp-admin` (sudo); no other logins. The app
runs as the non-sudo user `mathapp`:
| Ours | Note |
|---|---|
| `/home/administrator/mathapp-v2` | backend checkout; `.env`, `.django_secret_key`, `logs/`, `.deploy-backups/` are untracked and live here |
| `/home/administrator/mathapp_frontend_new` | frontend checkout; nginx serves its `dist/` |
| `/home/mathapp/mathapp-v2` | backend checkout; `.env`, `.django_secret_key`, `.pg_password`, `.redis_password`, `logs/`, `.deploy-backups/` are untracked and live here |
| `/home/mathapp/mathapp_frontend_new` | frontend checkout; nginx serves its `dist/` |
| `gunicorn-mathapp.service` = alias **`zareian-mathapp`** | always type the alias |
| `zareian-redis.service` (port 6390) | our own Redis |
| Postgres cluster `14/zareian` (port 5440, DB `zareian_mathapp`) | our own cluster |
| `zareian-applog-archive.timer` | nightly log archive |
| nginx: `sites-enabled/mathapp`, `sites-available/mathapp-front`, `snippets/zareian-*.conf` | source of truth is `ops/nginx/` |
| `zareian-redis.service` (6390), `postgresql@16-zareian` (5440, DB `zareian_mathapp`) | |
| `tadrisino-*` timers (pgBackRest, offsite sync, payment CSV, blocklist), `zareian-applog-archive.timer` | sources in `ops/server216/` |
| nginx `sites-enabled/mathapp`, `mathapp-front`, `snippets/zareian-*.conf` | sources in `ops/nginx/` |
| `/home/zareian/stage/` | DB dumps from the cut-over (2026-10-18) |
**Not ours, never touch:** `~/mathapp` (old, not git, not what runs), the generic `gunicorn.service`
(another team's app), `gitea`, `postfix`/`dovecot`, `named`, `mariadb`, `postgresql@14-main` (port 5432),
`redis-server` (6379), `rabbitmq`, `metabase`, `apirag`, `artinafront`, `metaverse`, `celerybeat`,
other people's home directories (`aria`, `artina`, `behdad`, `zahra`, `git`, `gitea`) and every
other nginx site. `metabase` and Artina's `celerybeat` crash-loop and eat ~3 of 8 cores; that is a
request to the server admin, not something to fix.
**`78.38.35.249` (`haghzad`, shared, retired for us since 2026-10-18)**, account `administrator`: our app
there is stopped and disabled, the replication slot is gone, Arvan no longer points at it. Still ours
and left in place as the rollback until about 2026-10-25: `/home/administrator/mathapp-v2`,
`/home/administrator/mathapp_frontend_new`, `gunicorn-mathapp.service` (disabled; the `zareian-mathapp`
alias returns with `enable`), `zareian-redis`, Postgres cluster `14/zareian` (5440, plaintext data as of
the cut-over), `zareian-applog-archive.timer`, our nginx sites and snippets. Removing them is the owner's
call. **Not ours, never touch:** `~/mathapp`, the generic `gunicorn.service`, `gitea`,
`postfix`/`dovecot`, `named`, `mariadb`, `postgresql@14-main` (5432), `redis-server` (6379), `rabbitmq`,
`metabase`, `apirag`, `artinafront`, `metaverse`, `celerybeat`, other people's home directories and every
other nginx site.
**`78.38.35.247` (backup server)**, same account and password: ours is only `~/zareian-pgbackup/`
and the `zareian-pg-*` / `zareian-applog-pull` units. It also runs other teams' services and its
own `postgresql@14-main`, which we never use.
**`78.38.35.247` (backup server, shared)**, same account and password as 249: ours is only
`~/zareian-pgbackrest-216/` (216's live backups, encrypted on 216), `~/zareian-pgbackup/` (the 249-era
archive, every file now `age`-encrypted to 216's key; its `zareian-pg-*` / `zareian-applog-pull` units are
disabled), our lines in `~/.ssh/authorized_keys` and `~/.pgpass`, and `~/.ssh/zareian_*`. 247 never
holds a decryption key. It also runs other teams' services and its own `postgresql@14-main`.
On 216 every rule below applies except 4 and 7 (nothing there is shared); on 249 and 247 all of them.
### Rules (not negotiable)
@@ -73,18 +103,18 @@ own `postgresql@14-main`, which we never use.
`df -h /`. Know what is there before you act on it.
2. **No destructive command outside our directories.** No `rm -rf`, `chown -R`, `chmod -R`,
`find … -delete`, `DROP`, `docker system prune`, package upgrades, reboots, firewall changes.
Inside our directories, write paths out in full (`/home/administrator/mathapp-v2/...`), never
Inside our directories, write paths out in full (`/home/mathapp/mathapp-v2/...`), never
rely on the current directory.
3. **Never `git clean`, never delete and re-clone the project.** `.env`, `.django_secret_key`, logs
and backups are untracked; `git reset --hard origin/main` is the way back to a known tree.
4. **Restart only `zareian-mathapp`.** `systemctl restart gunicorn` is another team's app. nginx is
shared: `sudo nginx -t` then `reload`, never `restart`.
5. **Back up before editing anything under `/etc` or `.env`**, into
`~/mathapp-v2/.deploy-backups/<what>-before-<change>-<YYYYMMDD-HHMMSS>`, and say where it is.
`/home/mathapp/mathapp-v2/.deploy-backups/<what>-before-<change>-<YYYYMMDD-HHMMSS>`, and say where it is.
Database: `deploy.sh` already takes a `pg_dump` before `migrate`; for any manual data change take
one yourself first.
6. **Disk is the standing risk.** Check `df -h /` before anything that writes a lot. A full disk
takes Gitea and mail down with us.
6. **Disk is the standing risk.** Check `df -h /` before anything that writes a lot. On 216 a full disk
stops our DB; on 249/247 it takes Gitea and mail down with us.
7. **Do not use other teams' resources**, even "just an empty database" on their Redis or Postgres,
their queues or their nginx sites. Ask the owner; it becomes a request to the server admin.
8. **Secrets stay put.** Read `.env` only through Django (`venv/bin/python manage.py shell -c` printing
@@ -107,19 +137,25 @@ destroy the password copy, **never** power anything off.
### The password
Access is a password for the shared `administrator` account (same for 249 and 247). The owner
keeps it in `~/Desktop/رمز سرور.txt` and wants agents to read it from there rather than ask in
chat; the auto-mode classifier may still block that read until the owner confirms in the
session. Take only the password line, everything after the first `: ` (stripping only the label
The owner keeps the passwords in `~/Desktop/رمز سرور.txt`; read them from there, never ask for them in
chat. 216 (`zareian`) uses the `AdminUser-Password` line; 249 and 247 (`administrator`) use the
first `Password` line. Take only the value, everything after the first `: ` (stripping only the label
once sent the whole line and SSH refused it):
```bash
sed -n '/^Password/{s/^[^:]*: *//;p;}' ~/Desktop/'رمز سرور.txt' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw"
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 '<command>'
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" <command>' < "$SCRATCH/.pw"
rm -P "$SCRATCH/.pw" # as soon as the work is done (macOS has no shred); recreate it next time
awk '/^AdminUser-Password/{sub(/^[^:]*: */,"");printf "%s",$0;exit}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pwn" && chmod 600 "$SCRATCH/.pwn" # 216
sed -n '/^Password/{s/^[^:]*: *//;p;q;}' ~/Desktop/'رمز سرور.txt' | tr -d '\r\n' > "$SCRATCH/.pw" && chmod 600 "$SCRATCH/.pw" # 249/247
# one connection, reused (the shell is zsh: write the -o options out, a "$C" variable does not split)
sshpass -f "$SCRATCH/.pwn" ssh -o ControlMaster=auto -o ControlPath=/tmp/z216-%C -o ControlPersist=30m zareian@78.38.35.216 '<command>'
sshpass -f "$SCRATCH/.pwn" ssh -o ControlPath=/tmp/z216-%C zareian@78.38.35.216 'sudo -S -p "" <command>' < "$SCRATCH/.pwn"
ssh -o ControlPath=/tmp/z216-%C -O exit zareian@78.38.35.216 # when done, in its own call
rm -P "$SCRATCH/.pwn" # as soon as the work is done (macOS has no shred); recreate it next time
```
The laptop's connection is slow: move anything bigger than a few MB **between servers over the LAN**
(216 = `192.168.200.163`, 249 = `.164`, 247 = `.167`), never piped through the laptop. 216 has `sshpass`;
a password copied to 216 for that goes in `/home/zareian/` with mode 600 and is deleted in the same command.
Never put it on a command line (`ps` shows argv to every user on the server), in the repo, a
memory, a commit or a note; never echo it. Many SSH connections in a row can trip a lockout: open
one `ControlMaster` connection (socket under `/tmp`, macOS limits the path to 104 bytes). Do not
@@ -130,17 +166,18 @@ create SSH keys, edit `sshd_config` or change the password unless the owner asks
Backend (commit and push to Gitea first; the server deploys what is on `origin/main`):
```bash
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'cd /home/administrator/mathapp-v2 && ./deploy.sh'
sshpass -f "$SCRATCH/.pw" ssh administrator@78.38.35.249 'sudo -S -p "" systemctl restart zareian-mathapp' < "$SCRATCH/.pw"
sshpass -f "$SCRATCH/.pwn" ssh zareian@78.38.35.216 'sudo -S -p "" -u mathapp bash -c "cd /home/mathapp/mathapp-v2 && ./deploy.sh"' < "$SCRATCH/.pwn"
sshpass -f "$SCRATCH/.pwn" ssh zareian@78.38.35.216 'sudo -S -p "" systemctl restart zareian-mathapp' < "$SCRATCH/.pwn"
```
`deploy.sh` saves any hand edits as a patch, resets to `origin/main`, installs, backs up the DB,
migrates, runs `seed_content`, collectstatic, and checks health through the gunicorn socket. The
restart is separate because `sudo` inside the script cannot prompt. Then **verify; never assume**:
`deploy.sh` runs as `mathapp`: it saves any hand edits as a patch, resets to `origin/main`, installs,
backs up the DB, migrates, runs `seed_content`, collectstatic, and checks health through the gunicorn
socket. Its own restart fails (`mathapp` has no sudo) and says so; the restart is the second line. Then
**verify; never assume**:
- `systemctl show zareian-mathapp -p ActiveEnterTimestamp` is after your deploy (a file on disk is not
a deployed file: once the server served two-day-old code while every file was current);
- `git -C /home/administrator/mathapp-v2 log -1 --format=%h` equals your local `main`;
- `sudo -u mathapp git -C /home/mathapp/mathapp-v2 log -1 --format=%h` equals your local `main`;
- `curl https://mathapi.tadrisino2.ir/api/health` → `{"status":"ok"}` (details need `X-Internal-Key`);
- the thing you changed, exercised once from outside.
@@ -148,7 +185,7 @@ If the server cannot reach Gitea (`git fetch` 504 from the server, 200 from the
`DEPLOY_BUNDLE` in `docs/operations.md` «دیپلوی».
Frontend: push to `upstream` (`Tadrisino/MathApp_frontend`) `main` (the owner authorized pushing and
deploying it without asking), then `cd /home/administrator/mathapp_frontend_new && ./deploy.sh` (builds
deploying it without asking), then as `mathapp` `cd /home/mathapp/mathapp_frontend_new && ./deploy.sh` (builds
into `dist.next`, swaps, keeps `dist.prev`). `config.js` and `index.html` are cached by ArvanCloud;
purge them there when they change.
@@ -264,9 +301,13 @@ Invariants that are not obvious from one file:
## Current state, by area
### Infrastructure (server 249)
### Infrastructure (server 216)
- Postgres cluster `14/zareian`, port 5440, localhost only (`DB_*` in `.env`; without `DB_NAME` the app
- Dedicated VMware VM, Ubuntu 24.04, 8 cores (Xeon E5-2690 v3), 12 GB RAM, 97 GB disk; outbound IP
`78.38.35.228`. ufw allows 22/80/443; FireHOL blocklist, `ufw limit 22` and stepped fail2ban against SSH
brute force. apt/pip/npm come from Iranian mirrors (`docs/operations.md` «سرور»).
- Traffic: ArvanCloud → 216:443 for both `math.` and `mathapi.tadrisino2.ir` (origin switched 2026-10-18).
- Postgres 16 cluster `16/zareian`, port 5440, localhost only (`DB_*` in `.env`; without `DB_NAME` the app
uses SQLite, which is what local dev and tests use). `max_connections=60`.
- Connections come from a per-worker pool (`DB_POOL_MAX_SIZE` 8); a full pool answers 503 + `Retry-After`
(`config/db.py`), and `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`.
@@ -280,22 +321,23 @@ Invariants that are not obvious from one file:
- nginx (versions in `ops/nginx/`): only ArvanCloud and the machine itself get through (direct IP → 403);
CSP and security headers on the frontend; API body limit 20 MB; HSTS. Verified in Chrome; **not** verified
in the Android WebView or through `pay.html` to the gateway.
- Capacity (load test on the server, `tools/loadtest/play.py`): 300 players ≈ 88 req/s, median 0.1 s;
600 ≈ 137 req/s, median 1.4 s, no errors; 1,000 hits the ceiling (~135 req/s, p95 15 s). The bottleneck
is the shared CPU, not the DB pool. More capacity needs the neighbours' crash-loops fixed plus more cores
(then 8 workers with `DB_POOL_MAX_SIZE` 6).
- Capacity: measured only on 249 (shared CPU): 300 players ≈ 88 req/s, median 0.1 s; 600 ≈ 137 req/s,
median 1.4 s; 1,000 hit the ceiling. **216 has not been load-tested yet** (`tools/loadtest/README.md`); with
more cores, 8 workers and `DB_POOL_MAX_SIZE` 6.
- `/api/health`: public answer is only ok/error; full details (DB, migrations, cache, pool, integrations,
`aiProvider`, `offsiteBackup`, warnings, `clientIp`) need `X-Internal-Key`. Swagger/ReDoc/schema need an
admin session.
### Backups and logs (server 247)
### Backups and logs (encrypted on 216, copied to 247)
Every Postgres change streams live to 247 (`pg_receivewal` over a restricted SSH tunnel, slot
`zareian_247`, async; `max_slot_wal_keep_size=1GB` protects 249's disk) plus a daily base backup (7 kept).
`deploy.sh` also takes a `pg_dump -Fc` before `migrate` (last 2 kept). Payment events are exported to CSV on
247 every 6 hours; the app log is archived nightly on 249 and pulled hourly to 247, where it is kept forever.
Restore procedure, monitoring and what was tested: `docs/operations.md` «بکاپ و بازیابی». Restoring onto 249
has been done before.
pgBackRest on 216 archives WAL continuously (at most a minute unarchived) with a weekly full and a daily
diff backup, encrypted (`aes-256-cbc`); payment CSVs every 6 hours, encrypted with `age`.
`tadrisino-offsite-sync` copies both to `~/zareian-pgbackrest-216/` on 247 every minute with a write-only
key. 247 holds only ciphertext; the keys are in `/etc/tadrisino-backup/RECOVERY-KEYS` on 216, and the owner
keeps a copy outside the servers (since 2026-10-18); without them 247's copy is unreadable.
`deploy.sh` also takes a `pg_dump -Fc` before `migrate` (last 2 kept). The app log is archived nightly on
216 but **not yet shipped to 247**. `sudo -u postgres tadrisino-restore-check` restores into a temp dir and
compares counts (run after the cut-over: equal). Details: `docs/operations.md` «بکاپ و بازیابی».
### Content
@@ -369,6 +411,10 @@ has been done before.
- `POST /api/ai/ask` is RAG: Qdrant retrieval filtered by book and chapter → GapGPT generation
(`OPENAI_BASE_URL`, model `gpt-4.1-mini`, prompt `tutor-v12-age-tone`). `usedChunks` names the book chunks
used. Without `OPENAI_API_KEY`/`QDRANT_API_KEY` and `DEBUG=0` it answers 503, never a stub.
- **Broken since the cut-over (2026-10-18):** `qdrant.tadrisino2.ir` (behind Arvan, origin server not ours
and unknown to us) now answers a Django `400 Bad Request` page, not Qdrant, from 216 and from the laptop;
every retrieval fails and the student sees «سرویس در دسترس نیست». Its Arvan origin has to be put back by
the senior; nothing in our code or `.env` changed.
- Answers are plain text, step by step, math-only (`rag/services/guardrails.py`; self-harm messages get a
kind reply with helpline numbers), tone by age, with an optional `<diagram>` JSON block that the frontend
draws (`rag/services/diagram.py`, frontend `src/diagram/`). Solutions marked `disagrees` never reach the

View File

@@ -97,11 +97,11 @@ python manage.py spectacular --file schema.yaml --format openapi # خروجی
## استقرار
سرویس روی یک سرورِ مشترکِ شرکت اجرا می‌شود و مالِ این پروژه فقط پوشه‌ی `~/mathapp-v2` و سرویسِ
`zareian-mathapp` است. کلِ انتشار:
سرویس روی سرورِ اختصاصیِ `78.38.35.216` اجرا می‌شود؛ اپ با کاربرِ `mathapp` در `/home/mathapp/mathapp-v2` و سرویسِ
`zareian-mathapp`. کلِ انتشار (جزئیات در `docs/operations.md` «دیپلوی»):
```bash
ssh -t administrator@78.38.35.249 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
ssh -t zareian@78.38.35.216 'sudo -u mathapp bash -c "cd /home/mathapp/mathapp-v2 && ./deploy.sh"; sudo systemctl restart zareian-mathapp'
```
`deploy.sh` کد را با `origin/main` یکی می‌کند، وابستگی‌ها را نصب می‌کند، پیش از `migrate` از دیتابیس بکاپ می‌گیرد،

View File

@@ -14,6 +14,7 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟»
"""
import logging
import os
import time
from datetime import timedelta
from django.conf import settings
@@ -160,6 +161,8 @@ def _offsite_backup() -> dict:
"""
if connection.vendor != 'postgresql':
return {'state': 'not_postgres'}
if settings.OFFSITE_SYNC_STATUS_FILE:
return _pushed_backup()
slot = settings.OFFSITE_WAL_SLOT
if not slot:
return {'state': 'not_configured'}
@@ -193,6 +196,45 @@ def _slot_state(row) -> dict:
return {'state': state, 'lagBytes': lag}
def _pushed_backup() -> dict:
"""
بکاپِ رمزشده‌ی ۲۱۶: pgBackRest هر WAL را با archive_command آرشیو می‌کند (pg_stat_archiver) و
`tadrisino-offsite-sync` مخزن را هر دقیقه به ۲۴۷ می‌فرستد و زمانش را در OFFSITE_SYNC_STATUS_FILE می‌نویسد.
کهنگیِ آخرین آرشیو هشدار نیست: دیتابیسِ بی‌تغییر WALِ تازه‌ای ندارد که آرشیو شود.
"""
try:
with connection.cursor() as cursor:
cursor.execute('SELECT last_archived_time, last_failed_time FROM pg_stat_archiver')
last_archived, last_failed = cursor.fetchone()
except Exception as exc: # noqa: BLE001
return {'state': 'error', 'error': type(exc).__name__}
try:
with open(settings.OFFSITE_SYNC_STATUS_FILE) as f:
synced_at = int(f.read().strip())
except FileNotFoundError:
synced_at = None
except (OSError, ValueError) as exc:
return {'state': 'error', 'error': type(exc).__name__}
return _pushed_state(last_archived, last_failed, synced_at, time.time())
def _pushed_state(last_archived, last_failed, synced_at, now) -> dict:
age = None if synced_at is None else int(now - synced_at)
if last_failed is not None and (last_archived is None or last_failed > last_archived):
state = 'archive_failing'
elif age is None:
state = 'never_synced'
elif age > settings.OFFSITE_SYNC_MAX_AGE_SECONDS:
state = 'sync_stale'
else:
state = 'ok'
return {
'state': state,
'syncAgeSeconds': age,
'lastArchivedAt': last_archived.isoformat() if last_archived else None,
}
# اتصال‌هایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و
# migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests
# عوض می‌کند و تا بسته شدنِ قبلی هم‌زمان بالاست (یک pool).
@@ -267,6 +309,9 @@ _OFFSITE_MESSAGES = {
'lagging': 'سرورِ بکاپ از دیتابیس عقب است.',
'lost': 'سرورِ بکاپ آن‌قدر قطع بود که زنجیره‌ی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.',
'error': 'وضعیتِ کپیِ زنده‌ی دیتابیس خوانده نشد.',
'archive_failing': 'آرشیوِ WAL (pgBackRest) شکست می‌خورد و تغییراتِ تازه بکاپ نمی‌شوند: sudo -u postgres pgbackrest --stanza=zareian check',
'never_synced': 'بکاپِ رمزشده هنوز هیچ‌وقت به سرورِ بیرونی (۲۴۷) نرسیده (فایلِ OFFSITE_SYNC_STATUS_FILE نیست).',
'sync_stale': 'ارسالِ بکاپِ رمزشده به ۲۴۷ عقب افتاده: journalctl -u tadrisino-offsite-sync',
}

View File

@@ -1078,6 +1078,12 @@ LOG_DIR = os.environ.get('LOG_DIR', '')
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
# بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است.
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
# سرورِ اختصاصی (۲۱۶): بکاپِ رمزشده‌ی pgBackRest؛ `tadrisino-offsite-sync` هر دقیقه آن را به ۲۴۷ می‌فرستد و زمانِ
# آخرین ارسالِ موفق (epoch) را در این فایل می‌نویسد (docs/operations.md «سرورِ اختصاصیِ جدید»). ست باشد، بر
# OFFSITE_WAL_SLOT مقدم است. OFFSITE_WAL_SLOT را فقط ۲۴۹ دارد و با سوئیچ به ۲۱۶ همراهِ _slot_state حذف می‌شود.
OFFSITE_SYNC_STATUS_FILE = os.environ.get('OFFSITE_SYNC_STATUS_FILE', '')
# ارسال هر دقیقه است؛ ۱۰ دقیقه بی‌ارسال یعنی چند اجرای پشتِ سرِ هم شکست خورده.
OFFSITE_SYNC_MAX_AGE_SECONDS = 600
# چرخشِ فایلِ لاگ: 'size' (پیش‌فرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمی‌ها پاک می‌شوند؛
# 'external' (سرور) = برنامه هرگز پاک نمی‌کند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابه‌جا و

View File

@@ -3,6 +3,8 @@
پرسشِ واقعی از pg_replication_slots این‌جا اجرا نمی‌شود (تست‌ها روی SQLite‌اند)؛ روی سرور با اسلاتِ
واقعی دیده شد (docs/operations.md «بکاپ»).
"""
from datetime import datetime, timedelta, timezone
from django.test import SimpleTestCase, override_settings
from config import health
@@ -27,13 +29,45 @@ class SlotStateTests(SimpleTestCase):
self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok')
@override_settings(OFFSITE_SYNC_MAX_AGE_SECONDS=600)
class PushedStateTests(SimpleTestCase):
"""بکاپِ رمزشده‌ی ۲۱۶ (_pushed_state). پرسشِ pg_stat_archiver و خواندنِ فایل روی سرور دیده شد، نه این‌جا."""
T0 = datetime(2026, 10, 17, 16, 0, tzinfo=timezone.utc)
def test_recent_sync_is_ok(self):
r = health._pushed_state(self.T0, None, 1000, now=1060)
self.assertEqual((r['state'], r['syncAgeSeconds']), ('ok', 60))
self.assertEqual(r['lastArchivedAt'], self.T0.isoformat())
def test_old_sync_is_stale(self):
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1601)['state'], 'sync_stale')
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1600)['state'], 'ok')
def test_no_status_file_is_never_synced(self):
self.assertEqual(health._pushed_state(self.T0, None, None, now=1000)['state'], 'never_synced')
def test_failure_after_last_success_wins(self):
later = self.T0 + timedelta(minutes=1)
self.assertEqual(health._pushed_state(self.T0, later, 1000, now=1001)['state'], 'archive_failing')
self.assertEqual(health._pushed_state(None, later, 1000, now=1001)['state'], 'archive_failing')
def test_old_failure_then_success_is_ok(self):
earlier = self.T0 - timedelta(minutes=1)
self.assertEqual(health._pushed_state(self.T0, earlier, 1000, now=1001)['state'], 'ok')
def test_idle_database_with_no_archive_yet_is_ok(self):
self.assertEqual(health._pushed_state(None, None, 1000, now=1001)['state'], 'ok')
class OffsiteWarningTests(SimpleTestCase):
def test_ok_and_dev_are_silent(self):
self.assertEqual(health._offsite_warnings({'state': 'ok', 'lagBytes': 0}), [])
self.assertEqual(health._offsite_warnings({'state': 'not_postgres'}), [])
def test_every_bad_state_warns(self):
for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error'):
for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error',
'archive_failing', 'never_synced', 'sync_stale'):
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
def test_sqlite_reports_not_postgres(self):

View File

@@ -6,10 +6,13 @@
## دیپلوی
```bash
ssh -t administrator@78.38.35.249 \
'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
ssh -t zareian@78.38.35.216 \
'sudo -u mathapp bash -c "cd /home/mathapp/mathapp-v2 && ./deploy.sh"; sudo systemctl restart zareian-mathapp'
```
`deploy.sh` با کاربرِ `mathapp` اجرا می‌شود که sudo ندارد، پس ری‌استارتِ خودش شکست می‌خورد و می‌گوید؛ ری‌استارت
همان دستورِ دوم است. فرانت: `sudo -u mathapp bash -c "cd /home/mathapp/mathapp_frontend_new && ./deploy.sh"`.
دو بار رمز می‌پرسد — یک‌بار برای `ssh` و یک‌بار برای `sudo`. **منتظرِ هر پرامپت
بمانید**؛ چسباندنِ چند خط با هم، متنِ صف‌شده را به پرامپتِ رمز می‌دهد و بعد از
سه تلاش قفل می‌شوید.
@@ -40,7 +43,7 @@ poll کردنِ `/api/health`.
می‌شود، چون از داخلِ سرور آدرسِ عمومی از مسیرِ ArvanCloud برمی‌گردد و ۵۰۴/۰۰۰ می‌داد (۲۰۲۶-۱۰-۰۷).
- **اگر سرور به Gitea نمی‌رسد** (از سرور ۵۰۴، از لپ‌تاپ ۲۰۰): روی لپ‌تاپ
`git bundle create x.bundle <هشِ origin/main روی سرور>..main`، `scp` به
`~/mathapp-v2/.deploy-backups/`، و روی سرور
`/home/zareian/stage/` و `sudo install -o mathapp -m 600` به `/home/mathapp/mathapp-v2/.deploy-backups/`، و روی سرور (با `sudo -u mathapp`)
`DEPLOY_BUNDLE=.deploy-backups/x.bundle ./deploy.sh`. هشِ `origin/main` که چاپ می‌شود را با لپ‌تاپ
مقایسه کنید. بدونِ این متغیر جایگزینی نیست و نرسیدن به Gitea دیپلو را می‌ایستاند.
@@ -72,102 +75,75 @@ curl -i https://mathapi.tadrisino2.ir/api/health
## بکاپ و بازیابی
دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`).
دیتابیس Postgres 16 روی ۲۱۶ است (cluster `16/zareian`، پورت ۵۴۴۰، فقط localhost). **همه‌ی بکاپ‌ها روی خودِ ۲۱۶ رمز
می‌شوند و ۲۴۷ فقط فایلِ رمزشده می‌بیند**، چون ۲۴۷ مشترک است و هر کس `administrator` را دارد root است.
| لایه | چه چیزی | از دست رفتنِ داده در بدترین حالت | کجا |
|---|---|---|---|
| کپیِ زنده (اصلی) | هر تغییرِ دیتابیس لحظه‌ای به سرورِ دیگر می‌رود + یک نسخه‌ی کامل در روز؛ بازیابی تا هر لحظه از ۷ روزِ اخیر | چند ثانیه‌ی آخر | `administrator@78.38.35.247:~/zareian-pgbackup/` |
| پیش از migrate | `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمی‌شود)؛ برای برگشتن از migrationِ خراب | — | `~/mathapp-v2/.deploy-backups/db-before-deploy-*.pgdump` (۲ تای آخر) |
| ایمیجِ کلِ ماشین | کارِ ادمینِ سرور، روزانه؛ ما محتوایش را نسنجیده‌ایم | — | دستِ ادمین |
| pgBackRest (اصلی) | آرشیوِ پیوسته‌ی WAL، نسخه‌ی کامل یکشنبه‌ها ۰۳:۰۰، diff هر روز ۰۳:۴۰؛ دو نسخه‌ی کامل می‌ماند؛ `aes-256-cbc`؛ بازیابی تا هر لحظه | حدودِ ۱ دقیقه (`archive_timeout=60`) و تا ۱ دقیقه‌ی دیگر تا رسیدن به ۲۴۷ | `/var/lib/pgbackrest` روی ۲۱۶ و کپی در `~/zareian-pgbackrest-216/repo/` روی ۲۴۷ |
| پیش از migrate | `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمی‌شود)؛ برای برگشتن از migrationِ خراب | — | `/home/mathapp/mathapp-v2/.deploy-backups/db-before-deploy-*.pgdump` (۲ تای آخر) |
| ایمیجِ کلِ ماشین | کارِ ادمینِ سرور؛ ما محتوایش را نسنجیده‌ایم | — | دستِ ادمین |
| آرشیوِ دوره‌ی ۲۴۹ | ۷ نسخه‌ی کاملِ ۱۲ تا ۱۸ اکتبر، WAL، CSVهای پرداخت و لاگِ اپ از وقتی اپ روی ۲۴۹ بود؛ همه با age برای کلیدِ ۲۱۶ رمز شده‌اند | فقط برای مراجعه | `~/zareian-pgbackup/` روی ۲۴۷ (پایینِ همین بخش) |
بکاپِ شبانه‌ی جداگانه (`backup_db` با cron) حذف شد: کپیِ زنده همان کار را بهتر می‌کند و ایمیجِ روزانه را ادمین دارد.
`tadrisino-offsite-sync` هر دقیقه `/var/lib/pgbackrest` و `/var/lib/tadrisino-backup/payment-log` را با `rsync --delete` به
۲۴۷ می‌فرستد. کلیدِ postgres در `authorized_keys`ِ ۲۴۷ با `from=192.168.200.163,restrict,command="rrsync -wo …"` فقط
می‌تواند در همان پوشه بنویسد (نه بخواند، نه شِل). `/api/health` → `offsiteBackup` از `pg_stat_archiver` و
`OFFSITE_SYNC_STATUS_FILE` می‌گوید آرشیو و sync چند ثانیه عقب‌اند.
### دفترِ خرید (`PaymentEvent`) روی هر دو سرور
### کلیدهای بازیابی
`/etc/tadrisino-backup/RECOVERY-KEYS` روی ۲۱۶ (root، 600): کلیدِ pgBackRest و کلیدِ خصوصیِ age
(`age-identity.txt` کنارش؛ `age-recipient.txt` کلیدِ عمومی است و رمز کردن با آن بی‌خطر است). **بی‌این‌ها هیچ
بکاپی روی ۲۴۷ خواندنی نیست**؛ یک نسخه بیرون از سرورها نزدِ صاحبِ محصول است (از ۲۰۲۶-۱۰-۱۸). اگر کلیدها عوض شدند، نسخه‌ی تازه را
او در ترمینالِ خودش برمی‌دارد: `ssh -t zareian@78.38.35.216 sudo cat /etc/tadrisino-backup/RECOVERY-KEYS` (نه در چت).
### دفترِ خرید (`PaymentEvent`)
هر قدمِ مسیرِ خرید یک ردیف در جدولِ `payment_paymentevent` می‌نویسد (`payment/events.py`): `order_created`،
`discount_applied`، `gateway_redirect`، `gateway_request_failed`، `callback_received`، `callback_unknown_authority`،
`payment_result` (همه‌ی مسیرها: کال‌بک، بازسنجی، بازار، تمدیدِ بازار، کدِ ۱۰۰٪؛ با `ignored` برای تکراری/جعلی)،
`bazaar_start`، `admin_cancel`، `admin_refund`. توکنِ خریدِ بازار در آن نمی‌رود. ادمین: «Payment events» (فقط‌خواندنی).
چون جدول در همان Postgres است با WAL لحظه‌ای به ۲۴۷ می‌رسد. روی ۲۴۷، `zareian-pg-export-events.timer`
(۰۱:۱۵، ۰۷:۱۵، ۱۳:۱۵، ۱۹:۱۵) با `bin/export-payment-events.sh` از روی همان بکاپ (نمونه‌ی موقت روی سوکتِ داخلِ پوشه،
بی‌اتصال به ۲۴۹ یا Postgresِ دیگران) سه CSVِ تجمعی در `~/zareian-pgbackup/payment-log/` می‌نویسد:
`payment-events-*`، `orders-*`، `transactions-*`؛ ۱۴ تای آخرِ هر نوع می‌ماند. شکستش در
`journalctl -u zareian-pg-export-events`ِ ۲۴۷. زیرِ ۳ گیگ فضای آزاد اجرا نمی‌شود.
جدا از بکاپِ دیتابیس، `tadrisino-export-payment-events.timer` (۰۱:۱۵، ۰۷:۱۵، ۱۳:۱۵، ۱۹:۱۵) سه CSVِ تجمعی
(`payment-events-*`، `orders-*`، `transactions-*`) را مستقیم با age رمز می‌کند و در `/var/lib/tadrisino-backup/payment-log/`
می‌گذارد؛ ۱۴ تای آخرِ هر نوع می‌ماند و با sync به ۲۴۷ می‌رود. متنِ خوانا هیچ‌جا ذخیره نمی‌شود. باز کردن:
`age -d -i age-identity.txt payment-events-….csv.age`.
### لاگِ برنامه (`app.log`): برای همیشه روی ۲۴۷
### لاگِ برنامه (`app.log`)
با `LOG_FILE_ROTATION=external` در `.env`ِ ۲۴۹ برنامه لاگ را پاک نمی‌کند (`WatchedFileHandler`). هر شب ۰۰:۰۵
`zareian-applog-archive.timer` (۲۴۹) با `ops/applogs/archive-app-log.sh` فایل را به `logs/archive/app-<UTC>.log.gz`
می‌برد و فشرده می‌کند؛ هر ساعت `zareian-applog-pull.timer` (۲۴۷) با `bin/pull-app-logs.sh` فایل‌های تازه را به
`~/zareian-pgbackup/app-logs/` می‌آورد (`gzip -t` پیش از نگه‌داشتن) و **هرگز پاک نمی‌کند** (تصمیمِ مالک ۲۰۲۶-۱۰-۱۳).
روی ۲۴۹ فقط ۳۰ روز می‌ماند و فقط فایلی پاک می‌شود که ۲۴۷ گرفتنش را تأیید کرده (`.shipped`).
دسترسی: کلیدِ `~/.ssh/zareian_applogs`ِ ۲۴۷ در `authorized_keys`ِ ۲۴۹ با
`restrict,command="…/ops/applogs/serve-archive.sh"` است: فقط `list`، `get NAME`، `ack NAME`؛ شِل و پورت ندارد
(سنجیده: `id` و `cat /etc/passwd` → `bad request`). بکاپِ `authorized_keys` پیش از افزودن کنارش است.
حجم (برآورد، نه سنجش): لاگِ JSON حدودِ ۱۵ برابر فشرده می‌شود (۷۰۰ک → ۴۰ک در اولین اجرا)؛ با ۶۰۰ کاربرِ فعال
حدودِ ۳۰ مگِ خام و ۲ تا ۳ مگِ فشرده در روز، یعنی حدودِ ۱ گیگ در سال روی ۲۴۷. اگر آرشیوِ شبانه بخوابد و
`app.log` از ۲۵۶ مگ بگذرد، `/api/health` هشدار می‌دهد. خواندن: `zcat app-*.log.gz | grep user_id=26`.
### کپیِ زنده چطور کار می‌کند
Postgres هر تغییر را اول در یک دفترِ پیوسته (WAL) می‌نویسد. سرورِ ۲۴۷ این دفتر را با ابزارِ خودِ Postgres
(`pg_receivewal`) لحظه‌ای می‌گیرد، نه کپیِ کلِ دیتابیس: فقط تغییرات می‌روند. روزی یک بار هم یک نسخه‌ی کامل
(`pg_basebackup`) گرفته می‌شود؛ ۷ نسخه‌ی آخر می‌مانند و WALِ قدیمی‌تر از قدیمی‌ترینشان پاک می‌شود، پس حجم
محدود است. بازیابی = آخرین نسخه‌ی کاملِ پیش از لحظه‌ی دلخواه + بازپخشِ WAL تا آن لحظه.
```
۲۴۹ (اپ) ۲۴۷ (بکاپ)
Postgres 5440 (localhost) ◄── تونلِ SSH ─── zareian-pg-tunnel (127.0.0.1:15440)
اسلاتِ zareian_247 zareian-pg-wal → wal/ (لحظه‌ای)
نقشِ zareian_replicator zareian-pg-basebackup.timer → base/ (هر روز ۰۴:۳۰، ۷ تا)
```
- **۲۴۹ را نمی‌خواباند:** اپ منتظرِ ۲۴۷ نمی‌ماند (تکثیرِ ناهمزمان). اگر ۲۴۷ قطع شود، اسلات تغییرات را نگه می‌دارد تا
وصل شود، ولی حداکثر ۱ گیگ (`max_slot_wal_keep_size`)؛ بیش از آن اسلات «lost» می‌شود و دیسکِ ۲۴۹ امن می‌ماند.
نسخه‌ی کامل با `--checkpoint=spread --max-rate=20M` گرفته می‌شود.
- **امنیت:** کلیدِ SSHِ ۲۴۷ در `authorized_keys`ِ ۲۴۹ با `restrict,port-forwarding,permitopen="127.0.0.1:5440"`
محدود است (نه شِل، نه پورتِ دیگر). رمزِ نقشِ `zareian_replicator` فقط در `~/.pgpass`ِ ۲۴۷ است. این نقش
همه‌ی داده را می‌خواند (طبیعتِ بکاپ)، پس `~/zareian-pgbackup` با مجوز ۷۰۰ است. ترافیک در شبکه‌ی داخلی است
(۲۴۹ = `192.168.200.164`، ۲۴۷ = `192.168.200.167`).
- **Postgresِ ۲۴۷ را به کار نمی‌گیریم:** سرویسِ `postgresql@14-main` آن‌جا مالِ دیگران است. ما فقط ابزارهای
Postgres 14 را اجرا می‌کنیم؛ آزمونِ بازیابی یک Postgresِ موقت روی سوکتِ داخلِ پوشه‌ی خودمان بالا می‌آورد.
- **پایش:** `/api/health` (با `X-Internal-Key`) زیرِ `offsiteBackup` وضعیت و عقب‌ماندگی را می‌دهد و اگر ۲۴۷
وصل نباشد، عقب باشد یا اسلات lost شده باشد هشدار می‌دهد (`OFFSITE_WAL_SLOT=zareian_247` در `.env`ِ ۲۴۹).
شکستِ نسخه‌ی کاملِ روزانه از ۲۴۹ دیده نمی‌شود: روی ۲۴۷ `systemctl status zareian-pg-basebackup` و
`journalctl -u zareian-pg-basebackup`. شکستش داده را از دست نمی‌دهد (WAL از آخرین نسخه‌ی سالم نگه داشته
می‌شود)، فقط بازیابی را کندتر می‌کند.
فایل‌ها در `ops/pgbackup/` ریپو. روی ۲۴۷: یونیت‌ها در `/etc/systemd/system/zareian-pg-*`، اسکریپت‌ها در
`~/zareian-pgbackup/bin/`. هر دو با دست کپی می‌شوند (`deploy.sh` فقط ۲۴۹ را می‌شناسد)؛ بعد از تغییرِ
یکی از آن‌ها: کپی، `sudo systemctl daemon-reload`، و `restart` همان یونیت.
با `LOG_FILE_ROTATION=external` برنامه لاگ را پاک نمی‌کند (`WatchedFileHandler`). هر شب ۰۰:۰۵
`zareian-applog-archive.timer` با `ops/applogs/archive-app-log.sh` فایل را به `logs/archive/app-<UTC>.log.gz` می‌برد و
فشرده می‌کند. **روی ۲۱۶ این آرشیو هنوز به ۲۴۷ نمی‌رود**؛ راهِ پیشنهادی: رمز کردن با age و افزودنِ `logs/archive` به
`tadrisino-offsite-sync` (همان rrsync، پوشه‌ی `app-logs/`). حجم (برآورد): با ۶۰۰ کاربرِ فعال ۲ تا ۳ مگِ فشرده در روز.
اگر آرشیوِ شبانه بخوابد و `app.log` از ۲۵۶ مگ بگذرد، `/api/health` هشدار می‌دهد. خواندن: `zcat app-*.log.gz | grep user_id=26`.
### آزمونِ بازیابی (ماهی یک بار)
روی ۲۴۷: `~/zareian-pgbackup/bin/restore-check.sh` (یا با یک لحظه: `… '2026-10-10 16:30:00+03:30'`).
آخرین نسخه‌ی کامل + همه‌ی WAL را در پوشه‌ی موقت بالا می‌آورد، شمارش و آخرین زمانِ کاربر/ورود/سفارش/تراکنش/
تغییرِ وضعیت را چاپ می‌کند و پاک می‌کند. همان پرسش را روی ۲۴۹ بزنید؛ باید یکی باشند (جز تغییرِ همان چند ثانیه).
### اگر ۲۴۷ مدتی قطع بود
`/api/health` می‌گوید `disconnected`. با برگشتنِ ۲۴۷ سرویس‌ها خودشان وصل می‌شوند و عقب‌ماندگی را می‌گیرند. اگر
`lost` شد (بیش از ۱ گیگ تغییر در قطعی): روی ۲۴۷
`systemctl stop zareian-pg-wal`، روی ۲۴۹ اسلات را بیندازید
(`SELECT pg_drop_replication_slot('zareian_247')`)، روی ۲۴۷ بسازید
(`pg_receivewal -h 127.0.0.1 -p 15440 -U zareian_replicator --create-slot --slot=zareian_247`)،
`systemctl start zareian-pg-wal` و `systemctl start zareian-pg-basebackup` (نسخه‌ی کاملِ تازه؛ WALِ پیش از آن
بریده است).
`sudo -u postgres tadrisino-restore-check` روی ۲۱۶: آخرین بکاپ را در پوشه‌ی موقت بالا می‌آورد و شمارشِ
کاربر/سؤال/سفارش/رویدادِ پرداخت/ورود را با دیتابیسِ زنده مقایسه می‌کند. آخرین اجرا: بعد از سوئیچ (۲۰۲۶-۱۰-۱۸)، همه برابر.
### بازیابیِ واقعی
- **دیتابیسِ ۲۴۹ خراب شد یا داده‌ای اشتباهی پاک شد:** لحظه‌ی درست را پیدا کنید، روی ۲۴۷ با `restore-check.sh`
تا آن لحظه آزمایش کنید؛ وقتی درست بود، با `KEEP=1` اجرا کنید تا Postgresِ موقت بماند، از آن با
`pg_dump -Fc -h <پوشه> -U postgres zareian_mathapp` خروجی بگیرید و روی ۲۴۹ با `pg_restore --clean --no-owner -d zareian_mathapp` برگردد (اپ را پیش از آن stop کنید و از
وضعیتِ فعلی هم یک `pg_dump` بگیرید). این مسیر روی سرور آزموده نشده؛ با کمک بروید.
- **کلِ ۲۴۹ رفت:** همان بازیابی تا انتهای WAL روی ماشینِ تازه، و سفارش‌های چند ثانیه‌ی آخر را با پنلِ زرین‌پال/بازار
تطبیق دهید.
- **داده‌ای اشتباهی پاک شد یا دیتابیس خراب شد:** اپ را stop، از وضعیتِ فعلی یک `pg_dump` بگیرید، و
`pgbackrest --stanza=zareian --type=time --target='<لحظه>' restore` (با `sudo -u postgres`). این مسیر با زمانِ دلخواه
روی سرور آزموده نشده؛ با کمک بروید.
- **کلِ ۲۱۶ از دست رفت:** روی ماشینِ تازه پوشه‌ی `~/zareian-pgbackrest-216/repo/` را از ۲۴۷ از شبکه‌ی داخلی کپی کنید،
`pgbackrest.conf` را از `ops/server216/` با کلید از RECOVERY-KEYS بسازید و `pgbackrest --stanza=zareian restore`.
بازیابیِ کامل از نسخه‌ی رمزشده‌ی ۲۴۷ یک بار آزموده شده (دیتابیس و CSV). سفارش‌های دقیقه‌ی آخر را با پنلِ
زرین‌پال/بازار تطبیق دهید.
### آرشیوِ دوره‌ی ۲۴۹ روی ۲۴۷
`~/zareian-pgbackup/` روی ۲۴۷ بکاپ‌های زمانی است که اپ روی ۲۴۹ بود (کپیِ زنده با `pg_receivewal` تا ۲۰۲۶-۱۰-۱۸). بعد
از سوئیچ هر فایل با age برای همان کلیدِ ۲۱۶ رمز و نسخه‌ی خوانا `shred` شد؛ نام‌ها و تاریخ‌ها همان است:
`base/<تاریخ>.tar.age` (هر کدام یک `pg_basebackup` با قالبِ plain)، `wal/*.age`، `payment-log/*.csv.age`،
`app-logs/*.log.gz.age`، و فهرستِ چک‌سام‌ها `bin/manifest.txt.age`. یونیت‌های `zareian-pg-*` و `zareian-applog-pull` روی
۲۴۷ disable شده‌اند و اسکریپت‌های `bin/` دیگر کاری نمی‌کنند.
خواندن فقط روی ۲۱۶ (که کلید را دارد) و از شبکه‌ی داخلی، نه از لپ‌تاپ:
`sshpass -f /home/zareian/.pw247 ssh administrator@192.168.200.167 'cat ~/zareian-pgbackup/base/<تاریخ>.tar.age' | sudo age -d -i /etc/tadrisino-backup/age-identity.txt | tar -x -C <پوشه>`.
درستیِ رمزنگاری با باز کردنِ دو نسخه‌ی کامل و سه فایلِ نمونه روی ۲۱۶ سنجیده شد. پاک کردنِ این پوشه تصمیمِ
صاحبِ محصول است.
## متغیرهای محیطی
@@ -332,37 +308,63 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
## سرور
`mathapi.tadrisino2.ir` روی یک **ماشینِ مشترکِ شرکت** اجرا می‌شود (`administrator@78.38.35.249`)، نه سروری که مالِ این
پروژه باشد. روی همان ماشین Gitea، ایمیل، DNS، چند دیتابیس و اپ‌های تیم‌های دیگر در حالِ کارند، و حسابِ ما `sudo`ِ
کامل دارد؛ تنها مانع میانِ یک فرمانِ بی‌دقت و داده‌ی کلِ شرکت، خودِ اجراکننده است. سرورِ بکاپ (`78.38.35.247`) هم
مشترک است.
`math.tadrisino2.ir` و `mathapi.tadrisino2.ir` از **۲۰۲۶-۱۰-۱۸** روی ماشینِ مجازیِ اختصاصیِ `78.38.35.216` اجرا می‌شوند
(ArvanCloud → 216:443). کلِ ماشین مالِ ماست. قواعدِ کار روی سرورها، با فهرستِ آنچه روی ۲۴۹ و ۲۴۷ مالِ ما نیست،
در `CLAUDE.md` «Working on the servers» است.
مالِ ما روی ۲۴۹: پوشه‌های `~/mathapp-v2` (بک‌اند) و `~/mathapp_frontend_new` (فرانت)، سرویسِ `gunicorn-mathapp` با
نامِ دومِ **`zareian-mathapp`** (همیشه همین نام را بنویسید)، `zareian-redis`، clusterِ Postgresِ `14/zareian`، تایمرِ
`zareian-applog-archive`، و دو سایت و دو snippetِ nginx (بخشِ بعد). روی ۲۴۷ فقط `~/zareian-pgbackup/` و یونیت‌های
`zareian-*`.
| | |
|---|---|
| ماشین | VMware، Ubuntu 24.04، ۸ هسته (Xeon E5-2690 v3)، ۱۲ گیگ رم، دیسکِ ۹۷ گیگ؛ hostname `mathapp`، IPِ داخلی `192.168.200.163`، IPِ خروجی `78.38.35.228` |
| ورود | دو حساب با sudo: `zareian` (صاحبِ محصول) و `mathapp-admin` (مشترکِ سینیور و صاحبِ شرکت)؛ هر دو با رمزِ خطِ `AdminUser-Password` فایلِ رمز. ورودِ root بسته، `AllowUsers zareian mathapp-admin`، forwarding بسته (`10-tadrisino.conf`)، `LoginGraceTime 2m` |
| اپ | کاربرِ بی‌sudoِ `mathapp`؛ `/home/mathapp/mathapp-v2` و `/home/mathapp/mathapp_frontend_new` |
| سرویس‌ها | `zareian-mathapp` (= `gunicorn-mathapp`، با `RuntimeDirectory`)، `zareian-redis` (6390)، `postgresql@16-zareian` (5440)، `nginx`، تایمرهای `tadrisino-*` و `zareian-applog-archive` |
| فایل‌ها | نسخه‌ی اصلیِ همه‌ی اسکریپت‌ها و تنظیم‌های این سرور در `ops/server216/` (بی‌کلید). تغییر: فایلِ ریپو، بعد کپی روی سرور |
| رمزها | رمزِ Postgres و Redis مالِ همین سرور است (`.pg_password`، `.redis_password` کنارِ `.env`) |
| فایروال | ufw فقط 22/80/443؛ فایروالِ شرکت هر سه را باز کرده |
| ضدِ ربات | ۱) `tadrisino-blocklist.timer`: هر روز FireHOL level1 (Spamhaus، DShield، …) در ipsetِ `tadrisino-block`، همه‌ی پورت‌ها؛ شبکه‌ی داخلی، `78.38.35.0/24` و رنج‌های آروان هرگز در آن نیستند. ۲) `ufw limit 22`: بیش از ۶ اتصال در ۳۰ ثانیه رد. ۳) fail2ban پله‌ای: ۶ اشتباه در ۱۰ دقیقه → ۱۰ دقیقه، هر تکرار دو برابر تا یک هفته؛ `recidive`: ۵ بار در روز → یک هفته همه‌ی پورت‌ها؛ `192.168.200.0/24` هرگز. آزاد کردن: `sudo fail2ban-client set sshd unbanip <IP>` |
| بسته‌ها | apt از `mirror.arvancloud.ir`، pip از `mirror-pypi.runflare.com` (`~mathapp/.config/pip/pip.conf`)، npm از `mirror-npm.runflare.com`؛ `files.pythonhosted.org` و `registry.npmjs.org` از این سرور بسته‌اند |
| شبکه‌ی داخلی | ۲۴۹ = `192.168.200.164`، ۲۴۷ = `.167`. انتقالِ فایلِ بزرگ بینِ سرورها از همین مسیر (اینترنتِ لپ‌تاپ کند است) |
```bash
sudo systemctl restart zareian-mathapp # مالِ ما
sudo systemctl restart gunicorn # مالِ ما نیست — اپِ تیمِ دیگر
sudo systemctl restart zareian-mathapp # اپ
sudo nginx -t && sudo systemctl reload nginx
```
قواعدی که قابلِ مذاکره نیستند (نسخه‌ی کامل، با فهرستِ آنچه مالِ ما نیست و رویه‌ی دیپلو، در `CLAUDE.md` «Working on
the servers»):
**سنجیده بعد از سوئیچ (۲۰۲۶-۱۰-۱۸):** ۴۴۳ از بیرون باز است و درخواستِ مستقیم به IP ۴۰۳ می‌گیرد؛ درخواست‌های هر دو دامنه
از مسیرِ آروان به ۲۱۶ می‌رسند (در `access.log` با IPِ آروان)؛ `/api/health` با جزئیات از مسیرِ آروان ok و بی‌هشدار؛
دیتابیسِ ۲۴۹ با همان شمارش‌ها منتقل شد؛ بکاپِ کاملِ تازه و آزمونِ بازیابی برابر. صاحبِ محصول از گوشی وارد شد و صفحه‌ی
زرین‌پال باز شد. **نسنجیده:** پرداختِ کامل، خریدِ بازار، اپِ اندروید روی سرورِ تازه.
1. **اول فقط نگاه کنید**، بعد تغییر بدهید؛ هیچ فرمانِ مخربی بیرون از پوشه‌های ما، و مسیرها را کامل بنویسید.
2. **`git clean` هرگز، و پروژه هرگز پاک و «از نو clone» نشود.** `.env`، `.django_secret_key`، لاگ‌ها و بکاپ‌ها در گیت
نیستند و همان‌جا زندگی می‌کنند؛ راهِ برگشت `git reset --hard origin/main` است.
3. **فقط `zareian-mathapp` ری‌استارت شود**؛ nginx مشترک است: `nginx -t` و بعد `reload`، هرگز `restart`.
4. **پیش از ویرایشِ هر چیز زیرِ `/etc` یا `.env` بکاپِ تاریخ‌دار** در `~/mathapp-v2/.deploy-backups/`.
5. **`.env` فقط از راهِ Django خوانده شود**؛ `cat .env` نه، و مقادیرش در هیچ چتی paste نشود.
6. **`df -h /` پیش از هر کارِ پرنوشت.** پر شدنِ دیسک ایمیل و Gitea را هم پایین می‌کشد.
7. **از منابعِ تیم‌های دیگر استفاده نشود** (Redis، Postgres، صف، سایتِ nginx)؛ نیاز به آن‌ها درخواست از ادمینِ سرور است.
### ۲۴۹ (کنار گذاشته، هنوز پاک نشده)
از ۲۰۲۶-۱۰-۱۸ اپ روی ۲۴۹ stop و disable است، اسلاتِ `zareian_247` برداشته شده و یونیت‌های بکاپِ آن روی ۲۴۷ خاموش‌اند.
پوشه‌ها، cluster ِ `14/zareian` (با داده‌ی خوانای لحظه‌ی سوئیچ)، `zareian-redis` و سایت‌های nginxِ ما آن‌جا مانده‌اند.
- **برگشت** (تا وقتی ۲۱۶ نوشتنِ واقعی مثلِ پرداخت و ثبت‌نام نگرفته): سینیور originِ آروان را به ۲۴۹ برمی‌گرداند و روی ۲۴۹
`sudo systemctl enable --now gunicorn-mathapp`. بعد از اولین نوشتنِ واقعی، اول dumpِ ۲۱۶ به ۲۴۹.
- **پاک کردن** (حدودِ ۲۰۲۶-۱۰-۲۵، با تأییدِ صاحبِ محصول): cluster و پوشه‌های ما روی ۲۴۹، و بعد حذفِ
`_slot_state`/`OFFSITE_WAL_SLOT` از `config/health.py` و `settings.py`.
### چه چیزی منتظرِ دیگران است
- **سینیور:** برگرداندنِ originِ `qdrant.tadrisino2.ir` در آروان (بخشِ Qdrant)؛ private کردنِ مخزن در Gitea؛ ۱۲ هسته و رمِ
بیشتر (اختیاری؛ سرور ریبوت را تحمل می‌کند، تست شده).
- **صاحبِ محصول:** آزمونِ اپِ اندروید و `pay.html` روی سرورِ تازه.
### کارهای باقی‌مانده
- توکنِ فقط‌خواندنیِ Gitea برای `deploy.sh` (بعد از private شدنِ مخزن `git fetch` بی‌رمز کار نمی‌کند).
- `CORS_ALLOWED_ORIGINS` در `.env`ِ سرور هنوز `localhost:5174` دارد؛ حذفش.
- رمز کردن و فرستادنِ آرشیوِ لاگِ اپ به ۲۴۷ (بخشِ «لاگِ برنامه»).
- ورود با کلیدِ SSH برای `zareian` (صاحبِ محصول خواسته فعلاً بماند)، بعد `PasswordAuthentication no` با
`Match Address 192.168.200.0/24` برای ورودِ با رمز از شبکه‌ی داخلی.
- مرورِ کاملِ یک فهرستِ امنیتیِ استاندارد (OWASP Top 10 / API Top 10، CIS Benchmark، همراهِ
`docs/security-before-production.md`)، بعد لودتست روی ۲۱۶ (`tools/loadtest/README.md`).
## nginx (دو سایتِ ما)
نسخه‌ی اصلی در ریپو است: `ops/nginx/`. روی ۲۴۹: `sites-enabled/mathapp` (API، فایلِ معمولی)،
`sites-available/mathapp-front` (فرانت)، و دو snippet در `/etc/nginx/snippets/`:
نسخه‌ی اصلی در ریپو است: `ops/nginx/`. روی ۲۱۶: `sites-enabled/mathapp` (API، فایلِ معمولی)،
`sites-enabled/mathapp-front` (فرانت)، و دو snippet در `/etc/nginx/snippets/`:
| فایل | چه می‌کند |
|---|---|
@@ -370,8 +372,8 @@ the servers»):
| `zareian-front-headers.conf` | CSP، `X-Frame-Options`، `nosniff`، `Referrer-Policy`، `Permissions-Policy`، HSTS برای فرانت |
| `mathapp` | سقفِ بدنه ۲۰ مگابایت، HSTS |
تغییر: فایلِ ریپو را ویرایش و کامیت کنید؛ روی سرور فایلِ فعلی را در `.deploy-backups/` کپی کنید، `sudo install -m 644`،
`sudo nginx -t`، و فقط اگر موفق بود `sudo systemctl reload nginx` (نه restart: سایت‌های دیگران روی همین nginx‌اند).
تغییر: فایلِ ریپو را ویرایش و کامیت کنید؛ روی سرور فایلِ فعلی را در `/home/mathapp/mathapp-v2/.deploy-backups/` کپی کنید، `sudo install -m 644`،
`sudo nginx -t`، و فقط اگر موفق بود `sudo systemctl reload nginx` (reload درخواست‌های در جریان را نمی‌بُرد).
بعد از بیرون: هدرها با `curl -sI`، و درخواستِ مستقیم به IP باید ۴۰۳ بدهد.
**وقتی دیگران به این‌ها بخورند:**
@@ -382,6 +384,37 @@ the servers»):
- سنجیده (۲۰۲۶-۱۰-۱۳): اپ در Chrome، بی‌ورود و با ورود، همه‌ی تب‌ها و آزمونِ یک جزیره، بی هیچ خطای CSP. **نسنجیده:** اپِ اندروید
(WebView همان CSP را می‌گیرد) و صفحه‌ی `pay.html` تا درگاه.
## Qdrant
**وضعِ فعلی Qdrant:** بردارهای RAG روی `https://qdrant.tadrisino2.ir` هستند (پشت ArvanCloud؛ سرورِ اصلی از بیرون پیدا
نیست و در فهرستِ سرورهای ما نیست. `rag_catalog` آن را سرورِ تیمِ دیگر می‌داند). نسخهٔ 1.17.0، ۳۴ collection، حدود
۱۰۸۸۰ نقطه، همه با بُعد ۱۵۳۶ و Cosine.
**هدف:** همهٔ کتاب‌های ریاضی‌ای که در Qdrant هست (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته؛ پایه‌های سوم تا دوازدهم) روی یک
Qdrant اختصاصی ببریم و اپ را به آن وصل کنیم، بدون ingest دوباره، چون ingest هزینهٔ GapGPT دارد.
**کدام collectionها (۱۵ تا، حدود ۵۶۸۰ نقطه):**
- ریاضی سوم، چهارم، پنجم، ششم، هفتم، هشتم، نهم، دهم.
- حسابان یازدهم، دوازدهم.
- هندسه دهم، یازدهم، دوازدهم.
- آمار و احتمال یازدهم.
- گسسته دوازدهم.
ریاضیِ سوم، ششم و هفتم را تیمِ دیگر ingest کرده، و صاحبِ محصول خواسته هر سه منتقل شوند. بقیهٔ collectionها (علوم، فارسی،
مطالعات اجتماعی، هدیهٔ آسمان، پیام‌های آسمان) در این برنامه نیستند.
**اصولِ انتقال:**
- از هر collection یک snapshot بگیرید، فایلِ آن را منتقل و روی Qdrant جدید بارگذاری کنید (بدون فراخوانیِ OpenAI).
- بعد از انتقال: تعدادِ نقاط، بُعد و Cosine یکی باشند؛ یک بردارِ ذخیره‌شده را به‌عنوانِ query بدهید و همان نقطه بالا بیاید.
- `/api/rag/ingest` هنوز روی Qdrant فعلی می‌نویسد. در بازهٔ کپی ingest متوقف شود، یا بعد از سوئیچ نقاطِ تازه دوباره همگام شوند.
- `QDRANT_URL` و `QDRANT_API_KEY` را فقط **بعد** از تأییدِ کپی عوض کنید؛ زودتر عوض شود، تیوتور جوابِ ناقص می‌دهد.
**خراب از سوئیچ (۲۰۲۶-۱۰-۱۸):** `qdrant.tadrisino2.ir` از مسیرِ آروان یک صفحه‌ی `400 Bad Request`ِ Django برمی‌گرداند، نه
Qdrant (هدرهای `referrer-policy: same-origin` و `cross-origin-opener-policy`؛ از ۲۱۶ و از لپ‌تاپ یکسان؛ درخواست به ۲۱۶ نمی‌رسد).
هر جستجوی دستیار شکست می‌خورد (`rag.retrieve: جستجوی Qdrant … شکست خورد` در `app.log`) و دانش‌آموز «سرویس در دسترس
نیست» می‌بیند. یعنی originِ این ساب‌دامین در آروان هم‌زمان با سوئیچ عوض شده؛ درستش با سینیور است. محلِ سرورِ اصلیِ
Qdrant را ما نمی‌دانیم. کد و `.env` عوض نشده‌اند. وارسی بعد از درست شدن: یک سوال از پی، و نبودنِ این خطا در `app.log`.
## لاگ‌ها
هر درخواستِ `/api/` به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌شود

View File

@@ -23,7 +23,7 @@
| ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد |
| ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد |
| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ شماره‌ی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
| ۱۵ | دسترسیِ سرور: ورود با رمز از اینترنت، رمز در فایلِ متنی | متوسط | صاحبِ محصول |
| ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت |
| ۲۴ | سقفِ روزانه‌ی پیامکِ کلِ سامانه (۲٬۰۰۰ روی سرور) را یک ناشناس پر می‌کند و ورود با پیامک برای همه بسته می‌شود | بالا (دسترس‌پذیری، هزینه) | تصمیمِ طراحی |
| ۲۵ | با دانستنِ شماره‌ی کسی، ورودِ او تا ۲۴ ساعت بسته می‌شود | متوسط | تصمیمِ طراحی |
@@ -62,15 +62,14 @@
## ۱۵. دسترسیِ سرور
- **چیست:** ورود با رمز به حسابِ مشترکِ `administrator` با `sudo (ALL : ALL) ALL` و عضوِ
`docker` و `lxd`، روی ماشینی که Gitea و ایمیلِ شرکت هم رویش است. رمز یک بار در گفتگو با
agent گذاشته شده و روی دسکتاپِ صاحبِ پروژه در یک فایلِ متنی نگه داشته می‌شود.
- **خطر:** هر نشتِ رمز یعنی دسترسیِ root به کلِ ماشینِ شرکت، نه فقط این پروژه.
۲۰۲۶-۱۰-۰۴: `PasswordAuthentication yes` در `sshd_config` و پورتِ ۲۲ از اینترنت باز است؛ پس رمز
از هر جای اینترنت حدس‌زدنی است (fail2ban سنجیده نشد).
- **رفع (ادمینِ سرور):** کلیدِ SSH به‌جای رمز، تعویضِ رمز، حسابِ جدا برای این پروژه با
دسترسیِ فقط به `~/mathapp-v2` و یک خطِ `NOPASSWD` فقط برای
`systemctl restart zareian-mathapp`؛ پاک کردنِ فایلِ رمز از دسکتاپ (Keychain جایگزین است).
- **چیست:** اپ روی سرورِ اختصاصیِ `78.38.35.216` است و فقط دو حساب با sudo دارد (`zareian` و `mathapp-admin`)؛ اپ با کاربرِ
بی‌sudoِ `mathapp` اجرا می‌شود و ورودِ root بسته است. ورود هنوز با رمز است و پورتِ ۲۲ از اینترنت باز (صاحبِ محصول از
بیرونِ شرکت هم وارد می‌شود)؛ رمزها روی دسکتاپِ صاحبِ پروژه در یک فایلِ متنی‌اند. ۲۴۹ و ۲۴۷ (مشترکِ شرکت، حسابِ
`administrator` با sudoِ کامل) دیگر اپ را اجرا نمی‌کنند، ولی تا پاک شدنِ cluster ِ ۲۴۹ داده‌ی خوانای لحظه‌ی سوئیچ آن‌جاست.
- **خطر:** نشتِ رمزِ `zareian` یعنی root روی سرورِ اپ. حدسِ رمز را FireHOL، `ufw limit` و fail2banِ پله‌ای کند می‌کنند
(روزِ اول ~۵٬۴۰۰ تلاشِ ورود دیده شد).
- **رفع:** کلیدِ SSH برای `zareian` و `PasswordAuthentication no` جز از شبکه‌ی داخلی (`docs/operations.md` «کارهای
باقی‌مانده»)؛ جای فایلِ رمز Keychain؛ پاک کردنِ cluster و پوشه‌های ما روی ۲۴۹ بعد از دوره‌ی برگشت.
## ۱۶. نگه‌داریِ توکن در فرانت
@@ -156,7 +155,7 @@
| قلبِ صفر جوابِ سوال را می‌بندد؛ چکش داخلِ تراکنش است (بی‌جواب‌ماندن و جزیره‌ی قفل جداگانه در ردیفِ بعدی بسته شده‌اند) | `content/tests_hearts_gate.py` |
| `select_for_update` مؤثر (قفلِ ردیفِ Postgres؛ روی SQLiteِ محلی `transaction_mode: IMMEDIATE`)؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
| `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` می‌گیرد (`pg_dump -Fc` روی Postgres) و اگر نشد migrate را اجرا نمی‌کند | مرحله‌ی «بکاپِ دیتابیس» در خروجیِ هر دیپلو |
| دیتابیس Postgresِ اختصاصی است و هر تغییرش لحظه‌ای روی سرورِ دیگری (۲۴۷) کپی می‌شود، با نسخه‌ی کاملِ روزانه و بازیابی تا هر لحظه از ۷ روزِ اخیر؛ قطعیِ ۲۴۷ اپ را نمی‌خواباند و بیش از ۱ گیگ روی دیسکِ ۲۴۹ جمع نمی‌شود (`docs/operations.md` «بکاپ و بازیابی») | `config/tests_offsite_backup.py` برای هشدارِ `/api/health`؛ روی سرور: بازیابی روی ۲۴۷ با شمارش‌های برابرِ ۲۴۹ و یک ردیفِ آزمایشی که ۲ ثانیه بعد آن‌جا بود |
| دیتابیس روی سرورِ اختصاصی (۲۱۶) با pgBackRest بکاپ می‌شود: آرشیوِ پیوسته‌ی WAL (حداکثر ~۱ دقیقه عقب)، نسخه‌ی کاملِ هفتگی و diffِ روزانه، **رمزشده روی خودِ ۲۱۶**؛ کپی هر دقیقه به ۲۴۷ با کلیدِ فقط‌نوشتنی، پس ۲۴۷ (مشترک) هیچ داده‌ی خوانایی ندارد. آرشیوِ دوره‌ی ۲۴۹ روی ۲۴۷ هم رمز شد (`docs/operations.md` «بکاپ و بازیابی») | `config/tests_offsite_backup.py` برای هشدارِ `/api/health`؛ روی سرور: `tadrisino-restore-check` بعد از سوئیچ با شمارش‌های برابر، بازیابیِ کامل از نسخه‌ی رمزشده‌ی ۲۴۷، و باز کردنِ نمونه‌های آرشیوِ ۲۴۹ با چک‌سامِ برابر |
| `.env`، کلیدِ امضا، لاگ‌ها و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | روی سرور `sudo -u nobody ls ~/mathapp-v2/.deploy-backups` باید خطای دسترسی بدهد |
| پانزده مسیرِ خواندنی و نوشتنیِ کم‌خطر سقفِ نرخ دارند (بانکِ سوال و intro بی‌ورود، `progress/nodes`، `user/hearts`، اعلان، تیکت، پلن‌ها، وضعیتِ اشتراک، سفارش، پیشنهادها، affiliate، تاریخچه و فیدبکِ دستیار، خروج). با نرخِ ۳ در دقیقه درخواستِ چهارم ۴۲۹ می‌گیرد | `content/tests_read_throttles.py` |
| ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) |

View File

@@ -1,11 +1,12 @@
#!/bin/bash
# روی ۲۴۹، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ می‌برد و فشرده می‌کند.
# روی سرورِ اپ، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ می‌برد و فشرده می‌کند.
# برنامه با LOG_FILE_ROTATION=external از WatchedFileHandler استفاده می‌کند و پس از جابه‌جایی خودش app.log تازه
# می‌سازد؛ خطی که در همان لحظه نوشته شود در فایلِ جابه‌جاشده می‌ماند، گم نمی‌شود.
# این‌جا فقط ۳۰ روز می‌ماند (دیسکِ ۲۴۹ پر است) و فقط فایلی پاک می‌شود که ۲۴۷ گرفتنش را تأیید کرده
# (`.shipped`)؛ نسخه‌ی همیشگی روی ۲۴۷ است (pull-app-logs.sh). فقط زیرِ ~/mathapp-v2/logs می‌نویسد.
set -euo pipefail
LOGS=/home/administrator/mathapp-v2/logs
# پوشه‌ی پروژه از جای خودِ اسکریپت (ops/applogs/ → دو پوشه بالاتر)، تا روی هر سروری با هر کاربری درست باشد.
LOGS="$(cd "$(dirname "$0")/../.." && pwd)/logs"
ARCH="$LOGS/archive"
mkdir -p "$ARCH"
chmod 750 "$ARCH"

View File

@@ -5,7 +5,7 @@ server {
server_name math.tadrisino2.ir;
ssl_certificate /etc/ssl/certs/mathapp.crt;
ssl_certificate_key /etc/ssl/private/mathapp.key;
root /home/administrator/mathapp_frontend_new/dist;
root /home/mathapp/mathapp_frontend_new/dist;
include /etc/nginx/snippets/zareian-arvan-only.conf;
include /etc/nginx/snippets/zareian-front-headers.conf;

View File

@@ -0,0 +1,13 @@
# تدریسینو: سخت‌سازیِ SSH. فقط zareian (صاحبِ محصول) و mathapp-admin (حسابِ مشترکِ سینیور و صاحبِ شرکت) وارد می‌شوند.
# ورود با رمز هنوز باز است (تا رفتن به کلید SSH)؛ fail2ban جلوی حدس زدن را می‌گیرد.
PermitRootLogin no
AllowUsers zareian mathapp-admin
MaxAuthTries 4
MaxSessions 10
LoginGraceTime 2m
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
ClientAliveInterval 300
ClientAliveCountMax 2

View File

@@ -0,0 +1,18 @@
# تدریسینو: تنظیماتِ شبکه‌ی استاندارد (CIS). سرور مسیریاب نیست.
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
fs.protected_hardlinks = 1
fs.protected_symlinks = 1

View File

@@ -0,0 +1,4 @@
# تدریسینو: آرشیوِ پیوسته‌ی WAL با pgBackRest (رمزنگاری‌شده). archive_timeout یعنی حداکثر ۶۰ ثانیه تغییرِ آرشیونشده.
archive_mode = on
archive_command = 'pgbackrest --stanza=zareian archive-push %p'
archive_timeout = 60

View File

@@ -0,0 +1,20 @@
[Unit]
Description=Gunicorn MathApp (tadrisino API)
After=network.target postgresql@16-zareian.service zareian-redis.service
Wants=postgresql@16-zareian.service zareian-redis.service
[Service]
User=mathapp
Group=www-data
WorkingDirectory=/home/mathapp/mathapp-v2
EnvironmentFile=/home/mathapp/mathapp-v2/.env
# /run/gunicorn بعد از هر ریبوت ساخته می‌شود (روی ۲۴۹ دستی ساخته شده بود و بعد از ریبوت نبود).
RuntimeDirectory=gunicorn
RuntimeDirectoryMode=0750
ExecStart=/home/mathapp/mathapp-v2/venv/bin/gunicorn --bind unix:/run/gunicorn/mathapp.sock config.wsgi:application
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Alias=zareian-mathapp.service

View File

@@ -0,0 +1,17 @@
# روی ۲۱۶: /etc/pgbackrest.conf (root:postgres 640). کلیدِ واقعی فقط روی سرور و در /etc/tadrisino-backup/RECOVERY-KEYS است.
# تدریسینو: بکاپِ رمزنگاری‌شده. مخزنِ محلی /var/lib/pgbackrest؛ نسخه‌ی بیرونی با tadrisino-offsite-sync به ۲۴۷ (فقط فایلِ رمزشده).
[global]
repo1-path=/var/lib/pgbackrest
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<RECOVERY-KEYS، خطِ کلیدِ ۱>
repo1-retention-full=2
compress-type=zst
process-max=2
start-fast=y
log-level-console=info
log-level-file=detail
[zareian]
pg1-path=/var/lib/postgresql/16/zareian
pg1-port=5440
pg1-socket-path=/var/run/postgresql

View File

@@ -0,0 +1,44 @@
#!/bin/bash
# تدریسینو: IPهای شناخته‌شده‌ی مخرب را پیش از اولین تلاش روی همه‌ی پورت‌ها می‌بندد.
# فهرست: FireHOL level1 (Spamhaus DROP/EDROP، DShield، Feodo، bogons)، روزی یک بار (tadrisino-blocklist.timer).
# شبکه‌های داخلی، رنجِ IPِ شرکت و رنج‌های ArvanCloud (از snippetِ nginx) هرگز بسته نمی‌شوند.
# اگر دانلود نشد، آخرین فهرستِ سالم (/var/lib/tadrisino-blocklist/list) دوباره بار می‌شود.
set -euo pipefail
URL=https://iplists.firehol.org/files/firehol_level1.netset
DIR=/var/lib/tadrisino-blocklist; SET=tadrisino-block
mkdir -p "$DIR"
tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT
if curl -fsS -m 90 "$URL" -o "$tmp" && [ "$(grep -c '^[0-9]' "$tmp")" -gt 1000 ]; then
python3 - "$tmp" "$DIR/list" <<'PY'
import ipaddress, re, sys
src, dst = sys.argv[1], sys.argv[2]
keep = ['127.0.0.0/8','10.0.0.0/8','172.16.0.0/12','192.168.0.0/16','100.64.0.0/10','169.254.0.0/16','78.38.35.0/24']
try:
keep += re.findall(r'^allow\s+([0-9./]+);', open('/etc/nginx/snippets/zareian-arvan-only.conf').read(), re.M)
except OSError:
pass
keep = [ipaddress.ip_network(k) for k in keep if ':' not in k]
out, dropped = [], 0
for line in open(src):
line = line.strip()
if not line or line[0] == '#':
continue
n = ipaddress.ip_network(line, strict=False)
if any(n.overlaps(k) for k in keep):
dropped += 1
continue
out.append(str(n))
open(dst, 'w').write('\n'.join(out) + '\n')
print(f'{len(out)} networks, {dropped} skipped (internal/company/Arvan)')
PY
else
echo "download failed; reloading last good list" >&2
fi
[ -s "$DIR/list" ] || { echo "no list available" >&2; exit 1; }
ipset create "$SET" hash:net maxelem 262144 -exist
ipset create "$SET-new" hash:net maxelem 262144 -exist
ipset flush "$SET-new"
sed "s/^/add $SET-new /" "$DIR/list" | ipset restore -exist
ipset swap "$SET-new" "$SET"; ipset destroy "$SET-new"
iptables -C INPUT -m set --match-set "$SET" src -j DROP 2>/dev/null || iptables -I INPUT 1 -m set --match-set "$SET" src -j DROP
echo "loaded $(ipset list "$SET" -t | sed -n 's/^Number of entries: //p') entries into $SET"

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Tadrisino: block known-malicious IPs (FireHOL level1)
After=network-online.target ufw.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/tadrisino-blocklist
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Tadrisino: refresh malicious-IP blocklist daily
[Timer]
OnCalendar=*-*-* 05:20:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,19 @@
#!/bin/bash
# تدریسینو: دفترِ خرید (payment_paymentevent)، سفارش‌ها و تراکنش‌ها به CSV، هر ۶ ساعت. هر فایل پیش از نوشته‌شدن روی دیسک
# با age برای کلیدِ /etc/tadrisino-backup/age-recipient.txt رمز می‌شود؛ متنِ خوانا هیچ‌جا ذخیره نمی‌شود.
# بازکردن: age -d -i age-identity.txt payment-events-….csv.age (کلید در RECOVERY-KEYS). ۱۴ فایلِ آخرِ هر نوع می‌ماند.
set -euo pipefail
OUT=/var/lib/tadrisino-backup/payment-log
REC=/etc/tadrisino-backup/age-recipient.txt
stamp=$(date -u +%Y%m%dT%H%M%SZ)
q() {
psql -p 5440 -d zareian_mathapp -v ON_ERROR_STOP=1 -Atqc "\\copy ($1) to stdout csv header" | age -R "$REC" -o "$OUT/$2-$stamp.csv.age"
}
q "select id, created_at, order_id, user_id, provider, event, detail from payment_paymentevent order by id" payment-events
q "select id, created_at, user_id, plan_id, plan_title, amount, status, provider, gateway_authority, subscription_id from payment_order order by id" orders
q "select id, created_at, order_id, gateway, gateway_ref_id, status, paid_at from payment_transaction order by id" transactions
chmod 640 "$OUT"/*-"$stamp".csv.age
for kind in payment-events orders transactions; do
ls -1t "$OUT/$kind"-*.csv.age | tail -n +15 | xargs -r rm -f --
done
echo "wrote $OUT/*-$stamp.csv.age"

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: encrypted payment CSVs every 6 hours
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/sbin/tadrisino-export-payment-events

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: encrypted payment CSVs every 6 hours
[Timer]
OnCalendar=*-*-* 01,07,13,19:15:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,11 @@
#!/bin/bash
# تدریسینو: نسخه‌ی بیرونیِ بکاپ. فقط فایل‌های رمزشده (مخزنِ pgBackRest با AES-256 و CSVهای age) به ۲۴۷ می‌روند.
# کلیدِ postgres روی ۲۴۷ با rrsync -wo محدود است: فقط نوشتن در ~/zareian-pgbackrest-216، نه خواندن، نه شِل.
# پس از موفقیتِ هر دو، زمانش در offsite-synced-at ثبت می‌شود و /api/health از روی آن عقب‌ماندگی را می‌سنجد.
set -euo pipefail
R=administrator@192.168.200.167
SSH="ssh -i /var/lib/postgresql/.ssh/id_offsite -o BatchMode=yes -o ConnectTimeout=20"
rsync -a --delete -e "$SSH" /var/lib/pgbackrest/ "$R:repo/"
rsync -a --delete -e "$SSH" /var/lib/tadrisino-backup/payment-log/ "$R:payment-log/"
date -u +%s > /var/lib/tadrisino-backup/offsite-synced-at.tmp
mv /var/lib/tadrisino-backup/offsite-synced-at.tmp /var/lib/tadrisino-backup/offsite-synced-at

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: copy encrypted backups to 247
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/sbin/tadrisino-offsite-sync

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: copy encrypted backups to 247
[Timer]
OnCalendar=minutely
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: daily differential encrypted backup
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/bin/pgbackrest --stanza=zareian --type=diff backup

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: daily differential encrypted backup
[Timer]
OnCalendar=*-*-* 03:40:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Tadrisino: weekly full encrypted backup
After=postgresql@16-zareian.service
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/bin/pgbackrest --stanza=zareian --type=full backup

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Tadrisino: weekly full encrypted backup
[Timer]
OnCalendar=Sun *-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,26 @@
#!/bin/bash
# تدریسینو: آزمونِ بازیابی از بکاپِ رمزشده (ماهی یک بار، دستی: sudo -u postgres tadrisino-restore-check).
# آخرین بکاپ + همه‌ی WALِ آرشیوشده را در پوشه‌ی موقت بازیابی می‌کند، Postgresِ موقت را روی سوکتِ همان پوشه (بی‌پورتِ شبکه،
# archive_mode=off تا چیزی به مخزن ننویسد) بالا می‌آورد، شمارش‌ها را کنارِ دیتابیسِ اصلی چاپ می‌کند و همه را پاک می‌کند.
set -euo pipefail
BIN=/usr/lib/postgresql/16/bin
work=$(mktemp -d /var/lib/postgresql/restore-check.XXXXXX)
trap '"$BIN/pg_ctl" -D "$work/data" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$work"' EXIT
pgbackrest --stanza=zareian --pg1-path="$work/data" --type=default --archive-mode=off restore --log-level-console=warn
cat > "$work/data/postgresql.conf" <<CONF
listen_addresses = ''
port = 5499
unix_socket_directories = '$work'
archive_mode = off
shared_buffers = 128MB
CONF
echo 'local all all peer' > "$work/data/pg_hba.conf"
"$BIN/pg_ctl" -D "$work/data" -l "$work/log" -w -t 600 start >/dev/null
for _ in $(seq 120); do
[ "$(psql -h "$work" -p 5499 -d zareian_mathapp -Atc 'select pg_is_in_recovery()')" = f ] && break; sleep 1
done
Q="select 'users', count(*) from accounts_user union all select 'questions', count(*) from content_question
union all select 'orders', count(*) from payment_order union all select 'payment_events', count(*) from payment_paymentevent
union all select 'login_events', count(*) from accounts_loginevent"
echo "restored | live"
paste -d'|' <(psql -h "$work" -p 5499 -d zareian_mathapp -Atc "$Q") <(psql -p 5440 -d zareian_mathapp -Atc "$Q" | cut -d'|' -f2)

View File

@@ -0,0 +1,25 @@
# تدریسینو: SSH در برابرِ ربات‌های حدسِ رمز، پله‌ای تا آدمِ واقعی (اشتباهِ تایپی، کیبوردِ فارسی) گیر نکند.
# ۶ اشتباه در ۱۰ دقیقه → ۱۰ دقیقه بسته؛ هر بارِ تکرار دو برابر (۲۰، ۴۰، ...، حداکثر یک هفته).
# شبکه‌ی داخلیِ شرکت (ادمین‌ها، ۲۴۷، ۲۴۹) هرگز بسته نمی‌شود.
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 192.168.200.0/24
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w
[sshd]
enabled = true
backend = systemd
mode = aggressive
maxretry = 6
findtime = 10m
bantime = 10m
# فقط رباتِ سمج: پنج بار بسته‌شدن در یک روز → یک هفته روی همه‌ی پورت‌ها.
[recidive]
enabled = true
logpath = /var/log/fail2ban.log
banaction = iptables-allports
findtime = 1d
maxretry = 5
bantime = 1w

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Redis for zareian-mathapp only
After=network.target
[Service]
User=redis
Group=redis
ExecStart=/usr/bin/redis-server /etc/zareian-redis.conf
Restart=on-failure
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,5 @@
# تدریسینو، سرور اختصاصی: فقط localhost. max_connections با workers × DB_POOL_MAX_SIZE + 6 در /api/health سنجیده می‌شود.
listen_addresses = '127.0.0.1'
max_connections = 60
shared_buffers = 1GB
effective_cache_size = 4GB