Compare commits
10 Commits
670513b462
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 92b157eda4 | |||
| 4efe6e3f6b | |||
| 43e02adf37 | |||
| e25ffb5575 | |||
| eb03b467d4 | |||
| f2c57c1ad1 | |||
| f6d779255d | |||
| 4cd859b5fa | |||
| dd98f16d12 | |||
| bd329f83ae |
@@ -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
152
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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` از دیتابیس بکاپ میگیرد،
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 را جابهجا و
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 در هر خط لاگ میشود
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
13
ops/server216/10-tadrisino.conf
Normal file
13
ops/server216/10-tadrisino.conf
Normal 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
|
||||
18
ops/server216/80-tadrisino-hardening.conf
Normal file
18
ops/server216/80-tadrisino-hardening.conf
Normal 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
|
||||
4
ops/server216/backup.conf
Normal file
4
ops/server216/backup.conf
Normal file
@@ -0,0 +1,4 @@
|
||||
# تدریسینو: آرشیوِ پیوستهی WAL با pgBackRest (رمزنگاریشده). archive_timeout یعنی حداکثر ۶۰ ثانیه تغییرِ آرشیونشده.
|
||||
archive_mode = on
|
||||
archive_command = 'pgbackrest --stanza=zareian archive-push %p'
|
||||
archive_timeout = 60
|
||||
20
ops/server216/gunicorn-mathapp.service
Normal file
20
ops/server216/gunicorn-mathapp.service
Normal 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
|
||||
17
ops/server216/pgbackrest.conf
Normal file
17
ops/server216/pgbackrest.conf
Normal 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
|
||||
44
ops/server216/tadrisino-blocklist
Executable file
44
ops/server216/tadrisino-blocklist
Executable 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"
|
||||
11
ops/server216/tadrisino-blocklist.service
Normal file
11
ops/server216/tadrisino-blocklist.service
Normal 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
|
||||
10
ops/server216/tadrisino-blocklist.timer
Normal file
10
ops/server216/tadrisino-blocklist.timer
Normal 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
|
||||
19
ops/server216/tadrisino-export-payment-events
Executable file
19
ops/server216/tadrisino-export-payment-events
Executable 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"
|
||||
8
ops/server216/tadrisino-export-payment-events.service
Normal file
8
ops/server216/tadrisino-export-payment-events.service
Normal 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
|
||||
9
ops/server216/tadrisino-export-payment-events.timer
Normal file
9
ops/server216/tadrisino-export-payment-events.timer
Normal 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
|
||||
11
ops/server216/tadrisino-offsite-sync
Executable file
11
ops/server216/tadrisino-offsite-sync
Executable 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
|
||||
8
ops/server216/tadrisino-offsite-sync.service
Normal file
8
ops/server216/tadrisino-offsite-sync.service
Normal 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
|
||||
9
ops/server216/tadrisino-offsite-sync.timer
Normal file
9
ops/server216/tadrisino-offsite-sync.timer
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Tadrisino: copy encrypted backups to 247
|
||||
|
||||
[Timer]
|
||||
OnCalendar=minutely
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
8
ops/server216/tadrisino-pgbackrest-diff.service
Normal file
8
ops/server216/tadrisino-pgbackrest-diff.service
Normal 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
|
||||
9
ops/server216/tadrisino-pgbackrest-diff.timer
Normal file
9
ops/server216/tadrisino-pgbackrest-diff.timer
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Tadrisino: daily differential encrypted backup
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:40:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
8
ops/server216/tadrisino-pgbackrest-full.service
Normal file
8
ops/server216/tadrisino-pgbackrest-full.service
Normal 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
|
||||
9
ops/server216/tadrisino-pgbackrest-full.timer
Normal file
9
ops/server216/tadrisino-pgbackrest-full.timer
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Tadrisino: weekly full encrypted backup
|
||||
|
||||
[Timer]
|
||||
OnCalendar=Sun *-*-* 03:00:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
26
ops/server216/tadrisino-restore-check
Executable file
26
ops/server216/tadrisino-restore-check
Executable 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)
|
||||
25
ops/server216/tadrisino.local
Normal file
25
ops/server216/tadrisino.local
Normal 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
|
||||
13
ops/server216/zareian-redis.service
Normal file
13
ops/server216/zareian-redis.service
Normal 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
|
||||
5
ops/server216/zareian.conf
Normal file
5
ops/server216/zareian.conf
Normal 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
|
||||
Reference in New Issue
Block a user