This commit is contained in:
Mateusz Gruszczyński committed 2026-10-28 14:55:35 +01:00
1 parent c7d09077fc
commit f813441bf0
45 files changed
+937 -333

No files matched your search

+2 -1
View File
@@ -3,7 +3,8 @@ APP_NAME="MikroTik Backup System"
APP_ENV=production APP_ENV=production
APP_PORT=5581 APP_PORT=5581
# Native defaults to 127.0.0.1; Docker overrides this to 0.0.0.0:5581. # Native defaults to 127.0.0.1; Docker overrides this to 0.0.0.0:5581.
LISTEN_ADDR=127.0.0.1:5581 LISTEN_ADDR=0.0.0.0:5581
BIND_ADDRESS=0.0.0.0
API_PREFIX=/api API_PREFIX=/api
TIMEZONE=Europe/Warsaw TIMEZONE=Europe/Warsaw
DATA_DIR=./storage DATA_DIR=./storage
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 0.0.2 — Automatic schema migration and configuration tooling
- Added automatic transactional startup migration from the supported Rust 0.0.1 schema (v1) to schema v2.
- Added default RouterOS username/password settings with automatic database upgrade; manual `sqlite3` migration is no longer required.
- Added cross-device configuration diff, standalone classic HTML diff view, and full diff loading without pagination.
- Added direct RouterOS `.rsc`/`.backup` file upload over SSH from the repository view.
- Fixed RouterOS credential-source presentation and primary-button hover behavior in light and dark themes.
## 0.0.1 — Initial release ## 0.0.1 — Initial release
Initial Rust rewrite of the original Python/TypeScript MikroTik Backup System. Initial Rust rewrite of the original Python/TypeScript MikroTik Backup System.
Generated
+1 -1
View File
@@ -1148,7 +1148,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]] [[package]]
name = "mikrotik-backup" name = "mikrotik-backup"
version = "0.0.1" version = "0.0.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mikrotik-backup" name = "mikrotik-backup"
version = "0.0.1" version = "0.0.2"
edition = "2024" edition = "2024"
rust-version = "1.99" rust-version = "1.99"
description = "Self-hosted MikroTik backup system: one Rust service with an embedded web interface" description = "Self-hosted MikroTik backup system: one Rust service with an embedded web interface"
+10 -2
View File
@@ -1,6 +1,6 @@
# MikroTik Backup System # MikroTik Backup System
Version **0.0.1** is the initial Rust rewrite of the previous Python/TypeScript application. It is a single self-hosted service with an embedded web UI, SQLite storage, RouterOS/SwOS backup workflows, scheduling, retention, notifications, and local OpenAPI documentation. Version **0.0.2** is the current Rust release of the MikroTik Backup System. It is a single self-hosted service with an embedded web UI, SQLite storage, RouterOS/SwOS backup workflows, scheduling, retention, notifications, and local OpenAPI documentation.
## Quick start ## Quick start
@@ -92,6 +92,12 @@ docker compose run --rm \
The importer copies accounts, devices, settings, logs, backup metadata, and every referenced backup file. It validates existing backup checksums when present and refuses to publish an incomplete destination. See [docs/migration.md](docs/migration.md). The importer copies accounts, devices, settings, logs, backup metadata, and every referenced backup file. It validates existing backup checksums when present and refuses to publish an incomplete destination. See [docs/migration.md](docs/migration.md).
### Automatic Rust database migration
Version 0.0.2 upgrades a supported 0.0.1 Rust database automatically at startup. The migration adds the optional default RouterOS username/password fields and advances the internal schema marker from v1 to v2 in one SQLite transaction. No manual `sqlite3` command is required.
The legacy Python/TypeScript database is a different format and still uses the explicit `import-python` flow described above.
## RouterOS and SwOS ## RouterOS and SwOS
RouterOS uses SSH-based export/backup operations. SwOS uses its web interface and downloads `/backup.swb`, with Digest authentication first and Basic as fallback. Connection testing distinguishes general web reachability from backup-endpoint availability. RouterOS uses SSH-based export/backup operations. SwOS uses its web interface and downloads `/backup.swb`, with Digest authentication first and Basic as fallback. Connection testing distinguishes general web reachability from backup-endpoint availability.
@@ -104,7 +110,9 @@ SwOS details and troubleshooting are in [docs/switchos.md](docs/switchos.md).
- side-by-side and unified diff; - side-by-side and unified diff;
- changed-lines-only mode and search; - changed-lines-only mode and search;
- paged browser rendering to limit DOM size; - paged browser rendering to limit DOM size, with an explicit full-diff view on demand;
- comparisons between exports from the same RouterOS device or two different RouterOS devices;
- a standalone classic unified-diff HTML view that can be opened in a separate tab;
- semantic RouterOS analysis that groups changes by configuration section and identifies added, removed, and modified commands/fields; - semantic RouterOS analysis that groups changes by configuration section and identifies added, removed, and modified commands/fields;
- masking of sensitive values in semantic summaries. - masking of sensitive values in semantic summaries.
+36 -35
View File
@@ -1,48 +1,48 @@
b1fce4075b13be98f98bb97826e6f8503b6f8684ff409f45fccfee8b721cf574 CHANGELOG.md b47ea5c16b7bb10bb41395c06e50602523ed0e338b8e4d05854c2dfcb130c964 CHANGELOG.md
341839930e9b231b548dbd1dbbc8e497531ff9c4903b3cf07722f3d9f024e2d8 Cargo.lock 503e7a205b33055d788377e72f26915326059a1748e501b24378dd813e4f9c78 Cargo.lock
b16e11cea88def78673ceacbb4704259f2e23fccc98645ed7a745435b06dbfbc Cargo.toml 662ce504da8a721dd1cfeada1cd4e3826f72acd5336587e15acea95b39fb1bfa Cargo.toml
d2dbe44f098b72c7d7c7326147e09e4ce1313a09995031baca1bc61d29d1cb6d Dockerfile 107f2abdb9a030c5b7854357c89df0cc22bb42bad28092c15a67274779aa7c69 Dockerfile
81013dfb92305dbda89ff1a1eee1216d3b2eef4290ba2896fb77cc1506d55218 README.md 20f0f53b627c5bbc19ed213a31eb623320b8fe7257ae7a10ac744a75b031a2f8 README.md
b7cab07053ffd10954d69adbb654ae1c00be78ec6f56be6b423c5e65f2be0f0a build.rs 6da56598b57511885067788413b235b7a90a72cf95e7d14a06d9df800b39fdfd build.rs
9fdd1d93ba6806b91c36bb295085b831fefd05af807df18442d7493342c22392 compose.yaml 3103411f556fd1eef92b178b9ad64967454b92a5779231441b491ebad41f7d8f compose.yaml
77e0ff099a7d85890555e05b7e6e146832e477c7f3393d1ecdc82c5e50ead91e deploy/mikrotik-backup.service 911d775c98604f8330eb08348d4960c62dfd6c8444abf60cfaf7fbf320c81a95 deploy/mikrotik-backup.service
2bf2eda4b31ab6c08a34b7800c072320e397b50d112858c840a408a58764d016 deploy/start.sh 2bf2eda4b31ab6c08a34b7800c072320e397b50d112858c840a408a58764d016 deploy/start.sh
4d0b41060eda3a422ff00d357fabf73bd3229e9df8a7f755e128c989eb59de93 docs/THIRD_PARTY_NOTICES.txt a245139dd8e13ca4b8743077f6011a8f31dab48ff0569caa6e5b6e0ddf0e2021 docs/THIRD_PARTY_NOTICES.txt
13dcb5c1bdbc71e1902d7c1237ccf67a6ce32ce081de9dd35e6b3b4cb8ff7b0a docs/api-ui/index.html b8b3bd39a1e792f0e1c63c6f095506e61a61dee9f1983bcb0391e12bbbbb93ad docs/api-ui/index.html
9e07aa7d91594d7066470face7f43e3a8bbd69cbd88fd845343f5689c0ca7027 docs/api-ui/init.js 8d43b1349e3e30dbf1fb677fc310c919439fb318da016c8f1d1f0b46bca4c28c docs/api-ui/init.js
a5cea2ffa49b8d2a0f11ed99e9b9602f600799fb0bfffa57d844c2be773e34b9 docs/api-ui/style.css eb9f5f220a465faa34f1bce7a09169241604ae7a8e363efed111a38b6d3befa1 docs/api-ui/style.css
061a0b6a4a2ce5823edbcbf8d7f47a775d348d4cd4f125fd6145189e717feba1 docs/dependency-audit-metadata.json 061a0b6a4a2ce5823edbcbf8d7f47a775d348d4cd4f125fd6145189e717feba1 docs/dependency-audit-metadata.json
49e44f55fdaf3e2aacd358d312489882a91147f8790a81348ffa3f9fd76e6634 docs/dependency-audit.json 49e44f55fdaf3e2aacd358d312489882a91147f8790a81348ffa3f9fd76e6634 docs/dependency-audit.json
dcebf59142a77d8f84950d535db0fa6b656519d530ccdfba2855f9ff0fc3f475 docs/migration.md c3a7e4304fa3960e4a65a5a1248b2e69f197e4bd60e51c11274ebec19634259f docs/migration.md
8d7b4db7302767a0b0d34df5c05270580ac6ae709e0b3ac002668a1a7b2aaf96 docs/openapi.json 967ac1a063ca43017ad3c3850cbad72674c0c3150f6b1673e94ae53fa1f98e2d docs/openapi.json
cb90e9f1f1827770c67561ca68f5b30f5b025a50da6804ee8c59efa9abd1c716 docs/security.md e4f9b9d5d82edc66f395bd5c416974c9663088463153c533b28b84705f231e97 docs/security.md
5dd31dff53db62fc97e94f02a52efb13c44b6a3950aa13416f4888ef215503e3 docs/switchos.md 9dc2baa1837a30c8794c9edaf05e4b2e2764259898a74cf511e40d618b92aa84 docs/switchos.md
1533deb562e6cd6379a3c8b7988f8b3170c6629a04c874d21a315bcc2ea94407 import-python.sh 1533deb562e6cd6379a3c8b7988f8b3170c6629a04c874d21a315bcc2ea94407 import-python.sh
a9c47bfae030614a21ea30893c285398251340624caec4d029c5b090b7431936 migrations/001_initial.sql a9c47bfae030614a21ea30893c285398251340624caec4d029c5b090b7431936 migrations/001_initial.sql
048c5f9d02a6c8f4dac0bbcc5b50650ff0ad036116ba463ae554f43dec750d54 migrations/002_switchos_credentials_source.sql 048c5f9d02a6c8f4dac0bbcc5b50650ff0ad036116ba463ae554f43dec750d54 migrations/002_switchos_credentials_source.sql
cb8a70125de6688e738ef27811d4297e575a35248b8c00f262fb86cff9ce03ce migrations/003_backup_layout.sql cb8a70125de6688e738ef27811d4297e575a35248b8c00f262fb86cff9ce03ce migrations/003_backup_layout.sql
4d71d9eb0fa89d7182b20fe448cae00c22a851258727451052acaf3145b29fbc release-manifest.json 7bf1f9f76c8a01c5f9cc464c1256d1cf5b6d05f379cecdac97eee5c07fa94175 release-manifest.json
638924b15afb3b6f88b83713e356162659987378c6731a2ea3403b90e31ae0eb rust-toolchain.toml 638924b15afb3b6f88b83713e356162659987378c6731a2ea3403b90e31ae0eb rust-toolchain.toml
2883973832dd23f34889b1788ca7ddcd57e2410cf28245b8da55dacd45960d43 src/api/auth.rs 2883973832dd23f34889b1788ca7ddcd57e2410cf28245b8da55dacd45960d43 src/api/auth.rs
079473319684d445eb7b9b8b01df7207fd96eed5a98287fa541376692e6d1204 src/api/backups.rs aa876ec7f03c5be28801883319eb13c0152640579031d8bec50fc98c00684bc0 src/api/backups.rs
eda20155c645788ebc238a4553edaa90b7e5314782e82337125f41ba50b90234 src/api/devices.rs 27d891210c8918373fbdb877f06dc9e900057850e50ef9ced4f8ff5aa20491b2 src/api/devices.rs
9827188b7a89573a1b108a3d0cb58e2f720d4b62549cf062b44be767f8aaafa5 src/api/documentation.rs 9827188b7a89573a1b108a3d0cb58e2f720d4b62549cf062b44be767f8aaafa5 src/api/documentation.rs
8f4f8ee55780e90d20c85920a471e4f72b3f17b92cb51469e4634176d6daba3b src/api/mod.rs 9e68ac135312bd7fc96c99b8eb06b93efb906c5d46bbe568ea897ef550e13f8f src/api/mod.rs
81ae453a28edaebf554e11324d088cfe95dbdc6625e388e7f1f5398d94d72e7b src/api/system.rs 81ae453a28edaebf554e11324d088cfe95dbdc6625e388e7f1f5398d94d72e7b src/api/system.rs
7b6959c99e9eca52e1f0aa862c32bb7ca0f988b6b4d7b1c0d1eb8bbd448a5f25 src/backup.rs 5185c0e69a17d3805d6038208964d2dfb8b3a563ec6230d341dbccca20cb6cf8 src/backup.rs
1eee0ed74a61882247332dea93372cba54f310211ed05095712054b0432d507a src/config.rs 1eee0ed74a61882247332dea93372cba54f310211ed05095712054b0432d507a src/config.rs
bd5d86db6192528ad4c7d79f422650317dd37bfd1e0b8ad623244b4ba93ef545 src/db.rs 87b2ad47c5ab7e33f4ca7ac823b6af006bfc087ee7d34459e79bca89b82429b1 src/db.rs
30e35f94bf7d876ce0f8fc696f47e6e7680da7fe74e43d47408a0140dac6e5e1 src/diff.rs 84db7474b9e218569229bdc466ad564691aec83ae16c322546c70ac7d904f44c src/diff.rs
18d23f46601f69dcf5ce817709e3b7e9ae08aa282bd29b2900946a81bdf78342 src/lib.rs 18d23f46601f69dcf5ce817709e3b7e9ae08aa282bd29b2900946a81bdf78342 src/lib.rs
92c3b8afe80d5d36e21e5fb8da6d718a69cab677ae935528105b0dd026b13869 src/main.rs 92c3b8afe80d5d36e21e5fb8da6d718a69cab677ae935528105b0dd026b13869 src/main.rs
89aad2d2795bfbfb18178818aa9908a01d010167d8647f7563aa85c0397e9622 src/models.rs 9800272a2bfb4b9f8ea1c5fbb2c83953ea0db025786357d6c7daf26f204dfa5d src/models.rs
e570ce7cad4fe4f325419e6cae0d15ff6ecb4b27f79ef3e9c77508a4cdd279d9 src/notifications.rs 8318c912ecfe5214d1b2446e0e0be958ccacbc1700aac792a5f68a5c7b15f9a5 src/notifications.rs
2db2f31fbf2c809c6a82cdf8a69dd0012570298b71bd2d2e5d7844ff39903a7c src/routeros.rs 2b63ef9bcfbd21687ff430dcb7f98c8a2e325336d1ec212d6414367430e2096e src/routeros.rs
733a77ddf1dbd6b64e6ec425ee4e2b6f842a0902c61c274f0a1171d69ea5a3ce src/scheduler.rs 733a77ddf1dbd6b64e6ec425ee4e2b6f842a0902c61c274f0a1171d69ea5a3ce src/scheduler.rs
6a002266e727e17a23260e25c151191889cee8a793e4412f57cd6ef35cc137e9 src/schema.sql 6bcbb64224105ace3c7711b6e82c034d393732bf8c8b7337511ed1fed8e55e32 src/schema.sql
77acdb3df08469f6834b952e2f75f0ec5198073f781630e16cd9b327f7757258 src/security.rs 77acdb3df08469f6834b952e2f75f0ec5198073f781630e16cd9b327f7757258 src/security.rs
ce3b2dd7e7005a80571494c954f18d6042ee88d055deab346b191ca9374daaf7 start.sh ce3b2dd7e7005a80571494c954f18d6042ee88d055deab346b191ca9374daaf7 start.sh
b7e80c541653c41e0ebb1ab7c65e35ace5f58adc40f628292a6d18d10ba58dd9 tests/api_contract.rs 9348833266f61023f25101a069625db63bdfe52f08cc36af4d437e71b3cf2192 tests/api_contract.rs
dd4d70d7de7209d1113eebaa6799c39535a88ac2cc783445350283fb1c9c5f2e tests/fixtures/python-diff.json dd4d70d7de7209d1113eebaa6799c39535a88ac2cc783445350283fb1c9c5f2e tests/fixtures/python-diff.json
bae48d41ce4561196c4428bcb274502e4d8e04fee28b9684c2c579ad82749b18 tests/import_launcher.rs bae48d41ce4561196c4428bcb274502e4d8e04fee28b9684c2c579ad82749b18 tests/import_launcher.rs
3cd3d0763f3b46b752720257b183669c602d39f1b07e35ddf07676cfbb5d0eae tests/openapi_contract.rs 3cd3d0763f3b46b752720257b183669c602d39f1b07e35ddf07676cfbb5d0eae tests/openapi_contract.rs
@@ -144,12 +144,13 @@ c02dd4680423fdaffe96012cd012adda68b26281152a95355744ed4f6141c282 vendor/swagger
e5a4792feaa5332dfba37f1d7adf84308b93b4f2d8da0569a9f48a3cb61c969b vendor/swagger-ui/swagger-ui-bundle.js.LICENSE.txt e5a4792feaa5332dfba37f1d7adf84308b93b4f2d8da0569a9f48a3cb61c969b vendor/swagger-ui/swagger-ui-bundle.js.LICENSE.txt
c3cfe8248c4b0b89673600fb829924eafbac6de4bc4b2b06f5f5243483074d0a vendor/swagger-ui/swagger-ui.css c3cfe8248c4b0b89673600fb829924eafbac6de4bc4b2b06f5f5243483074d0a vendor/swagger-ui/swagger-ui.css
32a37246461a62133053549be4917c5f879410440d67c2e24c76a294a06f9ddb web/assets.list 32a37246461a62133053549be4917c5f879410440d67c2e24c76a294a06f9ddb web/assets.list
c88b1848a818c76a76cc4ca57759002fae88d3470afaa8d3a60bea3977d35b00 web/css/app.css 9dc176bca56cda46f9d17d8c520469d583afbe2b1c3c75bdba8e82453a3ae62b web/css/app.css
706eb87cc7a6c538f58df091f91cc223a9de4aed308717625a9625495bbde507 web/index.html 5db148afe605137f20f70c5cfaabc69142415c546eea63a1aab8e7ae8aacdc77 web/index.html
cc5062ef7c94e375bb67e5d1127458c0f02163d4bcc0f9e618b44dee458fd58c web/js/app.js 9f72a910cab2e7f71dd4fbbcf2110648b8bad0cd4adf635183e310a7ca7756cf web/js/app.js
3d40fd0665a87e3d26cabfc20d0292498750c1b4974a69538eb0cb0e9344c4e3 web/js/core.js 03a6dc408ee473c7fa5e1edd412147300dbe25436c0106f93d6ba7080ca41e01 web/js/core.js
8c8ed4800886c9aebb7ac52de8351b2fc26dbafc8977e89217c631b960c228cc web/js/i18n.js 2571bf7ae9882c2aee5cbfe33c59707f851c440c02083c897436e63ce80491d4 web/js/i18n.js
510f9cc6137777e03062d23a5bf841f5b6033b1f976a4825bb4700dfba4d40fc web/js/views.js 1921e3641aa920aba0f1f1e69fdedacfa02bf60717bca44d004b9e33a4d72374 web/js/views.js
5423de8d4b4da517aa2d740a08bbb6fcc6e1d03e0b0d8dd255a489d08c2365ef web/tests/core.test.cjs b007dc2a47fb520c0ca3bdb4cb154e09da1090991c79d01e14ea35a74a81ecea web/tests/core.test.cjs
78955d3bb7eacc8f4144570df5d54b17b54419d403c6d98472d7314b21c8e377 web/tests/live.cjs 78955d3bb7eacc8f4144570df5d54b17b54419d403c6d98472d7314b21c8e377 web/tests/live.cjs
de7d09e3d50c339962aa75d4e7603fcd3457e1ba1c2c2c0d5c536c83a005c77d web/tests/smoke.cjs de7d09e3d50c339962aa75d4e7603fcd3457e1ba1c2c2c0d5c536c83a005c77d web/tests/smoke.cjs
686cfb85e9139ede4c2ddb395d2e44784c5bdb344aa2cb8928ab6eca35074b06 migrations/004_routeros_default_credentials.sql
+1 -1
View File
@@ -44,7 +44,7 @@ fn main() {
let manifest = fs::read_to_string("web/assets.list").expect("web/assets.list is required"); let manifest = fs::read_to_string("web/assets.list").expect("web/assets.list is required");
let mut js = let mut js =
String::from("/* MikroTik Backup System 0.0.1; generated from web/assets.list */\n"); String::from("/* MikroTik Backup System 0.0.2; generated from web/assets.list */\n");
let mut css = String::new(); let mut css = String::new();
for entry in manifest for entry in manifest
.lines() .lines()
+1 -1
View File
@@ -1,7 +1,7 @@
services: services:
app: app:
build: . build: .
image: mikrotik-backup:0.0.1 image: mikrotik-backup:0.0.2
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-5581}:5581" - "${BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-5581}:5581"
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=MikroTik Backup System 0.0.1 Description=MikroTik Backup System
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
+1 -1
View File
@@ -1,4 +1,4 @@
THIRD-PARTY NOTICES — MikroTik Backup System 0.0.1 THIRD-PARTY NOTICES — MikroTik Backup System 0.0.2
Generated from the locked Cargo dependency graph for x86_64-unknown-linux-gnu. Generated from the locked Cargo dependency graph for x86_64-unknown-linux-gnu.
This inventory includes build/test dependencies where they occur in that graph. This inventory includes build/test dependencies where they occur in that graph.
Original license and notice texts below are reproduced without alteration. Original license and notice texts below are reproduced without alteration.
+1 -1
View File
@@ -1,4 +1,4 @@
# Interactive API documentation — 0.0.1 # Interactive API documentation — 0.0.2
## URLs ## URLs
+2 -7
View File
@@ -1,4 +1,4 @@
# Architecture — release 0.0.1 # Architecture — release 0.0.2
## One process with explicit module boundaries ## One process with explicit module boundaries
@@ -101,12 +101,7 @@ operation timeouts or the number of devices require a longer shutdown window.
## Fresh initialization and explicit import ## Fresh initialization and explicit import
`src/schema.sql` contains the complete current schema. If the database file does not `src/schema.sql` contains the complete current schema. If the database file does not exist, startup creates a fresh schema v2 database. If a database file exists, startup first validates a private read-only snapshot. Current schema v2 databases are accepted unchanged; the supported Rust 0.0.1 schema v1 is then upgraded transactionally to v2 after storage locking. Unsupported or unrelated schemas are rejected with instructions to use a fresh installation or explicit Python import. Startup schema migration does not move backup files.
exist, startup creates a fresh database. If a database file exists, startup first
opens it read-only and validates that it already has the supported schema. An older
or unrelated schema is rejected with instructions to use a fresh installation and
explicit import. Startup does not run `ALTER TABLE`, create upgrade snapshots, add
migration journals, or move backup files.
The separate `import-python --source DIR [--database PATH] [--backup-root DIR]` The separate `import-python --source DIR [--database PATH] [--backup-root DIR]`
command imports into a new destination. The `import-python.sh` wrapper builds the command imports into a new destination. The `import-python.sh` wrapper builds the
+6 -17
View File
@@ -1,8 +1,6 @@
# Fresh installation and explicit Python import # Fresh installation and explicit Python import
Version **0.0.1** starts with a clean installation. It does not update an old Version **0.0.2** initializes clean installations and automatically migrates the supported Rust 0.0.1 schema to the current schema at startup. It does not import or rewrite a Python/TypeScript installation automatically. Importing data from the Python/TypeScript application remains a separate, explicit operation.
schema or move existing backup files during startup. Importing data from the
Python/TypeScript application is a separate, explicit operation.
## Choose one installation path ## Choose one installation path
@@ -23,22 +21,13 @@ server**. Starting the server first creates a database, and the importer will
refuse to overwrite or merge it. Use a different new destination if a database refuse to overwrite or merge it. Use a different new destination if a database
already exists; do not delete a database merely to bypass this check. already exists; do not delete a database merely to bypass this check.
## Startup never upgrades an existing database ## Startup migration policy
Startup accepts an existing database only when it has the current `schema_info` Startup accepts the current schema and the supported Rust 0.0.1 schema. Before creating storage locks or secrets, the server performs a read-only preflight against a private temporary copy of the database, including committed WAL content.
marker, supported format version, required tables and columns, valid integrity,
and the expected user/settings records. An older Python schema, an earlier Rust
work-in-progress schema, an unknown version, or a pre-existing empty database file
is rejected with instructions to select a fresh destination or use the explicit
Python importer. No `ALTER TABLE`, persistent pre-upgrade snapshot, migration
journal, automatic import, or backup relocation is performed.
Before creating storage locks or secrets, the server performs a read-only After storage locking, a Rust schema v1 database is upgraded transactionally to schema v2 by adding `default_routeros_username` and `default_routeros_password` and advancing `schema_info.schema_version`. The resulting schema is validated before the service starts. A current schema is left unchanged.
preflight. SQLite inspects a private temporary copy of the database and its WAL
or rollback journal, so opening an incompatible database cannot create a source Python/TypeScript schemas, unsupported Rust work-in-progress schemas, unknown versions, and pre-existing empty database files are rejected. They are never imported automatically.
`-shm` file. The temporary inspection copy is removed afterwards. The schema is
checked again while the server holds its storage locks. Existing current records
and file paths are left unchanged.
## Import command ## Import command
+6 -10
View File
@@ -1,11 +1,10 @@
# Importing data from the Python/TypeScript release # Data migration
The current release does not perform automatic data migration and does not expose the Version **0.0.2** performs automatic, transactional schema migration for supported Rust databases. A database created by Rust 0.0.1 uses schema v1; on startup 0.0.2 validates it, adds `default_routeros_username` and `default_routeros_password`, updates `schema_info.schema_version` to `2`, validates the resulting schema, and then starts the service. No manual `sqlite3` command is required.
old `migrate` command. The supported path is the explicit, copy-only
`import-python` command. See [data-import.md](data-import.md) for the full source
discovery, validation, rollback, and failure rules.
For the standard layout of the supplied legacy project: The automatic migration applies only to the supported Rust schema. It does not convert the previous Python/TypeScript application database. For that source, use the explicit, copy-only `import-python` command described in [data-import.md](data-import.md).
For the standard legacy Python layout:
```sh ```sh
DATA_DIR="$PWD/storage-imported" \ DATA_DIR="$PWD/storage-imported" \
@@ -15,7 +14,4 @@ DATABASE_PATH="$PWD/storage-imported/routeros_backup_next.db" \
--backup-root docker-data --backup-root docker-data
``` ```
Stop the old application first. The destination directory must be new and must not Stop the old Python/TypeScript application first. The import destination must be new and must not contain an existing database.
contain an existing database. The import preserves application records and copies
referenced backup files. It intentionally does not copy external environment secrets,
service-manager files, operating-system permissions, or external SSH host-trust files.
+2 -2
View File
@@ -1,7 +1,7 @@
# Dependency review — release 0.0.1 # Dependency review — release 0.0.2
The audit documented here was performed on **27 October 2026 for source revision 2** The audit documented here was performed on **27 October 2026 for source revision 2**
of release 0.0.1. Its scope was `Cargo.lock`, known public advisories, and the C SSH of release 0.0.2. Its scope was `Cargo.lock`, known public advisories, and the C SSH
library bundled through `libssh2-sys`. Revision 3 additionally verified the provenance library bundled through `libssh2-sys`. Revision 3 additionally verified the provenance
of local Swagger UI assets. These historical checks are not a claim that a new of local Swagger UI assets. These historical checks are not a claim that a new
advisory scan has been run for revision 4; see [the validation report](validation.md). advisory scan has been run for revision 4; see [the validation report](validation.md).
+1 -1
View File
@@ -1,6 +1,6 @@
# Import from the previous Python/TypeScript application # Import from the previous Python/TypeScript application
The Rust service does not perform automatic data migrations on startup. Use the explicit `import-python` command once, with the previous application stopped. Rust-to-Rust schema migration is automatic for supported releases: 0.0.2 upgrades a 0.0.1 Rust database at startup. This document covers the separate Python/TypeScript import, which remains explicit. Use `import-python` once, with the previous application stopped.
## What is imported ## What is imported
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "MikroTik Backup System", "title": "MikroTik Backup System",
"version": "0.0.1", "version": "0.0.2",
"description": "Documentation for the live API. Use Authorize with your username and password or provide a Bearer token. Try it out performs real operations on the current instance. Registration is disabled by default. Accounts can access their own devices and backups; settings and logs are shared by operators." "description": "Documentation for the live API. Use Authorize with your username and password or provide a Bearer token. Try it out performs real operations on the current instance. Registration is disabled by default. Accounts can access their own devices and backups; settings and logs are shared by operators."
}, },
"servers": [ "servers": [
+5 -10
View File
@@ -1,4 +1,4 @@
# Port coverage matrix — 0.0.1 # Port coverage matrix — 0.0.2
## Scope and source identity ## Scope and source identity
@@ -86,9 +86,7 @@ contains `access_token`, `token_type=bearer`, and `user`.
## Models and source fields ## Models and source fields
Models are explicitly typed in `src/models.rs`. `src/db.rs` implements persistence, Models are explicitly typed in `src/models.rs`. `src/db.rs` implements persistence, current-schema validation, automatic migration of the supported Rust v1 schema, and explicit Python import. `src/schema.sql` is the complete fresh-install schema for schema v2.
current-schema validation, and explicit import. `src/schema.sql` is the complete
fresh-install schema. Revision 4 does not run incremental schema migrations at startup.
| Model / group | Preserved or added fields | Notes | | Model / group | Preserved or added fields | Notes |
| --- | --- | --- | | --- | --- | --- |
@@ -107,9 +105,9 @@ fresh-install schema. Revision 4 does not run incremental schema migrations at s
| StorageStats | `total`, `used`, `free`, `folder_used`, `usage_percent` | `usage_percent` measures the data directory against total disk capacity, matching the source. | | StorageStats | `total`, `used`, `free`, `folder_used`, `usage_percent` | `usage_percent` measures the data directory against total disk capacity, matching the source. |
| Settings retention | `backup_retention_days`, `log_retention_days` | 0 means an age threshold of zero days, matching the original retention function; it does not disable cleanup. | | Settings retention | `backup_retention_days`, `log_retention_days` | 0 means an age threshold of zero days, matching the original retention function; it does not disable cleanup. |
| Settings scheduling | `export_cron`, `binary_cron`, `retention_cron`, `enable_auto_export`, `connection_test_interval_minutes` | Five-field cron, configured timezone, Monday=0 as in APScheduler. | | Settings scheduling | `export_cron`, `binary_cron`, `retention_cron`, `enable_auto_export`, `connection_test_interval_minutes` | Five-field cron, configured timezone, Monday=0 as in APScheduler. |
| Settings device defaults | `global_ssh_key`, `default_switchos_username`, `default_switchos_password` | Revealing the global key requires password verification. | | Settings device defaults | `global_ssh_key`, `default_routeros_username`, `default_routeros_password`, `default_switchos_username`, `default_switchos_password` | RouterOS user/password fall back to the global defaults when a device field is blank. Revealing the global key requires password verification. |
| Settings notifications | `pushover_token`, `pushover_userkey`, `notify_failures_only`, `smtp_host`, `smtp_port`, `smtp_login`, `smtp_password`, `smtp_notifications_enabled`, `recipient_email` | SMTP, attachments, Pushover, provider tests, and failures-only mode. | | Settings notifications | `pushover_token`, `pushover_userkey`, `notify_failures_only`, `smtp_host`, `smtp_port`, `smtp_login`, `smtp_password`, `smtp_notifications_enabled`, `recipient_email` | SMTP, attachments, Pushover, provider tests, and failures-only mode. |
| Settings DTO | `id`, `has_global_ssh_key`, `has_default_switchos_credentials` | Additional `has_smtp_password`, `has_pushover_token`, `has_pushover_userkey`, and `has_default_switchos_password` flags support editing masked secrets. | | Settings DTO | `id`, `has_global_ssh_key`, `has_default_routeros_credentials`, `has_default_switchos_credentials` | Additional password/token presence flags support editing masked secrets without returning secret values. |
| Diff JSON | `left_backup_id`, `right_backup_id`, `left_file_name`, `right_file_name`, `diff_text`, `stats`, `lines` | Statistics: added/removed/modified/context. Lines: type, left_number/right_number, left_text/right_text. Standalone `diff_html` is generated only by the dedicated HTML endpoint to avoid duplicating large payloads in the browser response. | | Diff JSON | `left_backup_id`, `right_backup_id`, `left_file_name`, `right_file_name`, `diff_text`, `stats`, `lines` | Statistics: added/removed/modified/context. Lines: type, left_number/right_number, left_text/right_text. Standalone `diff_html` is generated only by the dedicated HTML endpoint to avoid duplicating large payloads in the browser response. |
| SchedulerStatus | `timezone`, `running`, `jobs` | Job fields: key, label, enabled, cron, description, description_params, valid, next_runs, error. | | SchedulerStatus | `timezone`, `running`, `jobs` | Job fields: key, label, enabled, cron, description, description_params, valid, next_runs, error. |
@@ -193,10 +191,7 @@ deployment extensions.
- Empty cron expressions disable their schedules. Invalid expressions, invalid - Empty cron expressions disable their schedules. Invalid expressions, invalid
ports, negative retention, and excessive request sizes are rejected. Jobs are ports, negative retention, and excessive request sizes are rejected. Jobs are
prevented from overlapping themselves. prevented from overlapping themselves.
- Revision 4 initializes only a fresh schema and validates an existing database - Version 0.0.2 validates an existing Rust database before use and automatically upgrades supported schema v1 databases to schema v2 in a transaction. Python import remains explicit, reads the source without modifying it, and requires a new destination. See [data-import.md](data-import.md).
before use. There are no startup migrations or backup-layout conversions. Python
import is explicit, reads the source without modifying it, and requires a new
destination. See [data-import.md](data-import.md).
- Runtime is one Rust process with SQLite, scheduling, and an embedded interface. - Runtime is one Rust process with SQLite, scheduling, and an embedded interface.
Python, Node, Angular, and nginx are not required application processes. Python, Node, Angular, and nginx are not required application processes.
+1 -1
View File
@@ -1,6 +1,6 @@
# Security and dependency notes # Security and dependency notes
Version 0.0.1 is distributed as source and uses the dependency versions pinned in `Cargo.lock`. Version 0.0.2 is distributed as source and uses the dependency versions pinned in `Cargo.lock`.
Key controls include: Key controls include:
+1 -1
View File
@@ -43,4 +43,4 @@ Do not treat an HTTP `200` login page as a valid backup.
## Scope ## Scope
Version 0.0.1 downloads SwOS backups. It does not restore/upload a SwOS configuration or reboot a switch. Version 0.0.2 downloads SwOS backups. It does not restore/upload a SwOS configuration or reboot a switch.
+1 -1
View File
@@ -1,4 +1,4 @@
# Interface — 0.0.1 # Interface — 0.0.2
The interface uses the application's real API. It does not populate sample devices, The interface uses the application's real API. It does not populate sample devices,
simulate operation results, or present disconnected actions as working features. simulate operation results, or present disconnected actions as working features.
+7 -7
View File
@@ -7,7 +7,8 @@ respective source revisions.
| Area | Current result | Scope | | Area | Current result | Scope |
| --- | --- | --- | | --- | --- | --- |
| JavaScript syntax | **PASS** | `node --check web/js/*.js` | | JavaScript syntax | **PASS** | `node --check web/js/*.js` |
| JavaScript unit tests | **16/16 PASS** | `node web/tests/core.test.cjs` | | JavaScript unit tests | **18/18 PASS** | `node web/tests/core.test.cjs` |
| SQLite schema migration | **PASS** | v1 fixture migrated to v2; existing SwitchOS credentials preserved; migrated `global_settings` column layout matches fresh schema |
| Rust compile/tests | **NOT RUN in this environment** | `cargo` / `rustc` are not installed in the available runtime; no current Rust pass is claimed | | Rust compile/tests | **NOT RUN in this environment** | `cargo` / `rustc` are not installed in the available runtime; no current Rust pass is claimed |
| Browser smoke/live tests | **NOT RUN in this environment** | Playwright is not installed; the test files could not start | | Browser smoke/live tests | **NOT RUN in this environment** | Playwright is not installed; the test files could not start |
| SwitchOS protocol comparison | **REVIEWED** | Compared directly with the supplied Python `SwosBetaService`; local `requests.HTTPDigestAuth` output was also checked for header formatting | | SwitchOS protocol comparison | **REVIEWED** | Compared directly with the supplied Python `SwosBetaService`; local `requests.HTTPDigestAuth` output was also checked for header formatting |
@@ -21,7 +22,7 @@ resolved on the affected firmware.
--- ---
# Validation report — 0.0.1, revision 4 # Validation report — 0.0.2
Date: **27 October 2026**. This report separates completed revision 4 verification Date: **27 October 2026**. This report separates completed revision 4 verification
from historical results. Historical checks are explicitly labeled and are not from historical results. Historical checks are explicitly labeled and are not
@@ -48,7 +49,7 @@ checked-in input files and translates project documentation into English.
| API documentation embedding | **PASS** | `/docs`, `/openapi.json`, local versioned assets, matching content hashes, custom prefix, and English documentation | | API documentation embedding | **PASS** | `/docs`, `/openapi.json`, local versioned assets, matching content hashes, custom prefix, and English documentation |
| Release-profile check | **PASS** | `cargo check --release --locked --offline` after removing all six previous generated documentation files; no `OUT_DIR/docs.*` files recreated | | Release-profile check | **PASS** | `cargo check --release --locked --offline` after removing all six previous generated documentation files; no `OUT_DIR/docs.*` files recreated |
| Formatting and static analysis | **PASS** | Package-scoped formatting check and `cargo clippy --all-targets --locked --offline -- -D warnings` | | Formatting and static analysis | **PASS** | Package-scoped formatting check and `cargo clippy --all-targets --locked --offline -- -D warnings` |
| JavaScript unit tests | **16/16 PASS** | Existing interface behavior, translations, forms, and credential-source controls | | JavaScript unit tests | **18/18 PASS** | Existing interface behavior, translations, forms, and credential-source controls |
| Chromium with actual Rust and a SwOS fixture | **8/8 PASS** | Global credentials, backup bytes/layout, HTML rejection, English Swagger rendering, public requests, and OAuth-authorized requests | | Chromium with actual Rust and a SwOS fixture | **8/8 PASS** | Global credentials, backup bytes/layout, HTML rejection, English Swagger rendering, public requests, and OAuth-authorized requests |
| Chromium with actual Rust navigation | **PASS** | Eight routes, settings redirects, responsive layout, CSP, no API/JavaScript errors, and sign-out | | Chromium with actual Rust navigation | **PASS** | Eight routes, settings redirects, responsive layout, CSP, no API/JavaScript errors, and sign-out |
| Dependency integrity | **PASS** | All 91 libssh2 checksum entries and 5 Swagger UI assets verified; Cargo.lock unchanged from the documented historical audit | | Dependency integrity | **PASS** | All 91 libssh2 checksum entries and 5 Swagger UI assets verified; Cargo.lock unchanged from the documented historical audit |
@@ -91,8 +92,7 @@ Evidence is recorded in `revision4-release-check.log`, `revision4-rust-tests.log
These are acceptance-environment reports, not files promised in the source archive. These are acceptance-environment reports, not files promised in the source archive.
The project contains the Rust, launcher, and interface regression tests. The project contains the Rust, launcher, and interface regression tests.
The installation sequence is documented in [data-import.md](data-import.md). The installation sequence is documented in [data-import.md](data-import.md). Version 0.0.2 adds automatic transactional migration from the supported Rust schema v1 to v2. Python/TypeScript conversion remains an explicit import.
There is no automatic upgrade, startup conversion, or migration recovery framework.
## Historical revision 3 results ## Historical revision 3 results
@@ -106,7 +106,7 @@ a revision 3 release build, Docker container, or physical MikroTik acceptance te
| HTTP contract tests | **11/11 PASS** | Actual Axum router and SQLite, authorization, file operations, and SwitchOS credential-source modes | | HTTP contract tests | **11/11 PASS** | Actual Axum router and SQLite, authorization, file operations, and SwitchOS credential-source modes |
| OpenAPI/documentation tests | **2/2 PASS** | All 36 operations, route/custom-prefix compatibility, and local Swagger assets without a CDN | | OpenAPI/documentation tests | **2/2 PASS** | All 36 operations, route/custom-prefix compatibility, and local Swagger assets without a CDN |
| Total Rust tests | **104/104 PASS** | 91 unit + 11 API + 2 OpenAPI; zero failures or skipped tests | | Total Rust tests | **104/104 PASS** | 91 unit + 11 API + 2 OpenAPI; zero failures or skipped tests |
| JavaScript tests | **16/16 PASS** | UI logic, forms, schedules, translations, and interface regressions | | JavaScript tests | **18/18 PASS** | UI logic, forms, schedules, translations, and interface regressions |
| Chromium workflow with controlled API | PASS | **113 requests** to a local fixture API, full interface workflow, no JavaScript errors | | Chromium workflow with controlled API | PASS | **113 requests** to a local fixture API, full interface workflow, no JavaScript errors |
| Final formatting | **PASS** | `cargo fmt --package mikrotik-backup -- --check` | | Final formatting | **PASS** | `cargo fmt --package mikrotik-backup -- --check` |
| Final Rust static analysis | **PASS** | `cargo clippy --all-targets --locked -- -D warnings` | | Final Rust static analysis | **PASS** | `cargo clippy --all-targets --locked -- -D warnings` |
@@ -314,7 +314,7 @@ pairs recognizable commands, and flags selected security/network/service section
Current JavaScript verification in the patch environment: Current JavaScript verification in the patch environment:
- `node --test web/tests/core.test.cjs`: **16/16 PASS**. - `node --test web/tests/core.test.cjs`: **18/18 PASS**.
- `node --check web/js/*.js`: **PASS**. - `node --check web/js/*.js`: **PASS**.
- Playwright browser acceptance: **NOT RUN** because the Playwright Node module is not installed. - Playwright browser acceptance: **NOT RUN** because the Playwright Node module is not installed.
- Rust compiler/tests: **NOT RUN** because `cargo` and `rustc` are unavailable. - Rust compiler/tests: **NOT RUN** because `cargo` and `rustc` are unavailable.
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
import os
import sys
import zipfile
import subprocess
from pathlib import Path
def run_git_command(args, repo_path: Path) -> bytes:
result = subprocess.run(
["git", *args],
cwd=repo_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout
def get_files_to_archive(repo_path: Path) -> list[str]:
output = run_git_command(
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
repo_path,
)
files = output.decode("utf-8", errors="surrogateescape").split("\0")
return [f for f in files if f]
def make_zip(repo_path: Path, output_zip: Path) -> None:
files = get_files_to_archive(repo_path)
output_zip = output_zip.resolve()
if output_zip.exists():
output_zip.unlink()
with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for rel_path in files:
abs_path = repo_path / rel_path
if not abs_path.exists():
continue
if abs_path.resolve() == output_zip:
continue
zf.write(abs_path, arcname=rel_path)
print(f"Utworzono archiwum: {output_zip}")
print(f"Dodano plików: {len(files)}")
def main():
repo_path = Path.cwd()
if len(sys.argv) > 1:
output_zip = Path(sys.argv[1])
else:
output_zip = repo_path / f"{repo_path.name}.zip"
try:
run_git_command(["rev-parse", "--show-toplevel"], repo_path)
except subprocess.CalledProcessError:
print("Błąd: ten katalog nie jest repozytorium Git.", file=sys.stderr)
sys.exit(1)
make_zip(repo_path, output_zip)
if __name__ == "__main__":
main()
@@ -0,0 +1,6 @@
-- Automatic migration from the 0.0.1 Rust schema (schema v1) to 0.0.2 (schema v2).
-- The application applies the equivalent steps transactionally at startup.
ALTER TABLE global_settings ADD COLUMN default_routeros_username TEXT;
ALTER TABLE global_settings ADD COLUMN default_routeros_password TEXT;
UPDATE schema_info SET schema_version = 2
WHERE id = 1 AND application = 'mikrotik-backup' AND schema_version = 1;
+8 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "MikroTik Backup System", "name": "MikroTik Backup System",
"version": "0.0.1", "version": "0.0.2",
"distribution": "source", "distribution": "source",
"prebuilt_binary": false, "prebuilt_binary": false,
"rust_toolchain": "1.99.1", "rust_toolchain": "1.99.1",
@@ -15,10 +15,12 @@
}, },
"data": { "data": {
"schema": "src/schema.sql", "schema": "src/schema.sql",
"automatic_migrations": false, "automatic_migrations": true,
"legacy_import": "explicit import-python command into a new destination", "legacy_import": "explicit import-python command into a new destination",
"binary_backups": "storage/backups/binary/<device_id>/", "binary_backups": "storage/backups/binary/<device_id>/",
"text_exports": "storage/backups/exports/<device_id>/" "text_exports": "storage/backups/exports/<device_id>/",
"schema_version": 2,
"migration_policy": "automatic transactional migration from supported Rust schema v1; Python/TypeScript import remains explicit"
}, },
"frontend_assets": { "frontend_assets": {
"strategy": "content-hashed filenames generated at build time", "strategy": "content-hashed filenames generated at build time",
@@ -34,10 +36,11 @@
}, },
"verification": { "verification": {
"javascript_syntax": "PASS in patch environment", "javascript_syntax": "PASS in patch environment",
"javascript_unit_tests": "16/16 PASS", "javascript_unit_tests": "18/18 PASS",
"rust_compile_tests": "NOT RUN in patch environment because cargo/rustc are unavailable", "rust_compile_tests": "NOT RUN in patch environment because cargo/rustc are unavailable",
"browser_tests": "NOT RUN in patch environment because Playwright is unavailable", "browser_tests": "NOT RUN in patch environment because Playwright is unavailable",
"physical_switchos_device": false, "physical_switchos_device": false,
"docker_runtime": false "docker_runtime": false,
"sqlite_schema_migration": "PASS: schema v1 -> v2 preserves existing settings and matches fresh schema column layout"
} }
} }
+91 -1
View File
@@ -3,15 +3,18 @@ use crate::{AppState, backup, diff, models::BackupRecord};
use axum::{ use axum::{
Json, Json,
body::Body, body::Body,
extract::{Path, Query, State}, extract::{Path, Query, Request, State},
http::{StatusCode, header}, http::{StatusCode, header},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use http_body_util::BodyExt;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use tokio::io::AsyncWriteExt;
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
io::{Seek, SeekFrom}, io::{Seek, SeekFrom},
path::Path as FsPath,
}; };
fn owned_backup(state: &AppState, user_id: i64, id: i64) -> ApiResult<BackupRecord> { fn owned_backup(state: &AppState, user_id: i64, id: i64) -> ApiResult<BackupRecord> {
@@ -224,6 +227,93 @@ pub(super) async fn upload(
.map_err(|e| ApiError::operation(&state, e))?; .map_err(|e| ApiError::operation(&state, e))?;
Ok(Json(json!({"message":"Backup uploaded to router"}))) Ok(Json(json!({"message":"Backup uploaded to router"})))
} }
#[derive(Deserialize)]
pub(super) struct UploadFileQuery {
filename: String,
}
pub(super) async fn upload_file(
State(state): State<AppState>,
Auth(user): Auth,
Path(id): Path<i64>,
Query(query): Query<UploadFileQuery>,
request: Request,
) -> ApiResult<Json<Value>> {
let router = owned_router(&state, user.id, id)?;
if router.device_type != "routeros" {
return Err(ApiError::bad(
"Files can be uploaded over SSH only to RouterOS devices",
));
}
let name = FsPath::new(query.filename.trim())
.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.is_empty())
.ok_or_else(|| ApiError::invalid("Uploaded file name is invalid"))?
.to_string();
let extension = FsPath::new(&name)
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if !matches!(extension.as_str(), "rsc" | "backup") {
return Err(ApiError::invalid(
"Only RouterOS .rsc and .backup files can be uploaded",
));
}
if name.len() > 240
|| name.starts_with('.')
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
{
return Err(ApiError::invalid(
"File name may contain only ASCII letters, digits, dot, dash and underscore",
));
}
let temp = tempfile::NamedTempFile::new_in(&state.config.data_dir)
.map_err(anyhow::Error::from)?;
let mut writer = tokio::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(temp.path())
.await
.map_err(anyhow::Error::from)?;
let mut body = request.into_body();
let mut size = 0u64;
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|_| ApiError::bad("Cannot read uploaded file"))?;
let Ok(chunk) = frame.into_data() else {
continue;
};
size = size.saturating_add(chunk.len() as u64);
if size > crate::routeros::MAX_BACKUP_BYTES {
return Err(ApiError(
StatusCode::PAYLOAD_TOO_LARGE,
"Uploaded file exceeds the 512 MiB limit".into(),
));
}
writer.write_all(&chunk).await.map_err(anyhow::Error::from)?;
}
writer.flush().await.map_err(anyhow::Error::from)?;
drop(writer);
if size == 0 {
return Err(ApiError::invalid("Uploaded file is empty"));
}
let source = temp.reopen().map_err(anyhow::Error::from)?;
backup::upload_local_file(&state, &router, source, &name)
.await
.map_err(|error| ApiError::operation(&state, error))?;
Ok(Json(json!({
"message": "File uploaded to device",
"file_name": name,
"file_size": size
})))
}
pub(super) async fn remove( pub(super) async fn remove(
State(state): State<AppState>, State(state): State<AppState>,
Auth(user): Auth, Auth(user): Auth,
-3
View File
@@ -144,9 +144,6 @@ fn apply(router: &mut RouterRecord, payload: RouterInput) -> ApiResult<()> {
if payload.clear_ssh_password { if payload.clear_ssh_password {
router.ssh_password = None; router.ssh_password = None;
} }
if router.device_type == "routeros" && router.ssh_user.is_empty() {
router.ssh_user = "admin".into();
}
if let Some(value) = payload.disable_all_backups { if let Some(value) = payload.disable_all_backups {
router.disable_all_backups = value; router.disable_all_backups = value;
} }
+9 -3
View File
@@ -49,6 +49,7 @@ impl ApiError {
if let Ok(settings) = state.db.get_settings() { if let Ok(settings) = state.db.get_settings() {
for secret in [ for secret in [
&settings.global_ssh_key, &settings.global_ssh_key,
&settings.default_routeros_password,
&settings.default_switchos_password, &settings.default_switchos_password,
&settings.smtp_password, &settings.smtp_password,
&settings.pushover_token, &settings.pushover_token,
@@ -138,6 +139,7 @@ pub fn router(state: AppState) -> Router {
"/backups/router/{id}/upload/{backup_id}", "/backups/router/{id}/upload/{backup_id}",
post(backups::upload), post(backups::upload),
) )
.route("/routers/{id}/files/upload", post(backups::upload_file))
.route("/backups/bulk", post(backups::bulk)) .route("/backups/bulk", post(backups::bulk))
.route("/backups/{id}", delete(backups::remove)) .route("/backups/{id}", delete(backups::remove))
.route("/backups/{id}/download", get(backups::download)) .route("/backups/{id}/download", get(backups::download))
@@ -186,7 +188,7 @@ pub fn router(state: AppState) -> Router {
.route(env!("APP_CSS_ROUTE"), get(stylesheet)) .route(env!("APP_CSS_ROUTE"), get(stylesheet))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
.fallback(index) .fallback(index)
.layer(DefaultBodyLimit::max(1024 * 1024)) .layer(DefaultBodyLimit::disable())
.layer(middleware::from_fn(bounded_request_body)) .layer(middleware::from_fn(bounded_request_body))
.layer(middleware::from_fn(security_headers)) .layer(middleware::from_fn(security_headers))
.layer(CompressionLayer::new()) .layer(CompressionLayer::new())
@@ -196,8 +198,8 @@ pub fn router(state: AppState) -> Router {
} }
async fn collect_request_body(body: Body, deadline: Duration) -> ApiResult<axum::body::Bytes> { async fn collect_request_body(body: Body, deadline: Duration) -> ApiResult<axum::body::Bytes> {
// All input endpoints accept small JSON/form bodies. Device uploads use // JSON/form endpoints are intentionally small. The dedicated device-file upload
// already stored backups, so there is no streaming HTTP upload to interrupt. // route bypasses this collector and is streamed with its own 512 MiB limit.
match tokio::time::timeout(deadline, axum::body::to_bytes(body, 1024 * 1024)).await { match tokio::time::timeout(deadline, axum::body::to_bytes(body, 1024 * 1024)).await {
Ok(Ok(bytes)) => Ok(bytes), Ok(Ok(bytes)) => Ok(bytes),
Ok(Err(error)) Ok(Err(error))
@@ -219,6 +221,10 @@ async fn collect_request_body(body: Body, deadline: Duration) -> ApiResult<axum:
} }
async fn bounded_request_body(request: axum::extract::Request, next: Next) -> Response { async fn bounded_request_body(request: axum::extract::Request, next: Next) -> Response {
let path = request.uri().path();
if path.ends_with("/files/upload") && path.contains("/routers/") {
return next.run(request).await;
}
let (parts, body) = request.into_parts(); let (parts, body) = request.into_parts();
match collect_request_body(body, Duration::from_secs(15)).await { match collect_request_body(body, Duration::from_secs(15)).await {
Ok(bytes) => { Ok(bytes) => {
+36 -9
View File
@@ -1,4 +1,3 @@
//! Backup lifecycle with device-level exclusion, bounded transfers and atomic files.
use crate::{ use crate::{
AppState, AppState,
config::{create_private_dir, protect_file}, config::{create_private_dir, protect_file},
@@ -108,8 +107,6 @@ fn resolve_path_in(data_dir: &Path, backup: &BackupRecord) -> Result<PathBuf> {
Ok(canonical) Ok(canonical)
} }
/// Stable paths use the database device ID, so renaming a device never moves
/// its backup directory. Export is the API value; exports is the disk folder.
pub(crate) fn layout_folder(kind: &str, router_id: i64) -> Result<PathBuf> { pub(crate) fn layout_folder(kind: &str, router_id: i64) -> Result<PathBuf> {
ensure!(router_id > 0, "Invalid backup device ID"); ensure!(router_id > 0, "Invalid backup device ID");
let kind = match kind { let kind = match kind {
@@ -207,8 +204,6 @@ pub(crate) fn unused_layout_path(data_dir: &Path, backup: &BackupRecord) -> Resu
ensure!(backup.id > 0, "Invalid backup ID"); ensure!(backup.id > 0, "Invalid backup ID");
let folder = layout_folder(&backup.backup_type, backup.router_id)?; let folder = layout_folder(&backup.backup_type, backup.router_id)?;
let directory = layout_directory(data_dir, &backup.backup_type, backup.router_id)?; let directory = layout_directory(data_dir, &backup.backup_type, backup.router_id)?;
// Keep the metadata name intact, including for old filesystem-limit names.
// Only the physical filename needs a bounded collision-resistant prefix.
let name = if backup.file_name.len() <= 200 { let name = if backup.file_name.len() <= 200 {
backup.file_name.clone() backup.file_name.clone()
} else { } else {
@@ -309,7 +304,6 @@ pub async fn run(state: &AppState, router: &RouterRecord, kind: &str) -> Result<
let state = state.clone(); let state = state.clone();
let router = router.clone(); let router = router.clone();
let kind = kind.to_owned(); let kind = kind.to_owned();
// This owned task holds the lock even if the requesting HTTP connection closes.
let workers = state.workers.clone(); let workers = state.workers.clone();
workers workers
.spawn(async move { .spawn(async move {
@@ -484,6 +478,42 @@ pub async fn run_all(state: &AppState, owner: Option<i64>, kind: &str) -> Result
)) ))
} }
pub async fn upload_local_file(
state: &AppState,
router: &RouterRecord,
source: File,
file_name: &str,
) -> Result<()> {
ensure!(
router.device_type == "routeros",
"File upload is supported only for RouterOS"
);
let guard = DeviceGuard::acquire(state, router.id)?;
let state = state.clone();
let router = router.clone();
let name = file_name.to_string();
let workers = state.workers.clone();
workers
.spawn(async move {
let _guard = guard;
let _permit = state.operations.acquire().await?;
let addresses = routeros::resolve_addresses(&state.config, &router).await?;
let settings = state.db.get_settings()?;
let config = state.config.clone();
let target = router.clone();
tokio::task::spawn_blocking(move || {
routeros::upload_sync(&config, &target, &settings, source, &name, addresses)
})
.await??;
state.db.log(&format!(
"File upload OK for device {} (file transfer only)",
router.name
))?;
Ok(())
})
.await?
}
pub async fn upload(state: &AppState, router: &RouterRecord, backup: &BackupRecord) -> Result<()> { pub async fn upload(state: &AppState, router: &RouterRecord, backup: &BackupRecord) -> Result<()> {
ensure!( ensure!(
router.device_type == "routeros", router.device_type == "routeros",
@@ -540,7 +570,6 @@ pub fn delete(state: &AppState, backup: &BackupRecord) -> Result<()> {
let path = match resolve_path(state, backup) { let path = match resolve_path(state, backup) {
Ok(path) => Some(path), Ok(path) => Some(path),
Err(error) => { Err(error) => {
// Missing files may have their stale metadata removed; unsafe paths must never be touched.
if error if error
.downcast_ref::<std::io::Error>() .downcast_ref::<std::io::Error>()
.is_some_and(|cause| cause.kind() == std::io::ErrorKind::NotFound) .is_some_and(|cause| cause.kind() == std::io::ErrorKind::NotFound)
@@ -577,8 +606,6 @@ pub fn delete(state: &AppState, backup: &BackupRecord) -> Result<()> {
fn restore_deleted_file(tombstone: &mut tempfile::TempPath, original: &Path) -> Result<()> { fn restore_deleted_file(tombstone: &mut tempfile::TempPath, original: &Path) -> Result<()> {
if let Err(error) = fs::rename(&*tombstone, original) { if let Err(error) = fs::rename(&*tombstone, original) {
// A failed DB delete must never cause TempPath::drop to erase the only
// active copy when its rollback rename also fails.
tombstone.disable_cleanup(true); tombstone.disable_cleanup(true);
bail!( bail!(
"Could not restore backup after failed metadata deletion; recovery file retained at {}: {error}", "Could not restore backup after failed metadata deletion; recovery file retained at {}: {error}",
+241 -43
View File
@@ -1,4 +1,3 @@
//! Current-schema SQLite storage and explicit, copy-only Python installation import.
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use fs2::FileExt; use fs2::FileExt;
use rusqlite::{Connection, OpenFlags, OptionalExtension, Row, params}; use rusqlite::{Connection, OpenFlags, OptionalExtension, Row, params};
@@ -22,8 +21,6 @@ pub struct Db {
connection: Arc<Mutex<Connection>>, connection: Arc<Mutex<Connection>>,
} }
/// Process-lifetime locks shared by serving and importing. The database lock
/// also covers installations that point different data directories at one DB.
pub struct StorageLock { pub struct StorageLock {
_files: Vec<fs::File>, _files: Vec<fs::File>,
} }
@@ -65,8 +62,6 @@ pub fn lock_storage(config: &Config) -> Result<StorageLock> {
} }
impl Db { impl Db {
/// A new file receives the current schema. Existing files are inspected
/// read-only before any writable connection is opened; no upgrades occur.
pub fn open(config: &Config) -> Result<Self> { pub fn open(config: &Config) -> Result<Self> {
let mut normalized = config.clone(); let mut normalized = config.clone();
normalize_storage_paths(&mut normalized)?; normalize_storage_paths(&mut normalized)?;
@@ -92,7 +87,7 @@ impl Db {
) )
.context("Cannot open current SQLite database")?; .context("Cannot open current SQLite database")?;
conn.busy_timeout(Duration::from_secs(15))?; conn.busy_timeout(Duration::from_secs(15))?;
// Recheck after reopening, before persistent pragmas or permissions. migrate_supported_schema(&conn).context(INCOMPATIBLE_DATABASE)?;
validate_current_schema(&conn).context(INCOMPATIBLE_DATABASE)?; validate_current_schema(&conn).context(INCOMPATIBLE_DATABASE)?;
protect_file(&config.database_path)?; protect_file(&config.database_path)?;
conn.pragma_update(None, "foreign_keys", true)?; conn.pragma_update(None, "foreign_keys", true)?;
@@ -203,13 +198,14 @@ impl Db {
} }
pub fn save_settings(&self, settings: &Settings) -> Result<()> { pub fn save_settings(&self, settings: &Settings) -> Result<()> {
self.with_conn(|conn| { self.with_conn(|conn| {
let changed = conn.execute("UPDATE global_settings SET backup_retention_days=?1,log_retention_days=?2,export_cron=?3,binary_cron=?4,retention_cron=?5,enable_auto_export=?6,connection_test_interval_minutes=?7,global_ssh_key=?8,default_switchos_username=?9,default_switchos_password=?10,pushover_token=?11,pushover_userkey=?12,notify_failures_only=?13,smtp_host=?14,smtp_port=?15,smtp_login=?16,smtp_password=?17,smtp_notifications_enabled=?18,recipient_email=?19 WHERE id=?20", params![ let changed = conn.execute("UPDATE global_settings SET backup_retention_days=?1,log_retention_days=?2,export_cron=?3,binary_cron=?4,retention_cron=?5,enable_auto_export=?6,connection_test_interval_minutes=?7,global_ssh_key=?8,default_routeros_username=?9,default_routeros_password=?10,default_switchos_username=?11,default_switchos_password=?12,pushover_token=?13,pushover_userkey=?14,notify_failures_only=?15,smtp_host=?16,smtp_port=?17,smtp_login=?18,smtp_password=?19,smtp_notifications_enabled=?20,recipient_email=?21 WHERE id=?22", params![
settings.backup_retention_days, settings.log_retention_days, settings.export_cron, settings.binary_cron, settings.backup_retention_days, settings.log_retention_days, settings.export_cron, settings.binary_cron,
settings.retention_cron, settings.enable_auto_export, settings.connection_test_interval_minutes, settings.retention_cron, settings.enable_auto_export, settings.connection_test_interval_minutes,
settings.global_ssh_key, settings.default_switchos_username, settings.default_switchos_password, settings.global_ssh_key, settings.default_routeros_username, settings.default_routeros_password,
settings.pushover_token, settings.pushover_userkey, settings.notify_failures_only, settings.smtp_host, settings.default_switchos_username, settings.default_switchos_password, settings.pushover_token,
settings.smtp_port, settings.smtp_login, settings.smtp_password, settings.smtp_notifications_enabled, settings.pushover_userkey, settings.notify_failures_only, settings.smtp_host, settings.smtp_port,
settings.recipient_email, settings.id])?; settings.smtp_login, settings.smtp_password, settings.smtp_notifications_enabled, settings.recipient_email,
settings.id])?;
if changed != 1 { bail!("Settings record no longer exists"); } if changed != 1 { bail!("Settings record no longer exists"); }
Ok(()) Ok(())
}) })
@@ -217,7 +213,9 @@ impl Db {
} }
const SCHEMA: &str = include_str!("schema.sql"); const SCHEMA: &str = include_str!("schema.sql");
const INCOMPATIBLE_DATABASE: &str = "Existing database is not the current Rust schema. Startup never upgrades data. Use a fresh DATA_DIR/DATABASE_PATH, or explicitly run import-python --source /path/to/python-app into a new destination"; const MIGRATION_V1_TO_V2: &str = include_str!("../migrations/004_routeros_default_credentials.sql");
const CURRENT_SCHEMA_VERSION: i64 = 2;
const INCOMPATIBLE_DATABASE: &str = "Existing database is neither the current schema nor a supported Rust schema that can be migrated automatically. Python/TypeScript installations still require import-python --source /path/to/python-app into a new destination";
const DATA_TABLES: [&str; 5] = [ const DATA_TABLES: [&str; 5] = [
"users", "users",
"routers", "routers",
@@ -236,7 +234,6 @@ struct Column {
} }
fn columns(conn: &Connection, table: &str) -> Result<Vec<Column>> { fn columns(conn: &Connection, table: &str) -> Result<Vec<Column>> {
// Callers pass only table names from this module's fixed schema.
let mut statement = conn.prepare(&format!("PRAGMA table_info({table})"))?; let mut statement = conn.prepare(&format!("PRAGMA table_info({table})"))?;
Ok(statement Ok(statement
.query_map([], |row| { .query_map([], |row| {
@@ -259,8 +256,6 @@ fn table_exists(conn: &Connection, table: &str) -> Result<bool> {
)?) )?)
} }
/// Inspect a selected existing database before storage locks or secrets are
/// created. SQLite sees only a private copy, including committed WAL content.
pub fn preflight_existing(config: &Config) -> Result<()> { pub fn preflight_existing(config: &Config) -> Result<()> {
match fs::symlink_metadata(&config.database_path) { match fs::symlink_metadata(&config.database_path) {
Ok(metadata) => { Ok(metadata) => {
@@ -275,7 +270,7 @@ pub fn preflight_existing(config: &Config) -> Result<()> {
&snapshot, &snapshot,
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NOFOLLOW, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NOFOLLOW,
)?; )?;
validate_current_schema(&conn).context(INCOMPATIBLE_DATABASE) validate_supported_startup_schema(&conn).context(INCOMPATIBLE_DATABASE)
} }
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).context("Cannot inspect database path"), Err(error) => Err(error).context("Cannot inspect database path"),
@@ -313,8 +308,6 @@ fn future_path(path: &Path) -> Result<PathBuf> {
Ok(resolved) Ok(resolved)
} }
/// Normalize paths before any mkdir or lock call, without touching the selected
/// database leaf (which must still be checked for symbolic links).
pub fn normalize_storage_paths(config: &mut Config) -> Result<()> { pub fn normalize_storage_paths(config: &mut Config) -> Result<()> {
config.data_dir = future_path(&config.data_dir)?; config.data_dir = future_path(&config.data_dir)?;
let parent = config let parent = config
@@ -416,24 +409,19 @@ fn check_integrity(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
fn validate_current_schema(conn: &Connection) -> Result<()> { fn schema_marker(conn: &Connection) -> Result<(String, i64, i64)> {
anyhow::ensure!( anyhow::ensure!(
table_exists(conn, "schema_info")?, table_exists(conn, "schema_info")?,
"Current schema marker is missing" "Current schema marker is missing"
); );
let marker: (String,i64,i64) = conn.query_row("SELECT application,schema_version,(SELECT COUNT(*) FROM schema_info) FROM schema_info WHERE id=1", [], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?; Ok(conn.query_row(
anyhow::ensure!( "SELECT application,schema_version,(SELECT COUNT(*) FROM schema_info) FROM schema_info WHERE id=1",
marker == ("mikrotik-backup".into(), 1, 1), [],
"Unsupported application schema version" |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
); )?)
let expected = Connection::open_in_memory()?; }
expected.execute_batch(SCHEMA)?;
for table in std::iter::once("schema_info").chain(DATA_TABLES) { fn validate_data_integrity(conn: &Connection) -> Result<()> {
anyhow::ensure!(
table_exists(conn, table)? && columns(conn, table)? == columns(&expected, table)?,
"Current schema table {table} is missing or incompatible"
);
}
check_integrity(conn)?; check_integrity(conn)?;
let users: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?; let users: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?;
let settings: i64 = let settings: i64 =
@@ -448,6 +436,114 @@ fn validate_current_schema(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
fn expected_schema() -> Result<Connection> {
let expected = Connection::open_in_memory()?;
expected.execute_batch(SCHEMA)?;
Ok(expected)
}
fn validate_current_schema(conn: &Connection) -> Result<()> {
let marker = schema_marker(conn)?;
anyhow::ensure!(
marker == ("mikrotik-backup".into(), CURRENT_SCHEMA_VERSION, 1),
"Unsupported application schema version"
);
let expected = expected_schema()?;
for table in std::iter::once("schema_info").chain(DATA_TABLES) {
anyhow::ensure!(
table_exists(conn, table)? && columns(conn, table)? == columns(&expected, table)?,
"Current schema table {table} is missing or incompatible"
);
}
validate_data_integrity(conn)
}
fn validate_migratable_v1_schema(conn: &Connection) -> Result<()> {
let marker = schema_marker(conn)?;
anyhow::ensure!(
marker == ("mikrotik-backup".into(), 1, 1),
"Unsupported application schema version"
);
let expected = expected_schema()?;
for table in std::iter::once("schema_info").chain(DATA_TABLES) {
anyhow::ensure!(
table_exists(conn, table)?,
"Schema table {table} is missing"
);
let actual_columns = columns(conn, table)?;
let expected_columns = columns(&expected, table)?;
if table == "global_settings" {
let legacy_columns = &expected_columns[..expected_columns.len() - 2];
anyhow::ensure!(
actual_columns.as_slice() == legacy_columns
|| actual_columns.as_slice() == expected_columns.as_slice(),
"Rust schema v1 global_settings table is incompatible"
);
} else {
anyhow::ensure!(
actual_columns == expected_columns,
"Rust schema v1 table {table} is incompatible"
);
}
}
validate_data_integrity(conn)
}
fn validate_supported_startup_schema(conn: &Connection) -> Result<()> {
let marker = schema_marker(conn)?;
anyhow::ensure!(
marker.0 == "mikrotik-backup" && marker.2 == 1,
"Unsupported application schema marker"
);
match marker.1 {
CURRENT_SCHEMA_VERSION => validate_current_schema(conn),
1 => validate_migratable_v1_schema(conn),
version => bail!("Unsupported application schema version {version}"),
}
}
fn migrate_supported_schema(conn: &Connection) -> Result<()> {
let marker = schema_marker(conn)?;
if marker == ("mikrotik-backup".into(), CURRENT_SCHEMA_VERSION, 1) {
return Ok(());
}
validate_migratable_v1_schema(conn)?;
let expected = expected_schema()?;
let current_settings = columns(conn, "global_settings")?;
let expected_settings = columns(&expected, "global_settings")?;
let legacy_settings = &expected_settings[..expected_settings.len() - 2];
let transaction = conn.unchecked_transaction()?;
if current_settings.as_slice() == legacy_settings {
transaction.execute_batch(MIGRATION_V1_TO_V2)?;
} else {
let changed = transaction.execute(
"UPDATE schema_info SET schema_version=?1 WHERE id=1 AND application='mikrotik-backup' AND schema_version=1",
[CURRENT_SCHEMA_VERSION],
)?;
anyhow::ensure!(
changed == 1,
"Schema version changed while applying migration"
);
}
let migrated_version: i64 = transaction.query_row(
"SELECT schema_version FROM schema_info WHERE id=1",
[],
|row| row.get(0),
)?;
anyhow::ensure!(
migrated_version == CURRENT_SCHEMA_VERSION,
"Schema migration did not advance to the current version"
);
transaction.commit()?;
tracing::info!(
schema_version = CURRENT_SCHEMA_VERSION,
"SQLite schema migrated"
);
Ok(())
}
fn initialize_database(config: &Config) -> Result<()> { fn initialize_database(config: &Config) -> Result<()> {
let parent = config let parent = config
.database_path .database_path
@@ -576,6 +672,8 @@ fn settings_row(row: &Row<'_>) -> rusqlite::Result<Settings> {
.get::<_, Option<i64>>("connection_test_interval_minutes")? .get::<_, Option<i64>>("connection_test_interval_minutes")?
.unwrap_or(0), .unwrap_or(0),
global_ssh_key: row.get("global_ssh_key")?, global_ssh_key: row.get("global_ssh_key")?,
default_routeros_username: row.get("default_routeros_username")?,
default_routeros_password: row.get("default_routeros_password")?,
default_switchos_username: row.get("default_switchos_username")?, default_switchos_username: row.get("default_switchos_username")?,
default_switchos_password: row.get("default_switchos_password")?, default_switchos_password: row.get("default_switchos_password")?,
pushover_token: row.get("pushover_token")?, pushover_token: row.get("pushover_token")?,
@@ -925,8 +1023,6 @@ fn destination_is_separate(config: &Config, source: &Path, selected: &PythonSour
Ok(()) Ok(())
} }
/// Explicit import only. Source options are relative to the selected application
/// directory; the runtime configuration determines the separate new target.
pub fn import_python( pub fn import_python(
config: &Config, config: &Config,
source: &Path, source: &Path,
@@ -958,8 +1054,6 @@ pub fn import_python(
); );
destination_is_separate(config, &source, &selected)?; destination_is_separate(config, &source, &selected)?;
let stage = tempfile::tempdir_in(&config.data_dir)?; let stage = tempfile::tempdir_in(&config.data_dir)?;
// Never open the original with SQLite: even read-only WAL connections can
// modify shared-memory sidecars. Reconstruct only the private stopped copy.
let source_snapshot = stage.path().join("python-source.sqlite"); let source_snapshot = stage.path().join("python-source.sqlite");
snapshot_stopped_database(&selected.database, &source_snapshot)?; snapshot_stopped_database(&selected.database, &source_snapshot)?;
let source_copy = Connection::open_with_flags( let source_copy = Connection::open_with_flags(
@@ -1075,9 +1169,6 @@ pub fn import_python(
fn publish_copy_no_clobber(source: &Path, destination: &Path, published: &mut bool) -> Result<()> { fn publish_copy_no_clobber(source: &Path, destination: &Path, published: &mut bool) -> Result<()> {
let parent = destination.parent().context("Destination has no parent")?; let parent = destination.parent().context("Destination has no parent")?;
create_private_dir(parent)?; create_private_dir(parent)?;
// Stage beside the destination so publication is atomic even if a custom
// DATABASE_PATH lives on another filesystem. Never overwrite an existing
// file or symlink, including one created after the earlier validation.
let mut target = tempfile::NamedTempFile::new_in(parent)?; let mut target = tempfile::NamedTempFile::new_in(parent)?;
std::io::copy(&mut fs::File::open(source)?, &mut target)?; std::io::copy(&mut fs::File::open(source)?, &mut target)?;
target.as_file().sync_all()?; target.as_file().sync_all()?;
@@ -1085,8 +1176,6 @@ fn publish_copy_no_clobber(source: &Path, destination: &Path, published: &mut bo
target target
.persist_noclobber(destination) .persist_noclobber(destination)
.map_err(|error| error.error)?; .map_err(|error| error.error)?;
// From this point the database may reference installed backups. A later
// directory-sync failure must not make the caller remove those files.
*published = true; *published = true;
crate::backup::sync_directory(parent)?; crate::backup::sync_directory(parent)?;
Ok(()) Ok(())
@@ -1178,6 +1267,36 @@ mod tests {
} }
} }
fn rust_v1_fixture(path: &Path) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
let conn = Connection::open(path).unwrap();
let v1_schema = SCHEMA
.replace(
"VALUES (1, 'mikrotik-backup', 2);",
"VALUES (1, 'mikrotik-backup', 1);",
)
.replace(
",\n default_routeros_username TEXT,\n default_routeros_password TEXT",
"",
);
conn.execute_batch(&v1_schema).unwrap();
conn.execute(
"INSERT INTO users(id,username,password_hash) VALUES(1,'admin','fixture-hash')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO global_settings(id,default_switchos_username,default_switchos_password) VALUES(1,'switch-admin','switch-secret')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO routers(id,owner_id,name,host) VALUES(23,1,'existing','192.0.2.1')",
[],
)
.unwrap();
}
fn local_source(root: &Path) -> (PathBuf, PathBuf, PathBuf) { fn local_source(root: &Path) -> (PathBuf, PathBuf, PathBuf) {
let source = root.join("python-app"); let source = root.join("python-app");
let storage = source.join("storage"); let storage = source.join("storage");
@@ -1194,7 +1313,7 @@ mod tests {
} }
#[test] #[test]
fn fresh_initialization_and_current_schema_restart_do_not_upgrade_or_relocate() { fn fresh_initialization_and_current_schema_restart_do_not_relocate() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let config = config(dir.path()); let config = config(dir.path());
let db = Db::open(&config).unwrap(); let db = Db::open(&config).unwrap();
@@ -1225,6 +1344,85 @@ mod tests {
assert_eq!(directory_names(dir.path()), files_before); assert_eq!(directory_names(dir.path()), files_before);
} }
#[test]
fn startup_migrates_rust_v1_schema_and_preserves_data() {
let dir = tempfile::tempdir().unwrap();
let config = config(dir.path());
rust_v1_fixture(&config.database_path);
let before_preflight = fs::read(&config.database_path).unwrap();
preflight_existing(&config).unwrap();
assert_eq!(fs::read(&config.database_path).unwrap(), before_preflight);
let db = Db::open(&config).unwrap();
assert_eq!(db.router(23).unwrap().unwrap().name, "existing");
let settings = db.get_settings().unwrap();
assert_eq!(
settings.default_switchos_username.as_deref(),
Some("switch-admin")
);
assert_eq!(
settings.default_switchos_password.as_deref(),
Some("switch-secret")
);
assert_eq!(settings.default_routeros_username, None);
assert_eq!(settings.default_routeros_password, None);
db.with_conn(|conn| {
let version: i64 = conn.query_row(
"SELECT schema_version FROM schema_info WHERE id=1",
[],
|row| row.get(0),
)?;
assert_eq!(version, CURRENT_SCHEMA_VERSION);
let expected = expected_schema()?;
assert_eq!(
columns(conn, "global_settings")?,
columns(&expected, "global_settings")?
);
Ok(())
})
.unwrap();
}
#[test]
fn startup_accepts_previously_manual_routeros_columns_and_only_advances_marker() {
let dir = tempfile::tempdir().unwrap();
let config = config(dir.path());
rust_v1_fixture(&config.database_path);
{
let conn = Connection::open(&config.database_path).unwrap();
conn.execute_batch(
"ALTER TABLE global_settings ADD COLUMN default_routeros_username TEXT;
ALTER TABLE global_settings ADD COLUMN default_routeros_password TEXT;
CREATE TABLE rust_schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
INSERT INTO rust_schema_migrations(version) VALUES(4);
UPDATE global_settings SET default_routeros_username='router-admin', default_routeros_password='router-secret' WHERE id=1;",
)
.unwrap();
}
let db = Db::open(&config).unwrap();
let settings = db.get_settings().unwrap();
assert_eq!(
settings.default_routeros_username.as_deref(),
Some("router-admin")
);
assert_eq!(
settings.default_routeros_password.as_deref(),
Some("router-secret")
);
db.with_conn(|conn| {
let version: i64 = conn.query_row(
"SELECT schema_version FROM schema_info WHERE id=1",
[],
|row| row.get(0),
)?;
assert_eq!(version, CURRENT_SCHEMA_VERSION);
Ok(())
})
.unwrap();
}
#[test] #[test]
fn startup_rejects_python_database_without_changing_bytes_permissions_or_files() { fn startup_rejects_python_database_without_changing_bytes_permissions_or_files() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1238,7 +1436,7 @@ mod tests {
Ok(_) => panic!("legacy database was accepted"), Ok(_) => panic!("legacy database was accepted"),
Err(error) => error, Err(error) => error,
}; };
assert!(error.to_string().contains("Startup never upgrades")); assert!(error.to_string().contains("supported Rust schema"));
assert!(error.to_string().contains("import-python")); assert!(error.to_string().contains("import-python"));
assert_eq!(fs::read(&config.database_path).unwrap(), before); assert_eq!(fs::read(&config.database_path).unwrap(), before);
assert_eq!( assert_eq!(
+98 -32
View File
@@ -1,4 +1,3 @@
//! Text-only side-by-side and unified diffs; all HTML output is escaped.
use crate::{AppState, backup, models::BackupRecord}; use crate::{AppState, backup, models::BackupRecord};
use anyhow::{Result, ensure}; use anyhow::{Result, ensure};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -14,19 +13,52 @@ pub fn escape(value: &str) -> String {
.replace('\'', "&#39;") .replace('\'', "&#39;")
} }
fn standalone_diff_html(diff_text: &str, left_name: &str, right_name: &str) -> String {
let mut html = format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Diff: {} ↔ {}</title><style>:root{{color-scheme:light dark}}*{{box-sizing:border-box}}body{{margin:0;background:#f3f3f3;color:#111;font:13px/1.45 ui-monospace,SFMono-Regular,Consolas,\"Liberation Mono\",monospace}}header{{position:sticky;top:0;padding:10px 14px;border-bottom:1px solid #aaa;background:#e7e7e7;z-index:1}}h1{{font:700 14px/1.2 inherit;margin:0 0 5px}}.meta{{color:#444;font-size:12px}}pre{{margin:0;padding:10px 0;overflow:auto;background:#fff;min-height:calc(100vh - 58px)}}.line{{display:block;min-width:max-content;padding:0 14px;white-space:pre}}.head{{font-weight:700;background:#ddd}}.hunk{{color:#0047a3;background:#e8f0ff}}.add{{background:#e4f7df;color:#164b0f}}.del{{background:#ffe0e0;color:#7d1717}}.empty{{padding:18px 14px;color:#555}}@media(prefers-color-scheme:dark){{body{{background:#141414;color:#ddd}}header{{background:#222;border-color:#444}}.meta{{color:#aaa}}pre{{background:#111}}.head{{background:#292929}}.hunk{{background:#14243d;color:#9fc4ff}}.add{{background:#18351c;color:#bde6b7}}.del{{background:#3b1b1b;color:#ffc0c0}}.empty{{color:#aaa}}}}</style></head><body><header><h1>Configuration diff</h1><div class=\"meta\">{} ↔ {}</div></header><pre>",
escape(left_name),
escape(right_name),
escape(left_name),
escape(right_name)
);
if diff_text.is_empty() {
html.push_str("<span class=\"empty\">No differences.</span>");
} else {
for line in diff_text.lines() {
let class = if line.starts_with("--- ") || line.starts_with("+++ ") {
"line head"
} else if line.starts_with("@@ ") {
"line hunk"
} else if line.starts_with('+') {
"line add"
} else if line.starts_with('-') {
"line del"
} else {
"line"
};
html.push_str(&format!(
"<span class=\"{}\">{}</span>\n",
class,
escape(line)
));
}
}
html.push_str("</pre></body></html>");
html
}
pub fn compare(state: &AppState, left: &BackupRecord, right: &BackupRecord) -> Result<Value> { pub fn compare(state: &AppState, left: &BackupRecord, right: &BackupRecord) -> Result<Value> {
compare_files(state, left, right, true, true) compare_files(state, left, right, true, true)
} }
/// Compatibility payload used by the public API and tests: unified text is pub fn compare_browser(
/// retained, while the standalone HTML document is omitted. state: &AppState,
pub fn compare_browser(state: &AppState, left: &BackupRecord, right: &BackupRecord) -> Result<Value> { left: &BackupRecord,
right: &BackupRecord,
) -> Result<Value> {
compare_files(state, left, right, true, false) compare_files(state, left, right, true, false)
} }
/// Compact browser payload used by the web UI. The browser renders unified
/// rows from structured data, so sending another complete textual diff would
/// duplicate large configurations in memory and over the wire.
pub fn compare_browser_compact( pub fn compare_browser_compact(
state: &AppState, state: &AppState,
left: &BackupRecord, left: &BackupRecord,
@@ -146,31 +178,28 @@ fn compare_text_options(
"lines": rows, "lines": rows,
"smart": smart_analysis(left, right) "smart": smart_analysis(left, right)
}); });
if include_text { let unified = (include_text || include_html).then(|| {
result["diff_text"] = json!(unified_diff( unified_diff(
&operations, &operations,
&left_lines, &left_lines,
&right_lines, &right_lines,
left_name, left_name,
right_name, right_name,
)); )
});
if include_text {
result["diff_text"] = json!(unified.as_deref().unwrap_or(""));
} }
if include_html { if include_html {
let mut html = format!( result["diff_html"] = json!(standalone_diff_html(
"<!doctype html><html lang=\"pl\"><meta charset=\"utf-8\"><title>Configuration comparison</title><meta name=\"viewport\" content=\"width=device-width\"><style>body{{font:14px monospace;background:#071624;color:#d7e9fa;margin:20px}}table{{border-collapse:collapse;width:100%;table-layout:fixed}}th,td{{border:1px solid #244158;padding:6px;vertical-align:top;white-space:pre-wrap;overflow-wrap:anywhere}}.n{{width:4em;color:#88a8bf}}.added{{background:#083b32}}.removed{{background:#4d2431}}.modified{{background:#493c20}}</style><h1>Configuration comparison</h1><table><thead><tr><th class=\"n\">Line</th><th>{}</th><th class=\"n\">Line</th><th>{}</th></tr></thead><tbody>", unified.as_deref().unwrap_or(""),
escape(left_name), left_name,
escape(right_name) right_name,
); ));
for row in result["lines"].as_array().expect("diff lines are an array") {
html.push_str(&format!("<tr class=\"{}\"><td class=\"n\">{}</td><td>{}</td><td class=\"n\">{}</td><td>{}</td></tr>",row["type"].as_str().unwrap_or("context"),row["left_number"].as_i64().map(|i|i.to_string()).unwrap_or_default(),escape(row["left_text"].as_str().unwrap_or("")),row["right_number"].as_i64().map(|i|i.to_string()).unwrap_or_default(),escape(row["right_text"].as_str().unwrap_or(""))));
}
html.push_str("</tbody></table></html>");
result["diff_html"] = json!(html);
} }
Ok(result) Ok(result)
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct SmartCommand { struct SmartCommand {
section: String, section: String,
@@ -192,11 +221,20 @@ fn smart_analysis(left: &str, right: &str) -> Value {
let mut attention_sections = 0usize; let mut attention_sections = 0usize;
for section in names { for section in names {
let left_items: Vec<_> = left_commands.iter().filter(|item| item.section == section).collect(); let left_items: Vec<_> = left_commands
let right_items: Vec<_> = right_commands.iter().filter(|item| item.section == section).collect(); .iter()
.filter(|item| item.section == section)
.collect();
let right_items: Vec<_> = right_commands
.iter()
.filter(|item| item.section == section)
.collect();
let mut right_exact: HashMap<String, Vec<usize>> = HashMap::new(); let mut right_exact: HashMap<String, Vec<usize>> = HashMap::new();
for (index, item) in right_items.iter().enumerate() { for (index, item) in right_items.iter().enumerate() {
right_exact.entry(item.normalized.clone()).or_default().push(index); right_exact
.entry(item.normalized.clone())
.or_default()
.push(index);
} }
let mut right_used = vec![false; right_items.len()]; let mut right_used = vec![false; right_items.len()];
let mut left_unmatched = Vec::new(); let mut left_unmatched = Vec::new();
@@ -219,10 +257,16 @@ fn smart_analysis(left: &str, right: &str) -> Value {
let mut left_by_key: HashMap<String, Vec<&SmartCommand>> = HashMap::new(); let mut left_by_key: HashMap<String, Vec<&SmartCommand>> = HashMap::new();
let mut right_by_key: HashMap<String, Vec<&SmartCommand>> = HashMap::new(); let mut right_by_key: HashMap<String, Vec<&SmartCommand>> = HashMap::new();
for item in &left_unmatched { for item in &left_unmatched {
left_by_key.entry(item.identity.clone()).or_default().push(*item); left_by_key
.entry(item.identity.clone())
.or_default()
.push(*item);
} }
for item in &right_unmatched { for item in &right_unmatched {
right_by_key.entry(item.identity.clone()).or_default().push(*item); right_by_key
.entry(item.identity.clone())
.or_default()
.push(*item);
} }
let mut paired_left = BTreeSet::new(); let mut paired_left = BTreeSet::new();
@@ -230,7 +274,9 @@ fn smart_analysis(left: &str, right: &str) -> Value {
let mut details = Vec::new(); let mut details = Vec::new();
let mut modified = 0usize; let mut modified = 0usize;
for (key, old_items) in &left_by_key { for (key, old_items) in &left_by_key {
let Some(new_items) = right_by_key.get(key) else { continue }; let Some(new_items) = right_by_key.get(key) else {
continue;
};
if old_items.len() != 1 || new_items.len() != 1 || key.starts_with("raw:") { if old_items.len() != 1 || new_items.len() != 1 || key.starts_with("raw:") {
continue; continue;
} }
@@ -264,7 +310,10 @@ fn smart_analysis(left: &str, right: &str) -> Value {
if added == 0 && removed == 0 && modified == 0 { if added == 0 && removed == 0 && modified == 0 {
continue; continue;
} }
for item in removed_items.iter().take(12usize.saturating_sub(details.len())) { for item in removed_items
.iter()
.take(12usize.saturating_sub(details.len()))
{
details.push(json!({ details.push(json!({
"kind": "removed", "kind": "removed",
"verb": item.verb.as_str(), "verb": item.verb.as_str(),
@@ -272,7 +321,10 @@ fn smart_analysis(left: &str, right: &str) -> Value {
"before": truncate_command(&item.normalized) "before": truncate_command(&item.normalized)
})); }));
} }
for item in added_items.iter().take(12usize.saturating_sub(details.len())) { for item in added_items
.iter()
.take(12usize.saturating_sub(details.len()))
{
details.push(json!({ details.push(json!({
"kind": "added", "kind": "added",
"verb": item.verb.as_str(), "verb": item.verb.as_str(),
@@ -359,7 +411,13 @@ fn parse_rsc_commands(text: &str) -> (Vec<SmartCommand>, usize) {
let verb = tokens.first().cloned().unwrap_or_default(); let verb = tokens.first().cloned().unwrap_or_default();
let fields = assignment_fields(&tokens); let fields = assignment_fields(&tokens);
let identity = semantic_identity(&verb, &tokens, &fields, &normalized); let identity = semantic_identity(&verb, &tokens, &fields, &normalized);
commands.push(SmartCommand { section, verb, normalized, identity, fields }); commands.push(SmartCommand {
section,
verb,
normalized,
identity,
fields,
});
} }
(commands, ignored) (commands, ignored)
} }
@@ -440,7 +498,9 @@ fn shellish_tokens(value: &str) -> Vec<String> {
fn assignment_fields(tokens: &[String]) -> BTreeMap<String, String> { fn assignment_fields(tokens: &[String]) -> BTreeMap<String, String> {
let mut fields = BTreeMap::new(); let mut fields = BTreeMap::new();
for token in tokens.iter().skip(1) { for token in tokens.iter().skip(1) {
let Some((key, value)) = token.split_once('=') else { continue }; let Some((key, value)) = token.split_once('=') else {
continue;
};
if !key.is_empty() { if !key.is_empty() {
fields.insert(key.to_string(), value.to_string()); fields.insert(key.to_string(), value.to_string());
} }
@@ -568,7 +628,10 @@ fn classify_section(section: &str) -> (&'static str, &'static str) {
{ {
return ("medium", "network"); return ("medium", "network");
} }
if name.starts_with("/ip dns") || name.starts_with("/snmp") || name.starts_with("/system logging") { if name.starts_with("/ip dns")
|| name.starts_with("/snmp")
|| name.starts_with("/system logging")
{
return ("medium", "services"); return ("medium", "services");
} }
("low", "other") ("low", "other")
@@ -846,6 +909,9 @@ mod tests {
assert!(!html.contains("<script>")); assert!(!html.contains("<script>"));
assert!(html.contains("&lt;script&gt;")); assert!(html.contains("&lt;script&gt;"));
assert!(!html.contains("<img src=x>")); assert!(!html.contains("<img src=x>"));
assert!(html.contains("<pre>"));
assert!(html.contains("line del"));
assert!(html.contains("line add"));
} }
#[test] #[test]
fn final_newline_and_crlf_are_normalized() { fn final_newline_and_crlf_are_normalized() {
-7
View File
@@ -11,23 +11,16 @@ struct Arguments {
} }
#[derive(Subcommand)] #[derive(Subcommand)]
enum Command { enum Command {
/// Run API, web interface, and background schedules.
Serve, Serve,
/// Check the running service and exit with status 0/1.
Healthcheck, Healthcheck,
/// Explicitly import a stopped Python/TypeScript installation into a new target.
ImportPython { ImportPython {
/// Python/TypeScript application directory.
#[arg(long)] #[arg(long)]
source: PathBuf, source: PathBuf,
/// Source SQLite file; relative paths are resolved against --source.
#[arg(long)] #[arg(long)]
database: Option<PathBuf>, database: Option<PathBuf>,
/// Source backup directory; relative paths are resolved against --source.
#[arg(long)] #[arg(long)]
backup_root: Option<PathBuf>, backup_root: Option<PathBuf>,
}, },
/// Validate runtime configuration without starting the server.
CheckConfig, CheckConfig,
} }
+62 -9
View File
@@ -1,4 +1,3 @@
//! Database records. Secret fields are deliberately excluded from JSON and Debug output.
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -30,7 +29,6 @@ pub struct RouterRecord {
pub host: String, pub host: String,
pub port: i64, pub port: i64,
pub ssh_user: String, pub ssh_user: String,
/// None preserves the original per-field fallback for migrated devices.
#[serde(default)] #[serde(default)]
pub use_global_switchos_credentials: Option<bool>, pub use_global_switchos_credentials: Option<bool>,
#[serde(skip_serializing)] #[serde(skip_serializing)]
@@ -87,8 +85,6 @@ impl RouterRecord {
} }
} }
/// Resolve the exact pair at operation time. Explicit modes never mix
/// sources, and selecting one source does not overwrite the other source.
pub fn switchos_credentials<'a>( pub fn switchos_credentials<'a>(
&'a self, &'a self,
settings: &'a Settings, settings: &'a Settings,
@@ -101,18 +97,56 @@ impl RouterRecord {
None => "SwitchOS username is not configured on device or globally", None => "SwitchOS username is not configured on device or globally",
}) })
})?; })?;
// SwOS permits an empty password; it remains from the selected source.
Ok((username, password)) Ok((username, password))
} }
pub fn routeros_credentials<'a>(&'a self, settings: &'a Settings) -> (&'a str, &'a str) {
let username = (!self.ssh_user.trim().is_empty())
.then_some(self.ssh_user.as_str())
.or(settings
.default_routeros_username
.as_deref()
.filter(|value| !value.trim().is_empty()))
.unwrap_or("admin");
let password = self
.ssh_password
.as_deref()
.filter(|value| !value.is_empty())
.or(settings
.default_routeros_password
.as_deref()
.filter(|value| !value.is_empty()))
.unwrap_or("");
(username, password)
}
pub fn public_json(&self, settings: &Settings) -> Value { pub fn public_json(&self, settings: &Settings) -> Value {
let is_routeros = self.device_type == "routeros"; let is_routeros = self.device_type == "routeros";
let own_key = present(&self.ssh_key); let own_key = present(&self.ssh_key);
let global_key = present(&settings.global_ssh_key); let global_key = present(&settings.global_ssh_key);
let own_user = (!self.ssh_user.trim().is_empty()).then_some(self.ssh_user.as_str()); let own_user = (!self.ssh_user.trim().is_empty()).then_some(self.ssh_user.as_str());
let own_password = self
.ssh_password
.as_deref()
.filter(|value| !value.is_empty());
let default_routeros_user = settings
.default_routeros_username
.as_deref()
.filter(|value| !value.trim().is_empty());
let default_routeros_password = settings
.default_routeros_password
.as_deref()
.filter(|value| !value.is_empty());
let (switchos_user, switchos_password, uses_global_switchos) = let (switchos_user, switchos_password, uses_global_switchos) =
self.switchos_credential_fields(settings); self.switchos_credential_fields(settings);
let effective_username = if is_routeros { own_user } else { switchos_user }; let effective_username = if is_routeros {
Some(self.routeros_credentials(settings).0)
} else {
switchos_user
};
let uses_default_routeros_credentials = is_routeros
&& ((own_user.is_none() && default_routeros_user.is_some())
|| (own_password.is_none() && default_routeros_password.is_some()));
let mut value = serde_json::to_value(self).expect("record is serializable"); let mut value = serde_json::to_value(self).expect("record is serializable");
let object = value.as_object_mut().expect("record is an object"); let object = value.as_object_mut().expect("record is an object");
object.insert("ssh_key".into(), Value::Null); object.insert("ssh_key".into(), Value::Null);
@@ -137,11 +171,15 @@ impl RouterRecord {
object.insert( object.insert(
"has_effective_password".into(), "has_effective_password".into(),
json!(if is_routeros { json!(if is_routeros {
present(&self.ssh_password) own_password.is_some() || default_routeros_password.is_some()
} else { } else {
!switchos_password.is_empty() !switchos_password.is_empty()
}), }),
); );
object.insert(
"uses_default_routeros_credentials".into(),
json!(uses_default_routeros_credentials),
);
object.insert("supports_export".into(), json!(is_routeros)); object.insert("supports_export".into(), json!(is_routeros));
object.insert("supports_restore_upload".into(), json!(is_routeros)); object.insert("supports_restore_upload".into(), json!(is_routeros));
value value
@@ -179,6 +217,9 @@ pub struct Settings {
pub connection_test_interval_minutes: i64, pub connection_test_interval_minutes: i64,
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub global_ssh_key: Option<String>, pub global_ssh_key: Option<String>,
pub default_routeros_username: Option<String>,
#[serde(skip_serializing)]
pub default_routeros_password: Option<String>,
pub default_switchos_username: Option<String>, pub default_switchos_username: Option<String>,
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub default_switchos_password: Option<String>, pub default_switchos_password: Option<String>,
@@ -208,6 +249,8 @@ impl Default for Settings {
enable_auto_export: false, enable_auto_export: false,
connection_test_interval_minutes: 0, connection_test_interval_minutes: 0,
global_ssh_key: None, global_ssh_key: None,
default_routeros_username: None,
default_routeros_password: None,
default_switchos_username: None, default_switchos_username: None,
default_switchos_password: None, default_switchos_password: None,
pushover_token: None, pushover_token: None,
@@ -229,6 +272,7 @@ impl Settings {
let object = value.as_object_mut().expect("record is an object"); let object = value.as_object_mut().expect("record is an object");
for (name, field) in [ for (name, field) in [
("global_ssh_key", &self.global_ssh_key), ("global_ssh_key", &self.global_ssh_key),
("default_routeros_password", &self.default_routeros_password),
("default_switchos_password", &self.default_switchos_password), ("default_switchos_password", &self.default_switchos_password),
("pushover_token", &self.pushover_token), ("pushover_token", &self.pushover_token),
("pushover_userkey", &self.pushover_userkey), ("pushover_userkey", &self.pushover_userkey),
@@ -237,6 +281,13 @@ impl Settings {
object.insert(name.into(), Value::Null); object.insert(name.into(), Value::Null);
object.insert(format!("has_{name}"), json!(present(field))); object.insert(format!("has_{name}"), json!(present(field)));
} }
object.insert(
"has_default_routeros_credentials".into(),
json!(
present(&self.default_routeros_username)
|| present(&self.default_routeros_password)
),
);
object.insert( object.insert(
"has_default_switchos_credentials".into(), "has_default_switchos_credentials".into(),
json!( json!(
@@ -247,8 +298,6 @@ impl Settings {
value value
} }
/// Merge a partial API update. Blank/missing secrets preserve stored values;
/// explicit `clear_<field>: true` clears them. No update can overwrite `id`.
pub fn apply_patch(&mut self, patch: &Value) -> Result<()> { pub fn apply_patch(&mut self, patch: &Value) -> Result<()> {
let object = patch let object = patch
.as_object() .as_object()
@@ -257,6 +306,7 @@ impl Settings {
let merged = value.as_object_mut().expect("record is an object"); let merged = value.as_object_mut().expect("record is an object");
for (name, field) in [ for (name, field) in [
("global_ssh_key", &self.global_ssh_key), ("global_ssh_key", &self.global_ssh_key),
("default_routeros_password", &self.default_routeros_password),
("default_switchos_password", &self.default_switchos_password), ("default_switchos_password", &self.default_switchos_password),
("pushover_token", &self.pushover_token), ("pushover_token", &self.pushover_token),
("pushover_userkey", &self.pushover_userkey), ("pushover_userkey", &self.pushover_userkey),
@@ -266,6 +316,7 @@ impl Settings {
} }
let secrets = [ let secrets = [
"global_ssh_key", "global_ssh_key",
"default_routeros_password",
"default_switchos_password", "default_switchos_password",
"pushover_token", "pushover_token",
"pushover_userkey", "pushover_userkey",
@@ -293,6 +344,8 @@ impl Settings {
candidate.retention_cron = candidate.retention_cron.trim().into(); candidate.retention_cron = candidate.retention_cron.trim().into();
for item in [ for item in [
&mut candidate.global_ssh_key, &mut candidate.global_ssh_key,
&mut candidate.default_routeros_username,
&mut candidate.default_routeros_password,
&mut candidate.default_switchos_username, &mut candidate.default_switchos_username,
&mut candidate.default_switchos_password, &mut candidate.default_switchos_password,
&mut candidate.pushover_token, &mut candidate.pushover_token,
+4 -4
View File
@@ -1,4 +1,3 @@
//! SMTP and Pushover providers. Notification failures never turn a completed backup into a failed one.
use crate::{ use crate::{
AppState, AppState,
models::{RouterRecord, Settings}, models::{RouterRecord, Settings},
@@ -192,10 +191,10 @@ pub async fn test_pushover(state: &AppState) -> Result<()> {
.await .await
} }
/// Scrub all configured secrets before error strings enter logs or outgoing notifications.
pub fn redact_error(message: &str, router: Option<&RouterRecord>, settings: &Settings) -> String { pub fn redact_error(message: &str, router: Option<&RouterRecord>, settings: &Settings) -> String {
let mut secrets: Vec<&str> = [ let mut secrets: Vec<&str> = [
settings.global_ssh_key.as_deref(), settings.global_ssh_key.as_deref(),
settings.default_routeros_password.as_deref(),
settings.default_switchos_password.as_deref(), settings.default_switchos_password.as_deref(),
settings.smtp_password.as_deref(), settings.smtp_password.as_deref(),
settings.pushover_token.as_deref(), settings.pushover_token.as_deref(),
@@ -238,13 +237,14 @@ mod tests {
fn error_redaction_covers_global_secret_fields() { fn error_redaction_covers_global_secret_fields() {
let s = Settings { let s = Settings {
global_ssh_key: Some("private-key".into()), global_ssh_key: Some("private-key".into()),
default_routeros_password: Some("router-secret".into()),
smtp_password: Some("smtp-secret".into()), smtp_password: Some("smtp-secret".into()),
pushover_token: Some("push-token".into()), pushover_token: Some("push-token".into()),
..Settings::default() ..Settings::default()
}; };
assert_eq!( assert_eq!(
redact_error("private-key smtp-secret push-token", None, &s), redact_error("private-key router-secret smtp-secret push-token", None, &s),
"[redacted] [redacted] [redacted]" "[redacted] [redacted] [redacted] [redacted]"
); );
} }
#[tokio::test] #[tokio::test]
+63 -50
View File
@@ -1,4 +1,3 @@
//! Bounded RouterOS SSH/SCP and SwitchOS HTTP adapters. No commands run through a local shell.
use crate::{ use crate::{
AppState, AppState,
config::Config, config::Config,
@@ -237,7 +236,6 @@ fn connect(
.context("SSH adapter requires a Tokio runtime")? .context("SSH adapter requires a Tokio runtime")?
.spawn(async move { .spawn(async move {
tokio::time::sleep(timeout).await; tokio::time::sleep(timeout).await;
// Closing the shared socket interrupts a blocked libssh2 call, including slow streams.
let _ = peer.shutdown(Shutdown::Both); let _ = peer.shutdown(Shutdown::Both);
}); });
let mut session = SshConnection { session, deadline }; let mut session = SshConnection { session, deadline };
@@ -254,16 +252,14 @@ fn connect(
.global_ssh_key .global_ssh_key
.as_deref() .as_deref()
.filter(|v| !v.trim().is_empty())); .filter(|v| !v.trim().is_empty()));
let (username, password) = router.routeros_credentials(settings);
if let Some(key) = key { if let Some(key) = key {
session session
.userauth_pubkey_memory(&router.ssh_user, None, key, None) .userauth_pubkey_memory(username, None, key, None)
.context("SSH private-key authentication failed after host verification; use an unencrypted private key authorized for the device's SSH user")?; .context("SSH private-key authentication failed after host verification; use an unencrypted private key authorized for the device's SSH user")?;
} else { } else {
session session
.userauth_password( .userauth_password(username, password)
&router.ssh_user,
router.ssh_password.as_deref().unwrap_or(""),
)
.context("SSH password authentication failed")?; .context("SSH password authentication failed")?;
} }
ensure!(session.authenticated(), "SSH authentication failed"); ensure!(session.authenticated(), "SSH authentication failed");
@@ -381,7 +377,6 @@ pub fn binary_sync(
scp_download(&session, &remote, destination) scp_download(&session, &remote, destination)
.context("Neither SFTP nor SCP could download the backup") .context("Neither SFTP nor SCP could download the backup")
})(); })();
// A failed transfer must not leave a generated backup on device storage.
if let Err(error) = command( if let Err(error) = command(
&session, &session,
&format!("/file remove [find name=\"{remote}\"]"), &format!("/file remove [find name=\"{remote}\"]"),
@@ -415,7 +410,6 @@ pub fn upload_sync(
if let Some(actual) = destination.stat().ok().and_then(|stat| stat.size) { if let Some(actual) = destination.stat().ok().and_then(|stat| stat.size) {
ensure!(actual == length, "Incomplete SFTP upload"); ensure!(actual == length, "Incomplete SFTP upload");
} }
// ssh2 File::flush is a no-op. CLOSE carries the server's final acknowledgement.
destination destination
.close() .close()
.context("SFTP server rejected completion of upload")?; .context("SFTP server rejected completion of upload")?;
@@ -514,7 +508,6 @@ fn scp_download(session: &Session, remote_name: &str, destination: &Path) -> Res
channel.write_all(&[0])?; channel.write_all(&[0])?;
let mut record = scp_record(&mut channel)?; let mut record = scp_record(&mut channel)?;
if record.first() == Some(&b'T') { if record.first() == Some(&b'T') {
// Some implementations include timestamps even without -p. Permit one bounded record.
let times = std::str::from_utf8(&record[1..])? let times = std::str::from_utf8(&record[1..])?
.split_whitespace() .split_whitespace()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -605,8 +598,7 @@ fn swos_url(router: &RouterRecord) -> Result<Url> {
&& url.password().is_none(), && url.password().is_none(),
"Invalid SwitchOS URL" "Invalid SwitchOS URL"
); );
// Url normalises explicit :80/:443 away. Preserve the authority's explicit
// port before falling back to the device's separate port field.
let authority = raw let authority = raw
.split_once("://") .split_once("://")
.map(|(_, rest)| rest) .map(|(_, rest)| rest)
@@ -691,7 +683,8 @@ fn digest_authorization(
let qop = fields.get("qop"); let qop = fields.get("qop");
if let Some(qop) = qop { if let Some(qop) = qop {
ensure!( ensure!(
qop.split(',').any(|v| v.trim().eq_ignore_ascii_case("auth")), qop.split(',')
.any(|v| v.trim().eq_ignore_ascii_case("auth")),
"HTTP digest auth-int is not supported" "HTTP digest auth-int is not supported"
); );
} }
@@ -700,8 +693,7 @@ fn digest_authorization(
} else { } else {
hash(&format!("{a1}:{nonce}:{a2}"))? hash(&format!("{a1}:{nonce}:{a2}"))?
}; };
// Match requests.HTTPDigestAuth closely. Older SwOS builds are picky about
// the exact Authorization formatting even where RFC 7616 permits variants.
let mut output = format!( let mut output = format!(
"Digest username=\"{}\", realm=\"{}\", nonce=\"{}\", uri=\"{}\", response=\"{}\"", "Digest username=\"{}\", realm=\"{}\", nonce=\"{}\", uri=\"{}\", response=\"{}\"",
quote_auth(user), quote_auth(user),
@@ -732,12 +724,8 @@ fn swos_client(config: &Config) -> Result<Client> {
.timeout(Duration::from_secs(config.ssh_timeout_seconds)) .timeout(Duration::from_secs(config.ssh_timeout_seconds))
.connect_timeout(Duration::from_secs(config.ssh_timeout_seconds)) .connect_timeout(Duration::from_secs(config.ssh_timeout_seconds))
.no_proxy() .no_proxy()
// Match requests/wget and the very small embedded SwOS HTTP server: HTTP/1.x only,
// with conventional title-cased header names for older firmware parsers.
.http1_only() .http1_only()
.http1_title_case_headers() .http1_title_case_headers()
// Redirects are handled manually so Digest can be re-signed for the
// redirected URI instead of forwarding a signature for the old path.
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
.build()?) .build()?)
} }
@@ -773,11 +761,12 @@ fn swos_digest_challenge(response: &Response) -> Option<String> {
.filter_map(|value| value.to_str().ok()) .filter_map(|value| value.to_str().ok())
.find_map(|value| { .find_map(|value| {
let lower = value.to_ascii_lowercase(); let lower = value.to_ascii_lowercase();
lower.find("digest ").map(|index| value[index..].trim().to_owned()) lower
.find("digest ")
.map(|index| value[index..].trim().to_owned())
}) })
} }
async fn consume_swos_control_response(mut response: Response) -> Result<()> { async fn consume_swos_control_response(mut response: Response) -> Result<()> {
let mut total = 0usize; let mut total = 0usize;
while let Some(chunk) = response while let Some(chunk) = response
@@ -832,15 +821,36 @@ impl<'a> SwosHttp<'a> {
&& name.len() <= 128 && name.len() <= 128
&& name.bytes().all(|byte| { && name.bytes().all(|byte| {
byte.is_ascii_alphanumeric() byte.is_ascii_alphanumeric()
|| matches!(byte, b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~') || matches!(
byte,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
}); });
if !valid_name || cookie_value.len() > 4096 { if !valid_name || cookie_value.len() > 4096 {
continue; continue;
} }
if let Some(existing) = self.cookies.iter_mut().find(|(cookie_name, _)| cookie_name == name) { if let Some(existing) = self
.cookies
.iter_mut()
.find(|(cookie_name, _)| cookie_name == name)
{
existing.1 = cookie_value.to_owned(); existing.1 = cookie_value.to_owned();
} else if self.cookies.len() < 32 { } else if self.cookies.len() < 32 {
self.cookies.push((name.to_owned(), cookie_value.to_owned())); self.cookies
.push((name.to_owned(), cookie_value.to_owned()));
} }
} }
} }
@@ -866,8 +876,6 @@ impl<'a> SwosHttp<'a> {
SwosAuth::None => (), SwosAuth::None => (),
SwosAuth::Basic => request = request.basic_auth(self.user, Some(self.password)), SwosAuth::Basic => request = request.basic_auth(self.user, Some(self.password)),
SwosAuth::Digest(challenge) => { SwosAuth::Digest(challenge) => {
// requests.HTTPDigestAuth uses a short cnonce. Some older SwOS
// parsers reject longer values even though they are legal.
let cnonce = format!("{:016x}", rand::random::<u64>()); let cnonce = format!("{:016x}", rand::random::<u64>());
let authorization = let authorization =
digest_authorization(challenge, self.user, self.password, &url, &cnonce)?; digest_authorization(challenge, self.user, self.password, &url, &cnonce)?;
@@ -910,9 +918,6 @@ impl<'a> SwosHttp<'a> {
} }
async fn get(&mut self, path: &str) -> Result<Response> { async fn get(&mut self, path: &str) -> Result<Response> {
// Match requests.HTTPDigestAuth: receive the challenge, preserve cookies
// set by the 4xx response, fully consume it, then retry on the same Client.
// If Digest is unavailable/rejected, try Basic as the supplied Python app does.
let mut stale_retry_used = false; let mut stale_retry_used = false;
for attempt in 0..5 { for attempt in 0..5 {
let response = self.send(path, self.auth.clone()).await?; let response = self.send(path, self.auth.clone()).await?;
@@ -961,9 +966,6 @@ impl<'a> SwosHttp<'a> {
} }
async fn backup(&mut self) -> Result<SwosBackup> { async fn backup(&mut self) -> Result<SwosBackup> {
// The supplied Python implementation creates an independent authenticated
// request for /backup.swb. Do not reuse a nonce/counter learned from the
// homepage probe; older SwOS builds can reject that and return login HTML.
self.auth = SwosAuth::None; self.auth = SwosAuth::None;
self.cookies.clear(); self.cookies.clear();
@@ -974,8 +976,7 @@ impl<'a> SwosHttp<'a> {
Ok(backup) => return Ok(backup), Ok(backup) => return Ok(backup),
Err(error) => last_error = Some(error), Err(error) => last_error = Some(error),
} }
// A public page returned with HTTP 200 does not prove login. SwOS
// web UIs can obtain their first Digest challenge from /sys.b.
if matches!(self.auth, SwosAuth::None) && attempt == 0 { if matches!(self.auth, SwosAuth::None) && attempt == 0 {
let challenge_response = self.send("sys.b", SwosAuth::None).await?; let challenge_response = self.send("sys.b", SwosAuth::None).await?;
let digest = swos_digest_challenge(&challenge_response); let digest = swos_digest_challenge(&challenge_response);
@@ -996,7 +997,9 @@ impl<'a> SwosHttp<'a> {
let mode = self.auth.mode(); let mode = self.auth.mode();
Err(last_error Err(last_error
.expect("At least one backup candidate was rejected") .expect("At least one backup candidate was rejected")
.context(format!("SwitchOS /backup.swb failed after {mode} authentication"))) .context(format!(
"SwitchOS /backup.swb failed after {mode} authentication"
)))
} }
} }
@@ -1005,10 +1008,7 @@ fn swos_backup_prefix_valid(headers: &header::HeaderMap, bytes: &[u8]) -> Result
!bytes.is_empty(), !bytes.is_empty(),
"SwitchOS returned an empty backup; a device with default configuration may have nothing to save" "SwitchOS returned an empty backup; a device with default configuration may have nothing to save"
); );
// The working Python implementation accepts every non-empty /backup.swb
// response. Keep only the safety check that prevents an obvious login/error
// document from being written as a backup; do not reject valid firmware-
// specific textual SWB variants based on MIME type or guessed structure.
let text = if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) { let text = if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) {
let little_endian = bytes[0] == 0xff; let little_endian = bytes[0] == 0xff;
let units = bytes[2..] let units = bytes[2..]
@@ -1039,7 +1039,13 @@ fn swos_backup_prefix_valid(headers: &header::HeaderMap, bytes: &[u8]) -> Result
let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")? let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")?
.captures(trimmed) .captures(trimmed)
.and_then(|captures| captures.get(1)) .and_then(|captures| captures.get(1))
.map(|matched| matched.as_str().split_whitespace().collect::<Vec<_>>().join(" ")) .map(|matched| {
matched
.as_str()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
})
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or_else(|| "none".to_owned()); .unwrap_or_else(|| "none".to_owned());
bail!( bail!(
@@ -1153,9 +1159,6 @@ async fn probe_swos(config: &Config, router: &RouterRecord, settings: &Settings)
let mut http_status = None; let mut http_status = None;
let mut home_error = None; let mut home_error = None;
// Connectivity and backup availability are separate signals. The previous
// implementation reported the whole device offline whenever /backup.swb
// failed, even when the SwitchOS web UI itself was reachable.
let home_ok = match http.get("").await { let home_ok = match http.get("").await {
Ok(mut response) => { Ok(mut response) => {
http_status = Some(response.status().as_u16().to_string()); http_status = Some(response.status().as_u16().to_string());
@@ -1362,7 +1365,6 @@ async fn ping_host(host: &str) -> Result<f64> {
let checksum = icmp_checksum(&packet).to_be_bytes(); let checksum = icmp_checksum(&packet).to_be_bytes();
packet[2..4].copy_from_slice(&checksum); packet[2..4].copy_from_slice(&checksum);
} }
// Linux ping sockets fill the identifier and the ICMPv6 pseudo-header checksum.
let start = Instant::now(); let start = Instant::now();
socket.send(&packet).await?; socket.send(&packet).await?;
tokio::time::timeout(Duration::from_secs(1), async { tokio::time::timeout(Duration::from_secs(1), async {
@@ -2118,7 +2120,11 @@ mod tests {
) )
.into_response(); .into_response();
} }
([ ("content-type", "application/octet-stream") ], TEXT_SWOS_BACKUP).into_response() (
[("content-type", "application/octet-stream")],
TEXT_SWOS_BACKUP,
)
.into_response()
} }
let (port, task) = serve_swos_fixture( let (port, task) = serve_swos_fixture(
Router::new() Router::new()
@@ -2129,7 +2135,9 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path()); let config = test_config(dir.path());
let router = test_router("127.0.0.1", port); let router = test_router("127.0.0.1", port);
let probe = probe_swos(&config, &router, &Settings::default()).await.unwrap(); let probe = probe_swos(&config, &router, &Settings::default())
.await
.unwrap();
assert_eq!(probe["success"], true); assert_eq!(probe["success"], true);
assert_eq!(probe["backup_available"], true); assert_eq!(probe["backup_available"], true);
assert_eq!(probe["auth_mode"], "digest"); assert_eq!(probe["auth_mode"], "digest");
@@ -2147,8 +2155,7 @@ mod tests {
}; };
async fn handle(headers: HeaderMap, uri: Uri) -> axum::response::Response { async fn handle(headers: HeaderMap, uri: Uri) -> axum::response::Response {
if uri.path() == "/backup.swb" { if uri.path() == "/backup.swb" {
return (StatusCode::FOUND, [("location", "/download.swb")], "") return (StatusCode::FOUND, [("location", "/download.swb")], "").into_response();
.into_response();
} }
let challenge = let challenge =
"Digest realm=\"swos\", nonce=\"redirect-nonce\", qop=\"auth\", algorithm=MD5"; "Digest realm=\"swos\", nonce=\"redirect-nonce\", qop=\"auth\", algorithm=MD5";
@@ -2157,7 +2164,9 @@ mod tests {
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.unwrap_or(""); .unwrap_or("");
let fields = challenge_fields(authorization).unwrap(); let fields = challenge_fields(authorization).unwrap();
let authorized = fields.get("uri").is_some_and(|value| value == "/download.swb") let authorized = fields
.get("uri")
.is_some_and(|value| value == "/download.swb")
&& fields.get("cnonce").is_some_and(|cnonce| { && fields.get("cnonce").is_some_and(|cnonce| {
let host = headers.get("host").unwrap().to_str().unwrap(); let host = headers.get("host").unwrap().to_str().unwrap();
let url = Url::parse(&format!("http://{host}/download.swb")).unwrap(); let url = Url::parse(&format!("http://{host}/download.swb")).unwrap();
@@ -2172,7 +2181,11 @@ mod tests {
) )
.into_response(); .into_response();
} }
([ ("content-type", "application/octet-stream") ], TEXT_SWOS_BACKUP).into_response() (
[("content-type", "application/octet-stream")],
TEXT_SWOS_BACKUP,
)
.into_response()
} }
let (port, task) = serve_swos_fixture( let (port, task) = serve_swos_fixture(
Router::new() Router::new()
-3
View File
@@ -1,4 +1,3 @@
//! In-process scheduler, preserving APScheduler's Monday=0 weekday convention.
use crate::{AppState, backup, models::Settings, routeros}; use crate::{AppState, backup, models::Settings, routeros};
use anyhow::{Context, Result, bail, ensure}; use anyhow::{Context, Result, bail, ensure};
use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc}; use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};
@@ -143,7 +142,6 @@ impl Cron {
.succ_opt() .succ_opt()
.is_some_and(|next| next.month() == local.month())) .is_some_and(|next| next.month() == local.month()))
{ {
// Jump to the next local midnight, respecting timezone transitions.
if let Some(next_date) = local.date_naive().succ_opt() if let Some(next_date) = local.date_naive().succ_opt()
&& let Some(midnight) = next_date.and_hms_opt(0, 0, 0) && let Some(midnight) = next_date.and_hms_opt(0, 0, 0)
&& let Some(next) = self.timezone.from_local_datetime(&midnight).earliest() && let Some(next) = self.timezone.from_local_datetime(&midnight).earliest()
@@ -291,7 +289,6 @@ pub fn start(state: AppState, mut shutdown: watch::Receiver<bool>) -> tokio::tas
} }
} }
} }
// Allow active jobs to finish; individual network operations have hard deadlines.
while tasks.join_next().await.is_some() {} while tasks.join_next().await.is_some() {}
}) })
} }
+5 -3
View File
@@ -1,10 +1,10 @@
-- Current schema for a new installation. Never applied to an existing database. -- Current schema for a new installation. Existing supported Rust schemas are migrated at startup.
CREATE TABLE schema_info ( CREATE TABLE schema_info (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
application TEXT NOT NULL, application TEXT NOT NULL,
schema_version INTEGER NOT NULL schema_version INTEGER NOT NULL
); );
INSERT INTO schema_info (id, application, schema_version) VALUES (1, 'mikrotik-backup', 1); INSERT INTO schema_info (id, application, schema_version) VALUES (1, 'mikrotik-backup', 2);
CREATE TABLE users ( CREATE TABLE users (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -77,7 +77,9 @@ CREATE TABLE global_settings (
smtp_login TEXT, smtp_login TEXT,
smtp_password TEXT, smtp_password TEXT,
smtp_notifications_enabled INTEGER DEFAULT 0, smtp_notifications_enabled INTEGER DEFAULT 0,
recipient_email TEXT recipient_email TEXT,
default_routeros_username TEXT,
default_routeros_password TEXT
); );
CREATE INDEX ix_routers_owner_id ON routers(owner_id); CREATE INDEX ix_routers_owner_id ON routers(owner_id);
CREATE INDEX ix_backups_router_id ON backups(router_id); CREATE INDEX ix_backups_router_id ON backups(router_id);
+2 -9
View File
@@ -1,4 +1,3 @@
//! Password migration and the original HS256 JWT contract.
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD}; use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
@@ -132,8 +131,6 @@ pub fn verify_token(config: &Config, token: &str) -> Result<String> {
} }
let mut validation = Validation::new(Algorithm::HS256); let mut validation = Validation::new(Algorithm::HS256);
validation.leeway = 0; validation.leeway = 0;
// python-jose also checks a supplied nbf. Keep it optional for existing
// tokens, but reject malformed and not-yet-valid signed claims.
validation.validate_nbf = true; validation.validate_nbf = true;
let result = jsonwebtoken::decode::<Claims>( let result = jsonwebtoken::decode::<Claims>(
token, token,
@@ -242,8 +239,7 @@ mod tests {
#[test] #[test]
fn signed_malformed_or_missing_expiration_is_rejected() { fn signed_malformed_or_missing_expiration_is_rejected() {
let (_dir, config) = config(); let (_dir, config) = config();
// These are valid HMAC signatures: rejection must come from claim
// validation rather than an accidentally invalid signature.
for expiration in [ for expiration in [
serde_json::json!("4102444800"), serde_json::json!("4102444800"),
serde_json::json!("never"), serde_json::json!("never"),
@@ -297,10 +293,7 @@ mod tests {
fn verifies_independent_python_hmac_jwt_fixture() { fn verifies_independent_python_hmac_jwt_fixture() {
let (_dir, mut config) = config(); let (_dir, mut config) = config();
config.secret_key = "legacy-python-jwt-test-secret-32-bytes-minimum".into(); config.secret_key = "legacy-python-jwt-test-secret-32-bytes-minimum".into();
// Generated independently with Python stdlib json, base64 and
// hmac.new(key, header + b'.' + payload, hashlib.sha256).
// Payload: {"sub":"operator","exp":4102444800,"nbf":946684800}.
// The same HS256 representation is used by the source python-jose API.
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJvcGVyYXRvciIsImV4cCI6NDEwMjQ0NDgwMCwibmJmIjo5NDY2ODQ4MDB9.s0s1opp9vWVmZonuViNVQngNEr4m58t78K-pgJHcv5A"; let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJvcGVyYXRvciIsImV4cCI6NDEwMjQ0NDgwMCwibmJmIjo5NDY2ODQ4MDB9.s0s1opp9vWVmZonuViNVQngNEr4m58t78K-pgJHcv5A";
assert_eq!(verify_token(&config, token).unwrap(), "operator"); assert_eq!(verify_token(&config, token).unwrap(), "operator");
let tampered = token.replace("s0s1opp9", "t0s1opp9"); let tampered = token.replace("s0s1opp9", "t0s1opp9");
+1 -1
View File
@@ -897,7 +897,7 @@ async fn logs_retention_and_embedded_web_do_not_expose_storage() {
} }
assert_eq!( assert_eq!(
f.as_token("GET", "/api/health", None, None).await.1["version"], f.as_token("GET", "/api/health", None, None).await.1["version"],
"0.0.1" "0.0.2"
); );
let f = Fixture::with_options(&[("API_PREFIX", "/management/api")]); let f = Fixture::with_options(&[("API_PREFIX", "/management/api")]);
assert_eq!( assert_eq!(
+15 -4
View File
@@ -560,7 +560,7 @@ pre {
line-height: 1.3; line-height: 1.3;
white-space: nowrap; white-space: nowrap;
text-decoration: none; text-decoration: none;
transition: background .12s, border-color .12s transition: background-color .12s, border-color .12s, box-shadow .12s, filter .12s
} }
.btn:hover:not(:disabled) { .btn:hover:not(:disabled) {
@@ -577,8 +577,9 @@ pre {
} }
.btn.primary:hover:not(:disabled) { .btn.primary:hover:not(:disabled) {
background: #499d91; background: linear-gradient(135deg, #3e9186, #32776e);
border-color: #73bcb1 border-color: #73bcb1;
filter: brightness(1.08)
} }
.btn.danger { .btn.danger {
@@ -1688,7 +1689,7 @@ select {
.diff-controls { .diff-controls {
display: grid; display: grid;
grid-template-columns: 1fr 1.6fr 32px 1.6fr auto; grid-template-columns: minmax(135px, .75fr) minmax(150px, .9fr) minmax(210px, 1.35fr) 32px minmax(210px, 1.35fr) auto;
gap: 10px; gap: 10px;
align-items: end; align-items: end;
padding: 16px; padding: 16px;
@@ -1698,6 +1699,10 @@ select {
margin-bottom: 18px margin-bottom: 18px
} }
.diff-controls.cross-device {
grid-template-columns: minmax(125px, .65fr) minmax(145px, .8fr) minmax(190px, 1.2fr) 32px minmax(145px, .8fr) minmax(190px, 1.2fr) auto
}
.diff-controls select { .diff-controls select {
height: 35px; height: 35px;
font-size: 11px font-size: 11px
@@ -2067,6 +2072,12 @@ html[data-page="auth"] body {
box-shadow: none box-shadow: none
} }
.auth-layout .btn.primary:hover:not(:disabled) {
background: var(--blue);
border-color: var(--blue);
filter: brightness(1.08)
}
.auth-center { .auth-center {
display: grid; display: grid;
grid-template-columns: 1fr 410px; grid-template-columns: 1fr 410px;
+37 -14
View File
@@ -7,7 +7,7 @@
const navItems = [['/', 'nav.dashboard', 'home'], ['/devices', 'nav.routers', 'device'], ['/files', 'ui.backups', 'files'], ['/diff-configs', 'nav.diffConfigs', 'diff'], ['/logs', 'nav.logs', 'clock'], ['/settings', 'nav.settings', 'settings']]; const navItems = [['/', 'nav.dashboard', 'home'], ['/devices', 'nav.routers', 'device'], ['/files', 'ui.backups', 'files'], ['/diff-configs', 'nav.diffConfigs', 'diff'], ['/logs', 'nav.logs', 'clock'], ['/settings', 'nav.settings', 'settings']];
M.shell = () => { M.shell = () => {
const path = currentPath(); const path = currentPath();
return `<div class="layout ${M.state.collapsed ? 'collapsed' : ''} ${M.state.mobile ? 'mobile-open' : ''}"><header class="topbar">${IB('sidebar', 'ui.menu', 'menu')}<a class="brand" href="/" data-nav><span>MikroTik Backup System</span></a><span class="topbar-spacer"></span><div class="user-chip">${I('user')}<span>${E(M.state.user.username)}</span></div><span class="topbar-divider"></span><div class="actions">${M.languageSelect()}${IB('theme', 'ui.changeTheme', M.state.theme === 'dark' ? 'moon' : 'sun')}${IB('logout', 'nav.logout', 'logout')}</div></header><aside class="sidebar" aria-label="${T('app.menu')}"><p class="nav-section">MikroTik / control</p><nav class="nav">${navItems.map(([url, key, icon]) => `<a href="${url}" data-nav class="${path === url || url === '/devices' && path.startsWith('/devices/') ? 'active' : ''}" ${path === url ? 'aria-current="page"' : ''} title="${T(key)}">${I(icon)}<span class="nav-label">${T(key)}</span></a>`).join('')}</nav><div class="sidebar-footer"><button type="button" class="api-status pending" data-action="health" id="health-status" title="${T('footer.apiLatencyLabel')}"><span class="dot"></span><span>${T('footer.apiChecking')}</span></button><div class="sidebar-meta"><a class="api-docs-link" href="/docs">${T('ui.apiDocumentation')}</a><span class="sidebar-meta-separator" aria-hidden="true">|</span><span class="version">v0.0.1</span></div></div></aside><button type="button" class="mobile-overlay" data-action="sidebar-close" aria-label="${T('ui.close')}"></button><main class="main" id="main" tabindex="-1"><div class="loading" aria-live="polite"><span class="spinner"></span>${T('ui.loading')}</div></main></div>`; return `<div class="layout ${M.state.collapsed ? 'collapsed' : ''} ${M.state.mobile ? 'mobile-open' : ''}"><header class="topbar">${IB('sidebar', 'ui.menu', 'menu')}<a class="brand" href="/" data-nav><span>MikroTik Backup System</span></a><span class="topbar-spacer"></span><div class="user-chip">${I('user')}<span>${E(M.state.user.username)}</span></div><span class="topbar-divider"></span><div class="actions">${M.languageSelect()}${IB('theme', 'ui.changeTheme', M.state.theme === 'dark' ? 'moon' : 'sun')}${IB('logout', 'nav.logout', 'logout')}</div></header><aside class="sidebar" aria-label="${T('app.menu')}"><p class="nav-section">MikroTik / control</p><nav class="nav">${navItems.map(([url, key, icon]) => `<a href="${url}" data-nav class="${path === url || url === '/devices' && path.startsWith('/devices/') ? 'active' : ''}" ${path === url ? 'aria-current="page"' : ''} title="${T(key)}">${I(icon)}<span class="nav-label">${T(key)}</span></a>`).join('')}</nav><div class="sidebar-footer"><button type="button" class="api-status pending" data-action="health" id="health-status" title="${T('footer.apiLatencyLabel')}"><span class="dot"></span><span>${T('footer.apiChecking')}</span></button><div class="sidebar-meta"><a class="api-docs-link" href="/docs">${T('ui.apiDocumentation')}</a><span class="sidebar-meta-separator" aria-hidden="true">|</span><span class="version">v0.0.2</span></div></div></aside><button type="button" class="mobile-overlay" data-action="sidebar-close" aria-label="${T('ui.close')}"></button><main class="main" id="main" tabindex="-1"><div class="loading" aria-live="polite"><span class="spinner"></span>${T('ui.loading')}</div></main></div>`;
}; };
M.navigate = (path, replace = false) => { M.navigate = (path, replace = false) => {
if (!path.startsWith('/') || path.startsWith('//')) return; if (!path.startsWith('/') || path.startsWith('//')) return;
@@ -21,7 +21,10 @@
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
if (path === '/diff-configs') { if (path === '/diff-configs') {
M.state.diffSelection = { M.state.diffSelection = {
mode: params.has('mode') ? (params.get('mode') === 'cross' ? 'cross' : 'same') : (M.state.diffSelection?.mode === 'cross' ? 'cross' : 'same'),
router_id: params.get('router_id') || M.state.diffSelection?.router_id || '', router_id: params.get('router_id') || M.state.diffSelection?.router_id || '',
left_router_id: params.get('left_router_id') || M.state.diffSelection?.left_router_id || '',
right_router_id: params.get('right_router_id') || M.state.diffSelection?.right_router_id || '',
left: params.get('left') || M.state.diffSelection?.left || '', left: params.get('left') || M.state.diffSelection?.left || '',
right: params.get('right') || M.state.diffSelection?.right || '' right: params.get('right') || M.state.diffSelection?.right || ''
}; };
@@ -41,7 +44,7 @@
const revision = ++M.state.routeId; const revision = ++M.state.routeId;
M.applyPreferences(); M.applyPreferences();
document.documentElement.dataset.page = isAuthPath(path) ? 'auth' : 'app'; document.documentElement.dataset.page = isAuthPath(path) ? 'auth' : 'app';
document.title = `MikroTik Backup System · 0.0.1`; document.title = `MikroTik Backup System · 0.0.2`;
if (isAuthPath(path)) { M.$('#app').innerHTML = M.authView(path === '/register'); return; } if (isAuthPath(path)) { M.$('#app').innerHTML = M.authView(path === '/register'); return; }
M.syncUrlState(path); M.syncUrlState(path);
M.$('#app').innerHTML = M.shell(); M.updateHealth(); M.$('#app').innerHTML = M.shell(); M.updateHealth();
@@ -98,17 +101,26 @@
M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = position; }); M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = position; });
if (restoreSearch) { const field = M.$('#diff-search'); field?.focus(); if (selection != null) field?.setSelectionRange(selection, selection); } if (restoreSearch) { const field = M.$('#diff-search'); field?.focus(); if (selection != null) field?.setSelectionRange(selection, selection); }
}; };
M.redrawDiffControls = () => {
const controls = M.$('#diff-controls');
if (controls) controls.innerHTML = M.diffControls();
};
M.compare = async (left, right) => { M.compare = async (left, right) => {
left = Number(left); right = Number(right); left = Number(left); right = Number(right);
if (!left || !right || left === right) throw new Error(t('ui.selectPair')); if (!left || !right || left === right) throw new Error(t('ui.selectPair'));
const exports = (M.state.cache.diffFiles || []).filter(M.isTextExport); const exports = (M.state.cache.diffFiles || []).filter(M.isTextExport);
if (![left, right].every(id => exports.some(file => file.id === id))) throw new Error(t('ui.selectPair')); const leftFile = exports.find(file => file.id === left), rightFile = exports.find(file => file.id === right);
if (!leftFile || !rightFile) throw new Error(t('ui.selectPair'));
const selection = M.state.diffSelection || {};
if (selection.mode === 'cross') {
if (!selection.left_router_id || !selection.right_router_id || String(leftFile.router_id) !== String(selection.left_router_id) || String(rightFile.router_id) !== String(selection.right_router_id) || String(leftFile.router_id) === String(rightFile.router_id)) throw new Error(t('ui.selectPair'));
} else if (!selection.router_id || String(leftFile.router_id) !== String(selection.router_id) || String(rightFile.router_id) !== String(selection.router_id)) throw new Error(t('ui.selectPair'));
const revision = M.state.routeId; const revision = M.state.routeId;
const data = await M.api(`/backups/${left}/diff/${right}?compact=true`); const data = await M.api(`/backups/${left}/diff/${right}?compact=true`);
if (revision !== M.state.routeId) return; if (revision !== M.state.routeId) return;
M.state.diff = data; M.state.diffPage = 1; M.state.diffSelection = { ...(M.state.diffSelection || {}), left, right }; M.state.diff = data; M.state.diffPage = 1; M.state.diffShowAll = false; M.state.diffSelection = { ...(M.state.diffSelection || {}), left, right };
const query = new URLSearchParams({ left: String(left), right: String(right) }); const query = new URLSearchParams({ left: String(left), right: String(right), mode: M.state.diffSelection.mode === 'cross' ? 'cross' : 'same' });
if (M.state.diffSelection.router_id) query.set('router_id', M.state.diffSelection.router_id); for (const key of ['router_id', 'left_router_id', 'right_router_id']) if (M.state.diffSelection[key]) query.set(key, M.state.diffSelection[key]);
history.replaceState({}, '', `/diff-configs?${query}`); history.replaceState({}, '', `/diff-configs?${query}`);
M.redrawDiff(); M.redrawDiff();
}; };
@@ -264,23 +276,26 @@
const latest = files[0], older = latest?.id === selected.id ? files[1] : selected; const latest = files[0], older = latest?.id === selected.id ? files[1] : selected;
if (!latest || !older) throw new Error(t('ui.noExportPair')); if (!latest || !older) throw new Error(t('ui.noExportPair'));
M.state.diff = null; M.state.diff = null;
M.state.diffSelection = { left: older.id, right: latest.id, router_id: selected.router_id }; M.state.diffSelection = { mode: 'same', left: older.id, right: latest.id, router_id: selected.router_id, left_router_id: '', right_router_id: '' };
await M.navigate(`/diff-configs?${new URLSearchParams(M.state.diffSelection)}`); await M.navigate(`/diff-configs?${new URLSearchParams({ mode: 'same', router_id: selected.router_id, left: older.id, right: latest.id })}`);
}, },
'upload-local-file': () => { const routers = (M.state.cache.routers || []).filter(router => router.device_type === 'routeros'); if (!routers.length) throw new Error(t('files.noRouterosTargets')); M.openDialog(t('files.uploadFileDialogTitle'), M.uploadFileForm(routers)); },
download: el => M.download(`/backups/${Number(el.dataset.id)}/download`, {}, M.findFile(el.dataset.id)?.file_name || `backup-${el.dataset.id}`), download: el => M.download(`/backups/${Number(el.dataset.id)}/download`, {}, M.findFile(el.dataset.id)?.file_name || `backup-${el.dataset.id}`),
email: async el => { if (!await M.confirm(t('ui.emailConfirm'), { danger: false, label: t('common.email') })) return; await M.api(`/backups/${Number(el.dataset.id)}/email`, { method: 'POST', body: {} }); M.toast(t('toast.backupSentEmail')); }, email: async el => { if (!await M.confirm(t('ui.emailConfirm'), { danger: false, label: t('common.email') })) return; await M.api(`/backups/${Number(el.dataset.id)}/email`, { method: 'POST', body: {} }); M.toast(t('toast.backupSentEmail')); },
upload: async el => { if (!await M.confirm(t('ui.uploadConfirm'), { label: t('ui.upload') })) return; await M.api(`/backups/router/${Number(el.dataset.router)}/upload/${Number(el.dataset.id)}`, { method: 'POST', body: {}, timeout: 900000 }); M.toast(t('toast.binaryUploaded')); }, upload: async el => { if (!await M.confirm(t('ui.uploadConfirm'), { label: t('ui.upload') })) return; await M.api(`/backups/router/${Number(el.dataset.router)}/upload/${Number(el.dataset.id)}`, { method: 'POST', body: {}, timeout: 900000 }); M.toast(t('toast.binaryUploaded')); },
'delete-backup': async el => { if (!await M.confirm(t('confirm.deleteBackup'), { label: t('common.delete') })) return; await M.api(`/backups/${Number(el.dataset.id)}`, { method: 'DELETE' }); M.state.selected.delete(Number(el.dataset.id)); M.toast(t('toast.backupDeleted')); await M.render(); }, 'delete-backup': async el => { if (!await M.confirm(t('confirm.deleteBackup'), { label: t('common.delete') })) return; await M.api(`/backups/${Number(el.dataset.id)}`, { method: 'DELETE' }); M.state.selected.delete(Number(el.dataset.id)); M.toast(t('toast.backupDeleted')); await M.render(); },
'bulk-download': async () => { const ids = [...M.state.selected]; if (!ids.length) throw new Error(t('ui.bulkEmpty')); await M.download('/backups/bulk', { method: 'POST', body: { action: 'download', backup_ids: ids } }, 'backups.zip'); }, 'bulk-download': async () => { const ids = [...M.state.selected]; if (!ids.length) throw new Error(t('ui.bulkEmpty')); await M.download('/backups/bulk', { method: 'POST', body: { action: 'download', backup_ids: ids } }, 'backups.zip'); },
'bulk-delete': async () => { const ids = [...M.state.selected]; if (!ids.length) throw new Error(t('ui.bulkEmpty')); if (!await M.confirm(t('confirm.deleteSelectedFiles', { count: ids.length }), { label: t('common.delete') })) return; await M.api('/backups/bulk', { method: 'POST', body: { action: 'delete', backup_ids: ids } }); M.state.selected.clear(); M.toast(t('toast.selectedBackupsDeleted')); await M.render(); }, 'bulk-delete': async () => { const ids = [...M.state.selected]; if (!ids.length) throw new Error(t('ui.bulkEmpty')); if (!await M.confirm(t('confirm.deleteSelectedFiles', { count: ids.length }), { label: t('common.delete') })) return; await M.api('/backups/bulk', { method: 'POST', body: { action: 'delete', backup_ids: ids } }); M.state.selected.clear(); M.toast(t('toast.selectedBackupsDeleted')); await M.render(); },
'compare-selected': async () => { const selected = [...M.state.selected].map(id => M.findFile(id)); if (selected.length !== 2 || !selected.every(M.isTextExport)) throw new Error(t('ui.selectPair')); selected.sort((a, b) => (M.parseDate(a.created_at)?.getTime() || 0) - (M.parseDate(b.created_at)?.getTime() || 0)); M.state.diff = null; M.state.diffSelection = { left: selected[0].id, right: selected[1].id, router_id: selected[0].router_id === selected[1].router_id ? selected[0].router_id : '' }; await M.navigate(`/diff-configs?${new URLSearchParams(M.state.diffSelection)}`); }, 'compare-selected': async () => { const selected = [...M.state.selected].map(id => M.findFile(id)); if (selected.length !== 2 || !selected.every(M.isTextExport)) throw new Error(t('ui.selectPair')); selected.sort((a, b) => (M.parseDate(a.created_at)?.getTime() || 0) - (M.parseDate(b.created_at)?.getTime() || 0)); const cross = selected[0].router_id !== selected[1].router_id; M.state.diff = null; M.state.diffSelection = cross ? { mode: 'cross', left: selected[0].id, right: selected[1].id, router_id: '', left_router_id: selected[0].router_id, right_router_id: selected[1].router_id } : { mode: 'same', left: selected[0].id, right: selected[1].id, router_id: selected[0].router_id, left_router_id: '', right_router_id: '' }; await M.navigate(`/diff-configs?${new URLSearchParams(Object.fromEntries(Object.entries(M.state.diffSelection).filter(([, value]) => value !== '')))}`); },
'reset-filters': () => { M.state.filters = { search: '', backup_type: '', router_id: '', created_on: '', sort_by: 'created_at', order: 'desc' }; M.state.page = 1; M.state.selected.clear(); history.replaceState({}, '', '/files'); M.render(); }, 'reset-filters': () => { M.state.filters = { search: '', backup_type: '', router_id: '', created_on: '', sort_by: 'created_at', order: 'desc' }; M.state.page = 1; M.state.selected.clear(); history.replaceState({}, '', '/files'); M.render(); },
'page-prev': () => { M.state.page = Math.max(1, M.state.page - 1); M.redrawPageTable(); }, 'page-prev': () => { M.state.page = Math.max(1, M.state.page - 1); M.redrawPageTable(); },
'page-next': () => { M.state.page += 1; M.redrawPageTable(); }, 'page-next': () => { M.state.page += 1; M.redrawPageTable(); },
'diff-page-prev': () => { M.state.diffPage = Math.max(1, M.state.diffPage - 1); M.redrawDiff(); M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = 0; }); }, 'diff-page-prev': () => { M.state.diffPage = Math.max(1, M.state.diffPage - 1); M.redrawDiff(); M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = 0; }); },
'diff-page-next': () => { M.state.diffPage += 1; M.redrawDiff(); M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = 0; }); }, 'diff-page-next': () => { M.state.diffPage += 1; M.redrawDiff(); M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = 0; }); },
'swap-diff': () => { const form = M.$('#diff-form'); const left = form.elements.left.value; form.elements.left.value = form.elements.right.value; form.elements.right.value = left; M.state.diffSelection.left = form.elements.left.value; M.state.diffSelection.right = form.elements.right.value; }, 'diff-toggle-all': () => { M.state.diffShowAll = !M.state.diffShowAll; M.state.diffPage = 1; M.redrawDiff(); M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = 0; }); },
'latest-diff-pair': () => { const files = M.state.cache.diffFiles || []; if (files.length < 2) throw new Error(t('ui.noExportPair')); const right = files[0], left = files.slice(1).find(f => f.router_id === right.router_id) || files[1]; const form = M.$('#diff-form'); form.elements.left.value = left.id; form.elements.right.value = right.id; M.state.diffSelection.left = left.id; M.state.diffSelection.right = right.id; }, 'swap-diff': () => { const form = M.$('#diff-form'); const cross = M.state.diffSelection?.mode === 'cross'; if (cross) { const leftRouter = M.state.diffSelection.left_router_id; M.state.diffSelection.left_router_id = M.state.diffSelection.right_router_id; M.state.diffSelection.right_router_id = leftRouter; } const left = M.state.diffSelection.left; M.state.diffSelection.left = M.state.diffSelection.right; M.state.diffSelection.right = left; M.redrawDiffControls(); },
'latest-diff-pair': () => { const files = M.state.cache.diffFiles || []; if (files.length < 2) throw new Error(t('ui.noExportPair')); const right = files[0], left = files.slice(1).find(f => f.router_id === right.router_id); if (!left) throw new Error(t('ui.noExportPair')); M.state.diffSelection = { mode: 'same', router_id: right.router_id, left_router_id: '', right_router_id: '', left: left.id, right: right.id }; M.redrawDiffControls(); },
'open-html-diff': async () => { if (!M.state.diff) return; const popup = window.open('', '_blank'); if (!popup) throw new Error(t('ui.popupBlocked')); popup.document.title = t('ui.openStandaloneDiff'); popup.document.body.textContent = t('ui.loading'); try { const html = await M.api(`/backups/${M.state.diff.left_backup_id}/diff/${M.state.diff.right_backup_id}/html`, { text: true }); const url = URL.createObjectURL(new Blob([html], { type: 'text/html;charset=utf-8' })); popup.location.replace(url); setTimeout(() => URL.revokeObjectURL(url), 60000); } catch (error) { popup.close(); throw error; } },
'download-diff': async () => { if (!M.state.diff) return; const full = await M.api(`/backups/${M.state.diff.left_backup_id}/diff/${M.state.diff.right_backup_id}`); M.saveBlob(new Blob([full.diff_text || ''], { type: 'text/plain;charset=utf-8' }), `config-${M.state.diff.left_backup_id}-${M.state.diff.right_backup_id}.diff`); }, 'download-diff': async () => { if (!M.state.diff) return; const full = await M.api(`/backups/${M.state.diff.left_backup_id}/diff/${M.state.diff.right_backup_id}`); M.saveBlob(new Blob([full.diff_text || ''], { type: 'text/plain;charset=utf-8' }), `config-${M.state.diff.left_backup_id}-${M.state.diff.right_backup_id}.diff`); },
'download-html-diff': () => { if (!M.state.diff) return; return M.download(`/backups/${M.state.diff.left_backup_id}/diff/${M.state.diff.right_backup_id}/html`, {}, `config-${M.state.diff.left_backup_id}-${M.state.diff.right_backup_id}.html`); }, 'download-html-diff': () => { if (!M.state.diff) return; return M.download(`/backups/${M.state.diff.left_backup_id}/diff/${M.state.diff.right_backup_id}/html`, {}, `config-${M.state.diff.left_backup_id}-${M.state.diff.right_backup_id}.html`); },
'test-email': async () => { await M.api('/settings/test-email', { method: 'POST', body: {} }); M.toast(t('toast.testEmailSent')); }, 'test-email': async () => { await M.api('/settings/test-email', { method: 'POST', body: {} }); M.toast(t('toast.testEmailSent')); },
@@ -323,7 +338,9 @@
if (el.name === 'use_global_switchos_credentials' && el.form?.id === 'device-form') M.syncDeviceCredentialMode(el.form); if (el.name === 'use_global_switchos_credentials' && el.form?.id === 'device-form') M.syncDeviceCredentialMode(el.form);
if (el.name === 'disable_all_backups' && el.form?.id === 'device-policy-form' && el.checked) { if (el.form.elements.disable_export_backups) el.form.elements.disable_export_backups.checked = true; el.form.elements.disable_binary_backups.checked = true; } if (el.name === 'disable_all_backups' && el.form?.id === 'device-policy-form' && el.checked) { if (el.form.elements.disable_export_backups) el.form.elements.disable_export_backups.checked = true; el.form.elements.disable_binary_backups.checked = true; }
if (el.name === 'router_id' && el.form?.id === 'backup-form') M.syncBackupType(); if (el.name === 'router_id' && el.form?.id === 'backup-form') M.syncBackupType();
if (el.name === 'router_id' && el.form?.id === 'diff-form') { M.state.diffSelection = { router_id: el.value, left: '', right: '' }; M.state.diff = null; M.navigate(`/diff-configs?router_id=${encodeURIComponent(el.value)}`); } if (el.name === 'diff_scope' && el.form?.id === 'diff-form') { M.state.diffSelection = { mode: el.value === 'cross' ? 'cross' : 'same', router_id: '', left_router_id: '', right_router_id: '', left: '', right: '' }; M.state.diff = null; M.redrawDiffControls(); }
if (el.name === 'router_id' && el.form?.id === 'diff-form') { M.state.diffSelection.router_id = el.value; M.state.diffSelection.left = ''; M.state.diffSelection.right = ''; M.state.diff = null; M.redrawDiffControls(); }
if ((el.name === 'left_router_id' || el.name === 'right_router_id') && el.form?.id === 'diff-form') { M.state.diffSelection[el.name] = el.value; M.state.diffSelection[el.name === 'left_router_id' ? 'left' : 'right'] = ''; M.state.diff = null; M.redrawDiffControls(); }
if (el.name === 'left' || el.name === 'right') { if (el.form?.id === 'diff-form') M.state.diffSelection[el.name] = el.value; } if (el.name === 'left' || el.name === 'right') { if (el.form?.id === 'diff-form') M.state.diffSelection[el.name] = el.value; }
if (el.id === 'diff-changes-only') { M.state.diffChangesOnly = el.checked; M.state.diffPage = 1; M.redrawDiff(); } if (el.id === 'diff-changes-only') { M.state.diffChangesOnly = el.checked; M.state.diffPage = 1; M.redrawDiff(); }
if (el.closest('[data-schedule]')) M.updateScheduleEditor(el.closest('[data-schedule]')); if (el.closest('[data-schedule]')) M.updateScheduleEditor(el.closest('[data-schedule]'));
@@ -388,6 +405,12 @@
await M.api(`/routers/${id}`, { method: 'PUT', body: payload }); M.toast(t('toast.routerUpdated')); await M.render(); return; await M.api(`/routers/${id}`, { method: 'PUT', body: payload }); M.toast(t('toast.routerUpdated')); await M.render(); return;
} }
if (form.id === 'backup-form') { const type = data.backup_type; if (!['export', 'binary'].includes(type)) throw new Error(t('ui.notSupported')); await M.api(`/backups/router/${Number(data.router_id)}/${type}`, { method: 'POST', body: {}, timeout: 900000 }); M.closeDialog(); M.toast(t(type === 'export' ? 'toast.exportCreated' : 'toast.binaryCreated')); await M.render(); return; } if (form.id === 'backup-form') { const type = data.backup_type; if (!['export', 'binary'].includes(type)) throw new Error(t('ui.notSupported')); await M.api(`/backups/router/${Number(data.router_id)}/${type}`, { method: 'POST', body: {}, timeout: 900000 }); M.closeDialog(); M.toast(t(type === 'export' ? 'toast.exportCreated' : 'toast.binaryCreated')); await M.render(); return; }
if (form.id === 'device-file-upload-form') {
const upload = new FormData(form), file = upload.get('file'), routerId = Number(upload.get('router_id'));
if (!routerId || !file || typeof file.name !== 'string' || !/\.(rsc|backup)$/i.test(file.name)) throw new Error(t('files.uploadFileInvalid'));
await M.api(`/routers/${routerId}/files/upload?filename=${encodeURIComponent(file.name)}`, { method: 'POST', body: file, raw: true, timeout: 900000 });
M.closeDialog(); M.toast(t('toast.fileUploaded')); return;
}
if (form.id === 'files-filter') { M.state.filters = data; M.state.page = 1; M.state.selected.clear(); await M.render(); return; } if (form.id === 'files-filter') { M.state.filters = data; M.state.page = 1; M.state.selected.clear(); await M.render(); return; }
if (form.id === 'device-filter') { M.state.deviceFilters = data; M.state.page = 1; M.redrawPageTable(); return; } if (form.id === 'device-filter') { M.state.deviceFilters = data; M.state.page = 1; M.redrawPageTable(); return; }
if (form.id === 'diff-form') { await M.compare(data.left, data.right); return; } if (form.id === 'diff-form') { await M.compare(data.left, data.right); return; }
@@ -395,9 +418,9 @@
if (form.id === 'settings-form') { if (form.id === 'settings-form') {
const payload = {}; const payload = {};
for (const key of ['backup_retention_days', 'log_retention_days', 'connection_test_interval_minutes', 'smtp_port']) payload[key] = Number(data[key]); for (const key of ['backup_retention_days', 'log_retention_days', 'connection_test_interval_minutes', 'smtp_port']) payload[key] = Number(data[key]);
for (const key of ['default_switchos_username', 'smtp_host', 'smtp_login', 'recipient_email']) payload[key] = data[key]?.trim() || null; for (const key of ['default_routeros_username', 'default_switchos_username', 'smtp_host', 'smtp_login', 'recipient_email']) payload[key] = data[key]?.trim() || null;
for (const key of ['smtp_notifications_enabled', 'notify_failures_only']) payload[key] = form.elements[key].checked; for (const key of ['smtp_notifications_enabled', 'notify_failures_only']) payload[key] = form.elements[key].checked;
for (const key of ['global_ssh_key', 'default_switchos_password', 'smtp_password', 'pushover_token', 'pushover_userkey']) { for (const key of ['global_ssh_key', 'default_routeros_password', 'default_switchos_password', 'smtp_password', 'pushover_token', 'pushover_userkey']) {
if (data[key]) payload[key] = key === 'global_ssh_key' ? data[key].trim() : data[key]; if (data[key]) payload[key] = key === 'global_ssh_key' ? data[key].trim() : data[key];
if (data[`clear_${key}`]) payload[`clear_${key}`] = true; if (data[`clear_${key}`]) payload[`clear_${key}`] = true;
} }
+5 -4
View File
@@ -9,7 +9,7 @@
theme: read(localStorage, 'routeros_theme') === 'light' ? 'light' : 'dark', theme: read(localStorage, 'routeros_theme') === 'light' ? 'light' : 'dark',
collapsed: read(localStorage, 'mbs_sidebar') === 'collapsed', mobile: false, collapsed: read(localStorage, 'mbs_sidebar') === 'collapsed', mobile: false,
routeId: 0, cache: {}, selected: new Set(), page: 1, pageSize: 20, routeId: 0, cache: {}, selected: new Set(), page: 1, pageSize: 20,
health: { online: null, latency: null }, diff: null, diffMode: 'split', diffChangesOnly: true, diffSearch: '', diffPage: 1, diffPageSize: 200, health: { online: null, latency: null }, diff: null, diffMode: 'split', diffChangesOnly: true, diffSearch: '', diffPage: 1, diffPageSize: 200, diffShowAll: false,
filters: { search: '', backup_type: '', router_id: '', created_on: '', sort_by: 'created_at', order: 'desc' } filters: { search: '', backup_type: '', router_id: '', created_on: '', sort_by: 'created_at', order: 'desc' }
}; };
M.$ = (selector, root = document) => root.querySelector(selector); M.$ = (selector, root = document) => root.querySelector(selector);
@@ -119,18 +119,19 @@
M.card = (title, body, extras = '', classes = '') => `<section class="card ${classes}"><header class="card-header"><h2>${M.tr(title)}</h2>${extras}</header>${body}</section>`; M.card = (title, body, extras = '', classes = '') => `<section class="card ${classes}"><header class="card-header"><h2>${M.tr(title)}</h2>${extras}</header>${body}</section>`;
M.errorMessage = error => error && error.message ? error.message : M.t('ui.operationError'); M.errorMessage = error => error && error.message ? error.message : M.t('ui.operationError');
M.api = async (path, options = {}) => { M.api = async (path, options = {}) => {
const { method = 'GET', body, blob = false, text = false, timeout = 180000, quietAuth = false, signal } = options; const { method = 'GET', body, blob = false, text = false, raw = false, timeout = 180000, quietAuth = false, signal } = options;
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout); const timer = setTimeout(() => controller.abort(), timeout);
const forwardAbort = () => controller.abort(); const forwardAbort = () => controller.abort();
if (signal) signal.addEventListener('abort', forwardAbort, { once: true }); if (signal) signal.addEventListener('abort', forwardAbort, { once: true });
const headers = { Accept: blob ? '*/*' : text ? 'text/html' : 'application/json' }; const headers = { Accept: blob ? '*/*' : text ? 'text/html' : 'application/json' };
if (M.state.token) headers.Authorization = `Bearer ${M.state.token}`; if (M.state.token) headers.Authorization = `Bearer ${M.state.token}`;
if (body !== undefined) headers['Content-Type'] = 'application/json'; if (body !== undefined && raw) headers['Content-Type'] = 'application/octet-stream';
else if (body !== undefined) headers['Content-Type'] = 'application/json';
let response; let response;
try { try {
response = await fetch(`${M.$('meta[name="api-prefix"]')?.content || '/api'}${path}`, { response = await fetch(`${M.$('meta[name="api-prefix"]')?.content || '/api'}${path}`, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal, method, headers, body: body === undefined ? undefined : raw ? body : JSON.stringify(body), signal: controller.signal,
cache: 'no-store', credentials: 'same-origin', redirect: 'error' cache: 'no-store', credentials: 'same-origin', redirect: 'error'
}); });
if (!response.ok) { if (!response.ok) {
+1 -2
View File
@@ -1,3 +1,2 @@
/* Source language dictionaries preserved from the original application. */
window.MBS = window.MBS || {}; window.MBS = window.MBS || {};
window.MBS.translations = {"pl":{"app":{"menu":"Menu"},"sidebar":{"title":"Mikrotik Backup System","subtitle":"RouterOS/SwitchOS"},"topbar":{"caption":"mikrotik / control center","role":"administrator","languageSelector":"Wybór języka"},"common":{"apply":"Zastosuj","reset":"Resetuj","delete":"Usuń","confirm":"Potwierdź","cancel":"Anuluj","download":"Pobierz","email":"Wyślij e-mail","preview":"Podgląd","restore":"Przywróć","actions":"Akcje","open":"Otwórz","edit":"Edytuj","diff":"Diff","ok":"OK","idle":"Brak","asc":"Rosnąco","desc":"Malejąco","enabled":"Włączone","disabled":"Wyłączone","failed":"Błąd","save":"Zapisz"},"nav":{"dashboard":"Dashboard","routers":"Urządzenia","files":"Repozytorium","settings":"Ustawienia","logs":"Logi","logout":"Wyloguj","theme":"Motyw","changePassword":"Zmień hasło","diffConfigs":"Diff konfiguracji","switchosBeta":"SwitchOS beta"},"auth":{"username":"Użytkownik","password":"Hasło","login":"Zaloguj","register":"Rejestracja","confirmPassword":"Potwierdź hasło","changePassword":"Zmień hasło","currentPassword":"Obecne hasło","newPassword":"Nowe hasło","backToLogin":"Powrót do logowania","loginSubtitle":"Zaloguj się, aby kontynuować.","loginFailed":"Logowanie nie powiodło się","accountCreated":"Konto zostało utworzone","registrationFailed":"Rejestracja nie powiodła się","passwordsMismatch":"Hasła nie są takie same","changePasswordFailed":"Zmiana hasła nie powiodła się","securityEyebrow":"konto / bezpieczeństwo","changePasswordSubtitle":"Zaktualizuj hasło administratora bez zbędnych ustawień dodatkowych.","changePasswordCardSubtitle":"Podaj obecne hasło i ustaw nowe dane logowania.","passwordPanelSubtitle":"Szybki podgląd siły hasła i zgodności pól przed zapisem.","passwordStrength":"Siła hasła","passwordWeak":"Słabe","passwordMedium":"Średnie","passwordStrong":"Mocne","ruleLength":"Minimum 8 znaków","ruleDigit":"Przynajmniej jedna cyfra","ruleMatch":"Oba pola są zgodne","passwordsMatchHint":"Nowe hasło i potwierdzenie są zgodne."},"dashboard":{"title":"Dashboard","eyebrow":"strona główna / dashboard","subtitle":"Przegląd backupów, eksportów i aktywności systemu.","exportAll":"Exportuj wszystko","binaryAll":"Backup binarny","managedRouters":"Urządzenia","managedRoutersHint":"Wszystkie zarządzane urządzenia","inventoryTag":"Flota","exportsCard":"Exporty","exportsHint":"Czytelne snapshoty konfiguracji","textTag":"Tekst","binaryCard":"Backupy binarne","binaryHint":"Punkty odtworzenia","binaryTag":"Binary","allFilesCard":"Wszystkie pliki","allFilesHint":"Artefakty w repozytorium","archiveTag":"Archiwum","storageTitle":"Wykorzystanie przestrzeni","storageSubtitle":"Bieżący podgląd zajętości repozytorium i wolnego miejsca.","folderUsage":"Zajętość katalogu","diskUsage":"Użycie dysku","totalDisk":"Cały dysk","freeSpace":"Wolne miejsce","activityTitle":"Ostatnia aktywność","activitySubtitle":"Najnowsze zdarzenia operacyjne z backendu.","noActivity":"Brak ostatnich zdarzeń do wyświetlenia.","avgBackupsPerRouter":"Śr. backupów / urządzenie","activitySuccess":"Zadanie zakończone","activityFailure":"Wymaga uwagi","activityMaintenance":"Utrzymanie","activityDelivery":"Dystrybucja","operationsTitle":"Centrum operacji","operationsSubtitle":"Główne akcje i szybkie wskaźniki pracy repozytorium.","latestSnapshot":"Najnowszy snapshot","coverageLabel":"Pokrycie floty","coverageHint":"Urządzenia z co najmniej jednym backupem","weeklyActivityLabel":"Aktywność 7 dni","weeklyActivityHint":"Nowe backupy z ostatniego tygodnia","busiestRouterLabel":"Najaktywniejsze urządzenie","routerSnapshotsHint":"{{count}} snapshotów w repozytorium","exportShareLabel":"Udział exportów","activityTodayLabel":"Zdarzenia dzisiaj","noneLabel":"Brak","activityTodayHint":"Wpisy z bieżącego dnia","usedSpace":"Zajęte miejsce","storageViewCapacity":"Pojemność","storageViewCapacityHint":"Widok dysku, zajętości repozytorium i wolnego miejsca w jednej skali.","storageViewMix":"Typy backupów","storageViewMixHint":"Podział wszystkich kopii na exporty tekstowe i backupy binarne.","storageViewActivity":"Aktywność 7 dni","storageViewActivityHint":"Liczba nowych backupów z ostatnich siedmiu dni.","storageViewRouters":"Top urządzenia","storageViewRoutersHint":"Urządzenia z największą liczbą snapshotów w repozytorium.","storageChartEmpty":"Brak danych do narysowania wykresu.","storageSnapshotTitle":"Metryki repozytorium","storageSnapshotHint":"Szybki podgląd najważniejszych wskaźników przestrzeni i backupów."},"routers":{"title":"Urządzenia","detailTitle":"Szczegóły urządzenia","add":"Dodaj urządzenie","eyebrow":"inwentaryzacja urządzeń","subtitle":"Zarządzaj urządzeniami RouterOS i SwitchOS oraz ich kopiami.","registeredDevices":"Zarejestrowane urządzenia","fleetTag":"Flota","sshPassword":"Hasło","passwordHint":"Dostęp hasłem","credsTag":"Dostęp","sshKey":"Klucz SSH","keyHint":"Dostęp kluczem","securityTag":"BezLine truncated window.MBS.translations = { "pl": { "app": { "menu": "Menu" }, "sidebar": { "title": "Mikrotik Backup System", "subtitle": "RouterOS/SwitchOS" }, "topbar": { "caption": "mikrotik / control center", "role": "administrator", "languageSelector": "Wybór języka" }, "common": { "apply": "Zastosuj", "reset": "Resetuj", "delete": "Usuń", "confirm": "Potwierdź", "cancel": "Anuluj", "download": "Pobierz", "email": "Wyślij e-mail", "preview": "Podgląd", "restore": "Przywróć", "actions": "Akcje", "open": "Otwórz", "edit": "Edytuj", "diff": "Diff", "ok": "OK", "idle": "Brak", "asc": "Rosnąco", "desc": "Malejąco", "enabled": "Włączone", "disabled": "Wyłączone", "failed": "Błąd", "save": "Zapisz" }, "nav": { "dashboard": "Dashboard", "routers": "Urządzenia", "files": "Repozytorium", "settings": "Ustawienia", "logs": "Logi", "logout": "Wyloguj", "theme": "Motyw", "changePassword": "Zmień hasło", "diffConfigs": "Diff konfiguracji", "switchosBeta": "SwitchOS beta" }, "auth": { "username": "Użytkownik", "password": "Hasło", "login": "Zaloguj", "register": "Rejestracja", "confirmPassword": "Potwierdź hasło", "changePassword": "Zmień hasło", "currentPassword": "Obecne hasło", "newPassword": "Nowe hasło", "backToLogin": "Powrót do logowania", "loginSubtitle": "Zaloguj się, aby kontynuować.", "loginFailed": "Logowanie nie powiodło się", "accountCreated": "Konto zostało utworzone", "registrationFailed": "Rejestracja nie powiodła się", "passwordsMismatch": "Hasła nie są takie same", "changePasswordFailed": "Zmiana hasła nie powiodła się", "securityEyebrow": "konto / bezpieczeństwo", "changePasswordSubtitle": "Zaktualizuj hasło administratora bez zbędnych ustawień dodatkowych.", "changePasswordCardSubtitle": "Podaj obecne hasło i ustaw nowe dane logowania.", "passwordPanelSubtitle": "Szybki podgląd siły hasła i zgodności pól przed zapisem.", "passwordStrength": "Siła hasła", "passwordWeak": "Słabe", "passwordMedium": "Średnie", "passwordStrong": "Mocne", "ruleLength": "Minimum 8 znaków", "ruleDigit": "Przynajmniej jedna cyfra", "ruleMatch": "Oba pola są zgodne", "passwordsMatchHint": "Nowe hasło i potwierdzenie są zgodne." }, "dashboard": { "title": "Dashboard", "eyebrow": "strona główna / dashboard", "subtitle": "Przegląd backupów, eksportów i aktywności systemu.", "exportAll": "Exportuj wszystko", "binaryAll": "Backup binarny", "managedRouters": "Urządzenia", "managedRoutersHint": "Wszystkie zarządzane urządzenia", "inventoryTag": "Flota", "exportsCard": "Exporty", "exportsHint": "Czytelne snapshoty konfiguracji", "textTag": "Tekst", "binaryCard": "Backupy binarne", "binaryHint": "Punkty odtworzenia", "binaryTag": "Binary", "allFilesCard": "Wszystkie pliki", "allFilesHint": "Artefakty w repozytorium", "archiveTag": "Archiwum", "storageTitle": "Wykorzystanie przestrzeni", "storageSubtitle": "Bieżący podgląd zajętości repozytorium i wolnego miejsca.", "folderUsage": "Zajętość katalogu", "diskUsage": "Użycie dysku", "totalDisk": "Cały dysk", "freeSpace": "Wolne miejsce", "activityTitle": "Ostatnia aktywność", "activitySubtitle": "Najnowsze zdarzenia operacyjne z backendu.", "noActivity": "Brak ostatnich zdarzeń do wyświetlenia.", "avgBackupsPerRouter": "Śr. backupów / urządzenie", "activitySuccess": "Zadanie zakończone", "activityFailure": "Wymaga uwagi", "activityMaintenance": "Utrzymanie", "activityDelivery": "Dystrybucja", "operationsTitle": "Centrum operacji", "operationsSubtitle": "Główne akcje i szybkie wskaźniki pracy repozytorium.", "latestSnapshot": "Najnowszy snapshot", "coverageLabel": "Pokrycie floty", "coverageHint": "Urządzenia z co najmniej jednym backupem", "weeklyActivityLabel": "Aktywność 7 dni", "weeklyActivityHint": "Nowe backupy z ostatniego tygodnia", "busiestRouterLabel": "Najaktywniejsze urządzenie", "routerSnapshotsHint": "{{count}} snapshotów w repozytorium", "exportShareLabel": "Udział exportów", "activityTodayLabel": "Zdarzenia dzisiaj", "noneLabel": "Brak", "activityTodayHint": "Wpisy z bieżącego dnia", "usedSpace": "Zajęte miejsce", "storageViewCapacity": "Pojemność", "storageViewCapacityHint": "Widok dysku, zajętości repozytorium i wolnego miejsca w jednej skali.", "storageViewMix": "Typy backupów", "storageViewMixHint": "Podział wszystkich kopii na exporty tekstowe i backupy binarne.", "storageViewActivity": "Aktywność 7 dni", "storageViewActivityHint": "Liczba nowych backupów z ostatnich siedmiu dni.", "storageViewRouters": "Top urządzenia", "storageViewRoutersHint": "Urządzenia z największą liczbą snapshotów w repozytorium.", "storageChartEmpty": "Brak danych do narysowania wykresu.", "storageSnapshotTitle": "Metryki repozytorium", "storageSnapshotHint": "Szybki podgląd najważniejszych wskaźników przestrzeni i backupów." }, "routers": { "title": "Urządzenia", "detailTitle": "Szczegóły urządzenia", "add": "Dodaj urządzenie", "eyebrow": "inwentaryzacja urządzeń", "subtitle": "ZLine truncated
+43 -22
View File
@@ -1,4 +1,3 @@
/* Views contain presentation only; mutations live in app.js. */
(function (M) { (function (M) {
'use strict'; 'use strict';
const { escape: E, tr: T, t, icon: I, button: B, iconButton: IB, card: C, field: F, select: S } = M; const { escape: E, tr: T, t, icon: I, button: B, iconButton: IB, card: C, field: F, select: S } = M;
@@ -76,7 +75,7 @@
return `${M.heading('routers.title', 'routers.subtitle', B('new-device', t('ui.newDevice'), 'plus', '', 'primary') + B('refresh', t('ui.refresh'), 'refresh'), 'routers.eyebrow')}<form id="device-filter" class="filter-bar"><div class="field search-field"><span>${T('files.searchLabel')}</span><input name="search" type="search" value="${E(f.search || '')}" placeholder="${T('ui.search')}" aria-label="${T('files.searchLabel')}"></div>${S('status', 'ui.status', [['', t('ui.allStatuses')], ['online', t('ui.online')], ['offline', t('ui.offline')], ['unknown', t('ui.unknown')]], f.status || '')}${S('device_type', 'routers.deviceType', [['', t('files.allTypes')], ['routeros', 'RouterOS'], ['switchos', 'SwitchOS']], f.device_type || '')}${B('refresh-ping', t('ui.refreshPing'), 'refresh')}</form><section class="card"><div id="device-table">${M.deviceTable()}</div></section>`; return `${M.heading('routers.title', 'routers.subtitle', B('new-device', t('ui.newDevice'), 'plus', '', 'primary') + B('refresh', t('ui.refresh'), 'refresh'), 'routers.eyebrow')}<form id="device-filter" class="filter-bar"><div class="field search-field"><span>${T('files.searchLabel')}</span><input name="search" type="search" value="${E(f.search || '')}" placeholder="${T('ui.search')}" aria-label="${T('files.searchLabel')}"></div>${S('status', 'ui.status', [['', t('ui.allStatuses')], ['online', t('ui.online')], ['offline', t('ui.offline')], ['unknown', t('ui.unknown')]], f.status || '')}${S('device_type', 'routers.deviceType', [['', t('files.allTypes')], ['routeros', 'RouterOS'], ['switchos', 'SwitchOS']], f.device_type || '')}${B('refresh-ping', t('ui.refreshPing'), 'refresh')}</form><section class="card"><div id="device-table">${M.deviceTable()}</div></section>`;
}; };
M.deviceForm = router => { M.deviceForm = router => {
const editing = !!router, r = router || { name: '', host: '', port: 22, device_type: 'routeros', ssh_user: 'admin' }; const editing = !!router, r = router || { name: '', host: '', port: 22, device_type: 'routeros', ssh_user: '' };
const switchos = r.device_type === 'switchos'; const switchos = r.device_type === 'switchos';
const legacy = editing && switchos && r.use_global_switchos_credentials == null; const legacy = editing && switchos && r.use_global_switchos_credentials == null;
const source = legacy ? '' : r.use_global_switchos_credentials === false ? 'false' : 'true'; const source = legacy ? '' : r.use_global_switchos_credentials === false ? 'false' : 'true';
@@ -90,7 +89,7 @@
${F('port', 'routers.port', r.port, { type: 'number', required: true, attrs: 'min="1" max="65535"' })} ${F('port', 'routers.port', r.port, { type: 'number', required: true, attrs: 'min="1" max="65535"' })}
<div class="wide" id="switchos-credentials-mode"${switchos ? '' : ' hidden'}>${S('use_global_switchos_credentials', 'ui.credentialSource', sources, source, switchos ? '' : 'disabled')}<p class="credential-hint" data-switchos-mode-hint${source === 'false' ? ' hidden' : ''}>${T(legacy ? 'ui.legacyCredentialsHint' : 'ui.globalCredentialsHint')}</p></div> <div class="wide" id="switchos-credentials-mode"${switchos ? '' : ' hidden'}>${S('use_global_switchos_credentials', 'ui.credentialSource', sources, source, switchos ? '' : 'disabled')}<p class="credential-hint" data-switchos-mode-hint${source === 'false' ? ' hidden' : ''}>${T(legacy ? 'ui.legacyCredentialsHint' : 'ui.globalCredentialsHint')}</p></div>
<div class="form-grid wide" id="device-own-credentials"${global ? ' hidden' : ''}> <div class="form-grid wide" id="device-own-credentials"${global ? ' hidden' : ''}>
${F('ssh_user', 'routers.sshUser', r.ssh_user || '', { attrs: 'maxlength="120" autocomplete="off"' + (global ? ' disabled' : '') })} ${F('ssh_user', 'routers.sshUser', r.ssh_user || '', { attrs: 'maxlength="120" autocomplete="off"' + (global ? ' disabled' : ''), hint: switchos ? '' : t('routers.routerosDefaultFallback') })}
<div class="secret-field">${F('ssh_password', 'routers.sshPassword', '', { type: 'password', attrs: 'maxlength="255" autocomplete="new-password"' + (global ? ' disabled' : ''), placeholder: editing ? t(passwordSet ? 'ui.kept' : 'ui.notSet') : '' })}${editing ? `<label class="checkbox-label"><input type="checkbox" name="clear_ssh_password"${global ? ' disabled' : ''}>${T('ui.clearSecret')}</label>` : ''}</div> <div class="secret-field">${F('ssh_password', 'routers.sshPassword', '', { type: 'password', attrs: 'maxlength="255" autocomplete="new-password"' + (global ? ' disabled' : ''), placeholder: editing ? t(passwordSet ? 'ui.kept' : 'ui.notSet') : '' })}${editing ? `<label class="checkbox-label"><input type="checkbox" name="clear_ssh_password"${global ? ' disabled' : ''}>${T('ui.clearSecret')}</label>` : ''}</div>
</div> </div>
<div class="wide secret-field" id="device-key-field"${switchos ? ' hidden' : ''}>${F('ssh_key', 'routers.sshPrivateKey', '', { type: 'textarea', attrs: 'spellcheck="false" autocomplete="off"' + (switchos ? ' disabled' : ''), placeholder: editing && r.has_effective_ssh_key ? t('ui.kept') : t('routers.optionalPrivateKey'), hint: r.uses_global_ssh_key ? t('routers.globalKeyMode') : '' })}${editing ? `<label class="checkbox-label"><input type="checkbox" name="clear_ssh_key"${switchos ? ' disabled' : ''}>${T('ui.clearSecret')}</label>` : ''}</div> <div class="wide secret-field" id="device-key-field"${switchos ? ' hidden' : ''}>${F('ssh_key', 'routers.sshPrivateKey', '', { type: 'textarea', attrs: 'spellcheck="false" autocomplete="off"' + (switchos ? ' disabled' : ''), placeholder: editing && r.has_effective_ssh_key ? t('ui.kept') : t('routers.optionalPrivateKey'), hint: r.uses_global_ssh_key ? t('routers.globalKeyMode') : '' })}${editing ? `<label class="checkbox-label"><input type="checkbox" name="clear_ssh_key"${switchos ? ' disabled' : ''}>${T('ui.clearSecret')}</label>` : ''}</div>
@@ -107,7 +106,7 @@
const status = dl([['ui.status', M.connectionBadge(r), true], ['routers.lastTestAt', M.date(r.last_connection_tested_at)], ['routers.uptime', r.last_connection_uptime], ['routers.connectionLabel', r.last_connection_transport], ['routers.serverHeader', r.last_connection_server], ['routers.authMode', r.last_connection_auth_mode], ['routers.httpStatus', r.last_connection_http_status], ['routers.backupEndpoint', r.last_connection_backup_available == null ? '—' : t(r.last_connection_backup_available ? 'routers.backupAvailable' : 'routers.backupUnavailable')]]); const status = dl([['ui.status', M.connectionBadge(r), true], ['routers.lastTestAt', M.date(r.last_connection_tested_at)], ['routers.uptime', r.last_connection_uptime], ['routers.connectionLabel', r.last_connection_transport], ['routers.serverHeader', r.last_connection_server], ['routers.authMode', r.last_connection_auth_mode], ['routers.httpStatus', r.last_connection_http_status], ['routers.backupEndpoint', r.last_connection_backup_available == null ? '—' : t(r.last_connection_backup_available ? 'routers.backupAvailable' : 'routers.backupUnavailable')]]);
const last = backups[0]; const last = backups[0];
const backupSummary = dl([['ui.lastBackup', last ? M.date(last.created_at) : '—'], ['files.sizeColumn', last ? M.bytes(last.file_size) : '—'], ...(M.supportsExport(r) ? [['routers.exportsLabel', backups.filter(M.isTextExport).length]] : []), ['routers.binaryLabel', backups.filter(f => f.backup_type === 'binary').length]]); const backupSummary = dl([['ui.lastBackup', last ? M.date(last.created_at) : '—'], ['files.sizeColumn', last ? M.bytes(last.file_size) : '—'], ...(M.supportsExport(r) ? [['routers.exportsLabel', backups.filter(M.isTextExport).length]] : []), ['routers.binaryLabel', backups.filter(f => f.backup_type === 'binary').length]]);
return `<div class="grid-equal detail-grid"><div class="stack">${C('routers.connectionSectionTitle', `<div class="card-body">${summary}</div>`)}${C('ui.backups', `<div class="card-body">${backupSummary}</div><div class="card-foot actions">${M.supportsExport(r) ? B('device-export', t('files.exportType') + ' RSC', 'files', `data-id="${r.id}" ${r.disable_all_backups || r.disable_export_backups ? 'disabled' : ''}`) : ''}${B('device-binary', t('files.binaryType'), 'database', `data-id="${r.id}" ${r.disable_all_backups || r.disable_binary_backups ? 'disabled' : ''}`, 'primary')}</div>`)}</div><div class="stack">${C('routers.deviceStatusTitle', `<div class="card-body">${status}${r.last_connection_error ? `<div class="notice warning mt-18">${I('alert')}<span class="break">${E(r.last_connection_error)}</span></div>` : ''}${!r.last_connection_tested_at ? `<p class="section-hint mt-18">${T('routers.noConnection')}</p>` : ''}</div>`)}${C('routers.credentialsSectionTitle', `<div class="card-body">${dl([['routers.sshKey', r.uses_global_ssh_key ? t('routers.globalKeyMode') : t(r.has_effective_ssh_key ? 'routers.keyMode' : 'routers.noKey')], ['routers.sshPassword', t(r.has_effective_password ? 'routers.passwordMode' : 'routers.noPassword')], ['routers.defaultCredentials', t(r.uses_global_switchos_credentials ? 'common.enabled' : 'common.disabled')]])}</div>`)}</div></div>`; return `<div class="grid-equal detail-grid"><div class="stack">${C('routers.connectionSectionTitle', `<div class="card-body">${summary}</div>`)}${C('ui.backups', `<div class="card-body">${backupSummary}</div><div class="card-foot actions">${M.supportsExport(r) ? B('device-export', t('files.exportType') + ' RSC', 'files', `data-id="${r.id}" ${r.disable_all_backups || r.disable_export_backups ? 'disabled' : ''}`) : ''}${B('device-binary', t('files.binaryType'), 'database', `data-id="${r.id}" ${r.disable_all_backups || r.disable_binary_backups ? 'disabled' : ''}`, 'primary')}</div>`)}</div><div class="stack">${C('routers.deviceStatusTitle', `<div class="card-body">${status}${r.last_connection_error ? `<div class="notice warning mt-18">${I('alert')}<span class="break">${E(r.last_connection_error)}</span></div>` : ''}${!r.last_connection_tested_at ? `<p class="section-hint mt-18">${T('routers.noConnection')}</p>` : ''}</div>`)}${C('routers.credentialsSectionTitle', `<div class="card-body">${dl([['routers.sshKey', r.uses_global_ssh_key ? t('routers.globalKeyMode') : t(r.has_effective_ssh_key ? 'routers.keyMode' : 'routers.noKey')], ['routers.sshPassword', t(r.has_effective_password ? 'routers.passwordMode' : 'routers.noPassword')], ['routers.defaultCredentials', t(r.device_type === 'switchos' ? (r.uses_global_switchos_credentials ? 'common.enabled' : 'common.disabled') : (r.uses_default_routeros_credentials ? 'common.enabled' : 'common.disabled'))]])}</div>`)}</div></div>`;
}; };
M.views.device = async id => { M.views.device = async id => {
const revision = M.state.routeId; const revision = M.state.routeId;
@@ -124,7 +123,8 @@
Object.entries(filters).forEach(([key, value]) => { if (value !== '' && value !== null && value !== undefined) params.set(key, String(value)); }); Object.entries(filters).forEach(([key, value]) => { if (value !== '' && value !== null && value !== undefined) params.set(key, String(value)); });
return params.toString(); return params.toString();
}; };
M.fileToolbar = () => `<div class="actions">${B('bulk-download', t('files.downloadZip'), 'download')}${B('compare-selected', t('ui.compare'), 'diff', 'hidden')}${B('bulk-delete', t('common.delete'), 'trash', '', 'danger')}<span class="select-summary">${T('ui.selection')}: <b id="selected-count">${M.state.selected.size}</b></span></div>`; M.fileToolbar = () => `<div class="actions">${B('upload-local-file', t('files.uploadToDevice'), 'upload', '', 'primary')}${B('bulk-download', t('files.downloadZip'), 'download')}${B('compare-selected', t('ui.compare'), 'diff', 'hidden')}${B('bulk-delete', t('common.delete'), 'trash', '', 'danger')}<span class="select-summary">${T('ui.selection')}: <b id="selected-count">${M.state.selected.size}</b></span></div>`;
M.uploadFileForm = routers => `<form id="device-file-upload-form"><div class="form-grid">${S('router_id', 'files.uploadTarget', routers.map(r => [r.id, `${r.name} · ${r.host}`]), routers[0]?.id || '', 'required')}<label class="field wide" for="f-device_file"><span>${T('files.uploadFileLabel')}<span class="required"> *</span></span><input id="f-device_file" name="file" type="file" accept=".rsc,.backup" required><small>${T('files.uploadFileHint')}</small></label></div><div class="notice mt-16">${I('upload')}<span>${T('ui.uploadFile')}</span></div><div class="form-actions">${B('close-dialog', t('common.cancel'))}<button type="submit" class="btn primary">${I('upload')}${T('ui.upload')}</button></div></form>`;
M.views.files = async () => { M.views.files = async () => {
const revision = M.state.routeId; const revision = M.state.routeId;
const f = M.state.filters; const f = M.state.filters;
@@ -171,8 +171,8 @@
const pageSize = Math.max(80, Number(M.state.diffPageSize) || 200); const pageSize = Math.max(80, Number(M.state.diffPageSize) || 200);
const pages = Math.max(1, Math.ceil(allRows.length / pageSize)); const pages = Math.max(1, Math.ceil(allRows.length / pageSize));
M.state.diffPage = Math.min(pages, Math.max(1, Number(M.state.diffPage) || 1)); M.state.diffPage = Math.min(pages, Math.max(1, Number(M.state.diffPage) || 1));
const start = (M.state.diffPage - 1) * pageSize; const start = M.state.diffShowAll ? 0 : (M.state.diffPage - 1) * pageSize;
const rows = allRows.slice(start, start + pageSize); const rows = M.state.diffShowAll ? allRows : allRows.slice(start, start + pageSize);
const pane = side => `<div class="diff-pane"><div class="diff-title" title="${E(d[`${side}_file_name`] || '')}">${E(d[`${side}_file_name`] || '')}</div><div class="diff-scroll" data-diff-scroll="${side}"><div class="diff-lines">${rows.map(row => { const pane = side => `<div class="diff-pane"><div class="diff-title" title="${E(d[`${side}_file_name`] || '')}">${E(d[`${side}_file_name`] || '')}</div><div class="diff-scroll" data-diff-scroll="${side}"><div class="diff-lines">${rows.map(row => {
const text = row[`${side}_text`] || '', n = row[`${side}_number`]; const text = row[`${side}_text`] || '', n = row[`${side}_number`];
let type = ['context', 'added', 'removed', 'modified'].includes(row.type) ? row.type : 'context'; let type = ['context', 'added', 'removed', 'modified'].includes(row.type) ? row.type : 'context';
@@ -188,30 +188,51 @@
else { if (row.left_number != null) lines.push(['removed', '−', row.left_number, row.left_text]); if (row.right_number != null) lines.push(['added', '+', row.right_number, row.right_text]); } else { if (row.left_number != null) lines.push(['removed', '−', row.left_number, row.left_text]); if (row.right_number != null) lines.push(['added', '+', row.right_number, row.right_text]); }
return lines.map(([type, prefix, n, text]) => { const match = search && String(text || '').toLocaleLowerCase().includes(search.toLocaleLowerCase()); return `<div class="diff-line ${type}${match ? ' match' : ''}"><span class="diff-prefix">${prefix}</span><span class="line-no">${n == null ? '' : Number(n)}</span><span class="line-code">${M.highlight(text, search) || ' '}</span></div>`; }).join(''); return lines.map(([type, prefix, n, text]) => { const match = search && String(text || '').toLocaleLowerCase().includes(search.toLocaleLowerCase()); return `<div class="diff-line ${type}${match ? ' match' : ''}"><span class="diff-prefix">${prefix}</span><span class="line-no">${n == null ? '' : Number(n)}</span><span class="line-code">${M.highlight(text, search) || ' '}</span></div>`; }).join('');
}).join('')}</div></div>`; }).join('')}</div></div>`;
const pager = pages > 1 ? `<div class="diff-pager"><button type="button" class="btn small" data-action="diff-page-prev"${M.state.diffPage <= 1 ? ' disabled' : ''}>${T('ui.previous')}</button><span>${T('ui.page')} <strong>${M.state.diffPage}</strong> / ${pages} · ${start + 1}–${Math.min(allRows.length, start + rows.length)} / ${allRows.length}</span><button type="button" class="btn small" data-action="diff-page-next"${M.state.diffPage >= pages ? ' disabled' : ''}>${T('ui.next')}</button></div>` : `<span>${T('ui.lines')}: ${allRows.length}</span>`; const pager = M.state.diffShowAll ? `<span>${T('ui.lines')}: ${allRows.length}</span>` : pages > 1 ? `<div class="diff-pager"><button type="button" class="btn small" data-action="diff-page-prev"${M.state.diffPage <= 1 ? ' disabled' : ''}>${T('ui.previous')}</button><span>${T('ui.page')} <strong>${M.state.diffPage}</strong> / ${pages} · ${start + 1}–${Math.min(allRows.length, start + rows.length)} / ${allRows.length}</span><button type="button" class="btn small" data-action="diff-page-next"${M.state.diffPage >= pages ? ' disabled' : ''}>${T('ui.next')}</button></div>` : `<span>${T('ui.lines')}: ${allRows.length}</span>`;
return `${M.smartDiff(d.smart)}<section class="card raw-diff"><div class="diff-toolbar"><div class="tabs" role="tablist"><button type="button" class="tab ${M.state.diffMode === 'split' ? 'active' : ''}" data-diff-mode="split" role="tab" aria-selected="${M.state.diffMode === 'split'}">${T('ui.split')}</button><button type="button" class="tab ${M.state.diffMode === 'unified' ? 'active' : ''}" data-diff-mode="unified" role="tab" aria-selected="${M.state.diffMode === 'unified'}">${T('ui.unified')}</button></div><input type="search" id="diff-search" value="${E(search)}" placeholder="${T('ui.search')}" aria-label="${T('files.searchLabel')}"><label class="checkbox-label"><input type="checkbox" id="diff-changes-only"${M.state.diffChangesOnly ? ' checked' : ''}>${T('ui.changesOnly')}</label><div class="diff-stats">${[['added', 'ui.added'], ['removed', 'ui.removed'], ['modified', 'ui.modified'], ['context', 'ui.context']].map(([key, label]) => `<span class="${key}">${T(label)} <strong>${Number(stats[key]) || 0}</strong></span>`).join('')}</div></div>${M.state.diffMode === 'split' ? `<div class="diff-split">${pane('left')}${pane('right')}</div>` : unified()}<div class="diff-footer">${pager}<div class="actions">${B('download-diff', t('common.download') + ' .diff', 'download')}${B('download-html-diff', t('common.download') + ' HTML', 'files')}</div></div></section>`; const fullToggle = pages > 1 || M.state.diffShowAll ? B('diff-toggle-all', t(M.state.diffShowAll ? 'ui.usePagination' : 'ui.loadFullDiff'), 'files') : '';
return `${M.smartDiff(d.smart)}<section class="card raw-diff"><div class="diff-toolbar"><div class="tabs" role="tablist"><button type="button" class="tab ${M.state.diffMode === 'split' ? 'active' : ''}" data-diff-mode="split" role="tab" aria-selected="${M.state.diffMode === 'split'}">${T('ui.split')}</button><button type="button" class="tab ${M.state.diffMode === 'unified' ? 'active' : ''}" data-diff-mode="unified" role="tab" aria-selected="${M.state.diffMode === 'unified'}">${T('ui.unified')}</button></div><input type="search" id="diff-search" value="${E(search)}" placeholder="${T('ui.search')}" aria-label="${T('files.searchLabel')}"><label class="checkbox-label"><input type="checkbox" id="diff-changes-only"${M.state.diffChangesOnly ? ' checked' : ''}>${T('ui.changesOnly')}</label><div class="diff-stats">${[['added', 'ui.added'], ['removed', 'ui.removed'], ['modified', 'ui.modified'], ['context', 'ui.context']].map(([key, label]) => `<span class="${key}">${T(label)} <strong>${Number(stats[key]) || 0}</strong></span>`).join('')}</div></div>${M.state.diffMode === 'split' ? `<div class="diff-split">${pane('left')}${pane('right')}</div>` : unified()}<div class="diff-footer">${pager}<div class="actions">${fullToggle}${B('open-html-diff', t('ui.openStandaloneDiff'), 'globe')}${B('download-diff', t('common.download') + ' .diff', 'download')}${B('download-html-diff', t('common.download') + ' HTML', 'files')}</div></div></section>`;
}; };
M.diffControls = () => { M.diffControls = () => {
const files = (M.state.cache.diffFiles || []).filter(M.isTextExport), routers = (M.state.cache.routers || []).filter(M.supportsExport), s = M.state.diffSelection || {}; const files = (M.state.cache.diffFiles || []).filter(M.isTextExport), routers = (M.state.cache.routers || []).filter(M.supportsExport), s = M.state.diffSelection || {};
const options = [['', t('diffConfigs.noneSelected')], ...files.map(f => [f.id, `${f.router_name || '#' + f.router_id} · ${M.date(f.created_at)} · ${f.file_name}`])]; const mode = s.mode === 'cross' ? 'cross' : 'same';
return `<form id="diff-form" class="diff-controls">${S('router_id', 'files.routerLabel', [['', t('files.allRouters')], ...routers.map(r => [r.id, r.name])], s.router_id || '')}${S('left', 'files.compareOlder', options, s.left || '', 'required')}${IB('swap-diff', 'ui.swap', 'swap')}${S('right', 'files.compareNewer', options, s.right || '', 'required')}<button class="btn primary" type="submit">${I('diff')}${T('ui.compare')}</button></form>`; const fileOptions = list => [['', t('diffConfigs.noneSelected')], ...list.map(f => [f.id, `${M.date(f.created_at)} · ${f.file_name}`])];
const routerOptions = [['', t('diffConfigs.chooseDevice')], ...routers.map(r => [r.id, `${r.name} · ${r.host}`])];
let selectors;
if (mode === 'cross') {
const leftFiles = s.left_router_id ? files.filter(f => String(f.router_id) === String(s.left_router_id)) : [];
const rightFiles = s.right_router_id ? files.filter(f => String(f.router_id) === String(s.right_router_id)) : [];
selectors = `${S('left_router_id', 'diffConfigs.leftDevice', routerOptions, s.left_router_id || '', 'required')}${S('left', 'files.compareOlder', fileOptions(leftFiles), s.left || '', 'required')}${IB('swap-diff', 'ui.swap', 'swap')}${S('right_router_id', 'diffConfigs.rightDevice', routerOptions, s.right_router_id || '', 'required')}${S('right', 'files.compareNewer', fileOptions(rightFiles), s.right || '', 'required')}`;
} else {
const scoped = s.router_id ? files.filter(f => String(f.router_id) === String(s.router_id)) : [];
const options = fileOptions(scoped);
selectors = `${S('router_id', 'files.routerLabel', routerOptions, s.router_id || '', 'required')}${S('left', 'files.compareOlder', options, s.left || '', 'required')}${IB('swap-diff', 'ui.swap', 'swap')}${S('right', 'files.compareNewer', options, s.right || '', 'required')}`;
}
return `<form id="diff-form" class="diff-controls ${mode === 'cross' ? 'cross-device' : ''}">${S('diff_scope', 'diffConfigs.compareMode', [['same', t('diffConfigs.sameDevice')], ['cross', t('diffConfigs.differentDevices')]], mode)}${selectors}<button class="btn primary" type="submit">${I('diff')}${T('ui.compare')}</button></form>`;
}; };
M.views.diff = async () => { M.views.diff = async () => {
const revision = M.state.routeId; const revision = M.state.routeId;
const s = M.state.diffSelection || {}; const s = M.state.diffSelection || {};
const query = M.backupQuery({ backup_type: 'export', router_id: s.router_id || '', sort_by: 'created_at', order: 'desc' }); let [files, routers] = await Promise.all([M.api('/backups?backup_type=export&sort_by=created_at&order=desc'), M.api('/routers')]);
let [files, routers] = await Promise.all([M.api(`/backups?${query}`), M.api('/routers')]);
if (revision !== M.state.routeId) return ""; if (revision !== M.state.routeId) return "";
if (s.router_id && routers.some(router => String(router.id) === String(s.router_id) && !M.supportsExport(router))) {
M.state.diffSelection = { router_id: '', left: '', right: '' }; M.state.diff = null;
files = await M.api('/backups?backup_type=export&sort_by=created_at&order=desc');
if (revision !== M.state.routeId) return '';
history.replaceState({}, '', '/diff-configs');
}
M.state.cache.diffFiles = files.filter(M.isTextExport); M.state.cache.routers = routers; M.state.cache.diffFiles = files.filter(M.isTextExport); M.state.cache.routers = routers;
const selection = M.state.diffSelection || s;
const allowed = new Set(M.state.cache.diffFiles.map(file => Number(file.id))); const allowed = new Set(M.state.cache.diffFiles.map(file => Number(file.id)));
M.state.diffSelection = { ...selection, left: allowed.has(Number(selection.left)) ? selection.left : '', right: allowed.has(Number(selection.right)) ? selection.right : '' }; const routerIds = new Set(routers.filter(M.supportsExport).map(router => String(router.id)));
const selection = { mode: s.mode === 'cross' ? 'cross' : 'same', router_id: s.router_id || '', left_router_id: s.left_router_id || '', right_router_id: s.right_router_id || '', left: s.left || '', right: s.right || '' };
if (!routerIds.has(String(selection.router_id))) selection.router_id = '';
if (!routerIds.has(String(selection.left_router_id))) selection.left_router_id = '';
if (!routerIds.has(String(selection.right_router_id))) selection.right_router_id = '';
if (!allowed.has(Number(selection.left))) selection.left = '';
if (!allowed.has(Number(selection.right))) selection.right = '';
if (selection.left && selection.right && !selection.router_id && !selection.left_router_id && !selection.right_router_id) {
const leftFile = M.state.cache.diffFiles.find(file => String(file.id) === String(selection.left));
const rightFile = M.state.cache.diffFiles.find(file => String(file.id) === String(selection.right));
if (leftFile && rightFile && String(leftFile.router_id) === String(rightFile.router_id)) {
selection.mode = 'same'; selection.router_id = leftFile.router_id;
} else if (leftFile && rightFile) {
selection.mode = 'cross'; selection.left_router_id = leftFile.router_id; selection.right_router_id = rightFile.router_id;
}
}
M.state.diffSelection = selection;
if (M.state.diff && (!allowed.has(Number(M.state.diff.left_backup_id)) || !allowed.has(Number(M.state.diff.right_backup_id)))) M.state.diff = null; if (M.state.diff && (!allowed.has(Number(M.state.diff.left_backup_id)) || !allowed.has(Number(M.state.diff.right_backup_id)))) M.state.diff = null;
return `${M.heading('diffConfigs.title', 'diffConfigs.subtitle', B('latest-diff-pair', t('files.compareLatestPair'), 'clock', M.state.cache.diffFiles.length < 2 ? 'disabled' : '') + B('refresh', t('ui.refresh'), 'refresh'), 'diffConfigs.eyebrow')}<div id="diff-controls">${M.diffControls()}</div><div id="diff-result">${M.diffResult()}</div><div class="notice mt-16">${I('files')}<span>${T('files.binaryNoCompare')} · ${M.state.cache.diffFiles.length} ${T('files.exportPoolLabel')}</span></div>`; return `${M.heading('diffConfigs.title', 'diffConfigs.subtitle', B('latest-diff-pair', t('files.compareLatestPair'), 'clock', M.state.cache.diffFiles.length < 2 ? 'disabled' : '') + B('refresh', t('ui.refresh'), 'refresh'), 'diffConfigs.eyebrow')}<div id="diff-controls">${M.diffControls()}</div><div id="diff-result">${M.diffResult()}</div><div class="notice mt-16">${I('files')}<span>${T('files.binaryNoCompare')} · ${M.state.cache.diffFiles.length} ${T('files.exportPoolLabel')}</span></div>`;
}; };
@@ -237,7 +258,7 @@
const s = settings, hidden = key => M.state.settingsTab === key ? '' : ' hidden'; const s = settings, hidden = key => M.state.settingsTab === key ? '' : ' hidden';
const general = `<div class="form-grid">${F('backup_retention_days', 'settings.backupRetentionDays', s.backup_retention_days, { type: 'number', required: true, attrs: 'min="0" max="36500"' })}${F('log_retention_days', 'settings.logRetentionDays', s.log_retention_days, { type: 'number', required: true, attrs: 'min="0" max="36500"' })}</div>`; const general = `<div class="form-grid">${F('backup_retention_days', 'settings.backupRetentionDays', s.backup_retention_days, { type: 'number', required: true, attrs: 'min="0" max="36500"' })}${F('log_retention_days', 'settings.logRetentionDays', s.log_retention_days, { type: 'number', required: true, attrs: 'min="0" max="36500"' })}</div>`;
const schedules = `${M.scheduleEditor('export', 'settings.exportScheduleTitle', s.export_cron, s.enable_auto_export)}${M.scheduleEditor('binary', 'settings.binaryScheduleTitle', s.binary_cron)}${M.scheduleEditor('retention', 'settings.retentionTitle', s.retention_cron)}<div class="automation-row"><div class="automation-caption"><h3>${T('settings.connectionTestsTitle')}</h3><p class="automation-note">${T('settings.connectionTestsHint')}</p></div><div class="schedule-fields">${F('connection_test_interval_minutes', 'settings.connectionTestIntervalMinutes', s.connection_test_interval_minutes, { type: 'number', attrs: 'min="0" max="1440"' })}</div></div>`; const schedules = `${M.scheduleEditor('export', 'settings.exportScheduleTitle', s.export_cron, s.enable_auto_export)}${M.scheduleEditor('binary', 'settings.binaryScheduleTitle', s.binary_cron)}${M.scheduleEditor('retention', 'settings.retentionTitle', s.retention_cron)}<div class="automation-row"><div class="automation-caption"><h3>${T('settings.connectionTestsTitle')}</h3><p class="automation-note">${T('settings.connectionTestsHint')}</p></div><div class="schedule-fields">${F('connection_test_interval_minutes', 'settings.connectionTestIntervalMinutes', s.connection_test_interval_minutes, { type: 'number', attrs: 'min="0" max="1440"' })}</div></div>`;
const credentials = `<h3 class="section-title">${T('settings.globalSshPrivateKey')}</h3><p class="section-hint">${T('settings.sshRevealHint')}</p>${M.secretField('global_ssh_key', 'settings.globalSshPrivateKey', s.has_global_ssh_key, 'textarea')}<div class="actions mt-15">${B('reveal-ssh', t('settings.revealSshKey'), 'eye', s.has_global_ssh_key ? '' : 'disabled')}${s.has_global_ssh_key ? M.badge(t('settings.sshKeyStoredTag'), 'success') : ''}</div><hr><h3 class="section-title">${T('settings.switchosDefaultsTitle')}</h3><p class="section-hint">${T('settings.switchosDefaultsHint')}</p><div class="form-grid">${F('default_switchos_username', 'settings.defaultSwitchosUsername', s.default_switchos_username || '', { attrs: 'autocomplete="off" maxlength="120"' })}${M.secretField('default_switchos_password', 'settings.defaultSwitchosPassword', s.has_default_switchos_password || s.has_default_switchos_credentials)}</div>`; const credentials = `<h3 class="section-title">${T('settings.globalSshPrivateKey')}</h3><p class="section-hint">${T('settings.sshRevealHint')}</p>${M.secretField('global_ssh_key', 'settings.globalSshPrivateKey', s.has_global_ssh_key, 'textarea')}<div class="actions mt-15">${B('reveal-ssh', t('settings.revealSshKey'), 'eye', s.has_global_ssh_key ? '' : 'disabled')}${s.has_global_ssh_key ? M.badge(t('settings.sshKeyStoredTag'), 'success') : ''}</div><hr><h3 class="section-title">${T('settings.routerosDefaultsTitle')}</h3><p class="section-hint">${T('settings.routerosDefaultsHint')}</p><div class="form-grid">${F('default_routeros_username', 'settings.defaultRouterosUsername', s.default_routeros_username || '', { attrs: 'autocomplete="off" maxlength="120"' })}${M.secretField('default_routeros_password', 'settings.defaultRouterosPassword', s.has_default_routeros_password || s.has_default_routeros_credentials)}</div><hr><h3 class="section-title">${T('settings.switchosDefaultsTitle')}</h3><p class="section-hint">${T('settings.switchosDefaultsHint')}</p><div class="form-grid">${F('default_switchos_username', 'settings.defaultSwitchosUsername', s.default_switchos_username || '', { attrs: 'autocomplete="off" maxlength="120"' })}${M.secretField('default_switchos_password', 'settings.defaultSwitchosPassword', s.has_default_switchos_password || s.has_default_switchos_credentials)}</div>`;
const smtp = `${M.toggle('smtp_notifications_enabled', 'settings.smtpEnabled', s.smtp_notifications_enabled, 'settings.smtpEnabledHint')}${M.toggle('notify_failures_only', 'settings.failuresOnly', s.notify_failures_only, 'settings.failuresOnlyHint')}<div class="form-grid mt-22">${F('smtp_host', 'settings.smtpHost', s.smtp_host || '', { attrs: 'maxlength="255"' })}${F('smtp_port', 'settings.smtpPort', s.smtp_port || 587, { type: 'number', attrs: 'min="1" max="65535"' })}${F('smtp_login', 'settings.smtpLogin', s.smtp_login || '', { attrs: 'autocomplete="off"' })}${M.secretField('smtp_password', 'settings.smtpPassword', s.has_smtp_password)}${F('recipient_email', 'settings.recipientEmail', s.recipient_email || '', { type: 'email', wide: true })}</div><div class="actions mt-22">${B('test-email', t('settings.testEmail'), 'mail')}<small>${T('ui.saveFirst')}</small></div>`; const smtp = `${M.toggle('smtp_notifications_enabled', 'settings.smtpEnabled', s.smtp_notifications_enabled, 'settings.smtpEnabledHint')}${M.toggle('notify_failures_only', 'settings.failuresOnly', s.notify_failures_only, 'settings.failuresOnlyHint')}<div class="form-grid mt-22">${F('smtp_host', 'settings.smtpHost', s.smtp_host || '', { attrs: 'maxlength="255"' })}${F('smtp_port', 'settings.smtpPort', s.smtp_port || 587, { type: 'number', attrs: 'min="1" max="65535"' })}${F('smtp_login', 'settings.smtpLogin', s.smtp_login || '', { attrs: 'autocomplete="off"' })}${M.secretField('smtp_password', 'settings.smtpPassword', s.has_smtp_password)}${F('recipient_email', 'settings.recipientEmail', s.recipient_email || '', { type: 'email', wide: true })}</div><div class="actions mt-22">${B('test-email', t('settings.testEmail'), 'mail')}<small>${T('ui.saveFirst')}</small></div>`;
const pushover = `<div class="form-grid">${M.secretField('pushover_token', 'settings.pushoverToken', s.has_pushover_token)}${M.secretField('pushover_userkey', 'settings.pushoverUserKey', s.has_pushover_userkey)}</div><div class="actions mt-22">${B('test-pushover', t('settings.testPushover'), 'check')}<small>${T('ui.saveFirst')}</small></div>`; const pushover = `<div class="form-grid">${M.secretField('pushover_token', 'settings.pushoverToken', s.has_pushover_token)}${M.secretField('pushover_userkey', 'settings.pushoverUserKey', s.has_pushover_userkey)}</div><div class="actions mt-22">${B('test-pushover', t('settings.testPushover'), 'check')}<small>${T('ui.saveFirst')}</small></div>`;
return `<form id="settings-form" class="settings-form"><div class="settings-fields card"><div class="settings-fields-body card-body"><section data-settings-pane="general"${hidden('general')}><h3 class="section-title">${T('settings.retentionTitle')}</h3><p class="section-hint">${T('settings.retentionSubtitle')}</p>${general}</section><section data-settings-pane="automation"${hidden('automation')}>${schedules}</section><section data-settings-pane="credentials"${hidden('credentials')}>${credentials}</section><section data-settings-pane="smtp"${hidden('smtp')}>${smtp}</section><section data-settings-pane="pushover"${hidden('pushover')}>${pushover}</section></div></div><div class="settings-save">${B('refresh', t('common.reset'), 'refresh')}<button type="submit" class="btn primary">${I('check')}${T('settings.save')}</button></div></form>`; return `<form id="settings-form" class="settings-form"><div class="settings-fields card"><div class="settings-fields-body card-body"><section data-settings-pane="general"${hidden('general')}><h3 class="section-title">${T('settings.retentionTitle')}</h3><p class="section-hint">${T('settings.retentionSubtitle')}</p>${general}</section><section data-settings-pane="automation"${hidden('automation')}>${schedules}</section><section data-settings-pane="credentials"${hidden('credentials')}>${credentials}</section><section data-settings-pane="smtp"${hidden('smtp')}>${smtp}</section><section data-settings-pane="pushover"${hidden('pushover')}>${pushover}</section></div></div><div class="settings-save">${B('refresh', t('common.reset'), 'refresh')}<button type="submit" class="btn primary">${I('check')}${T('settings.save')}</button></div></form>`;
+44 -2
View File
@@ -50,11 +50,13 @@ test('settings and device forms never prefill secret values returned by a legacy
const app = application(); const app = application();
app.state.settingsTab = 'credentials'; app.state.settingsTab = 'credentials';
const secret = 'do-not-render-secret-654321'; const secret = 'do-not-render-secret-654321';
const settings = app.settingsForm({ global_ssh_key: secret, default_switchos_password: secret, smtp_password: secret, pushover_token: secret, pushover_userkey: secret }); const settings = app.settingsForm({ global_ssh_key: secret, default_routeros_username: 'fallback-admin', default_routeros_password: secret, default_switchos_password: secret, smtp_password: secret, pushover_token: secret, pushover_userkey: secret });
const device = app.deviceForm({ id: 1, name: 'R1', device_type: 'routeros', host: '127.0.0.1', ssh_password: secret, ssh_key: secret }); const device = app.deviceForm({ id: 1, name: 'R1', device_type: 'routeros', host: '127.0.0.1', ssh_password: secret, ssh_key: secret });
assert.ok(!settings.includes(secret)); assert.ok(!settings.includes(secret));
assert.ok(!device.includes(secret)); assert.ok(!device.includes(secret));
assert.ok(settings.includes('clear_global_ssh_key')); assert.ok(settings.includes('clear_global_ssh_key'));
assert.ok(settings.includes('name="default_routeros_username"'));
assert.ok(settings.includes('clear_default_routeros_password'));
assert.ok(device.includes('clear_ssh_password')); assert.ok(device.includes('clear_ssh_password'));
}); });
@@ -111,10 +113,11 @@ test('binary and SwitchOS backups never offer comparison actions or export tabs'
assert.ok(!app.detailBody().includes('name="disable_export_backups"')); assert.ok(!app.detailBody().includes('name="disable_export_backups"'));
app.state.cache.routers = [routeros, switchos]; app.state.cache.routers = [routeros, switchos];
app.state.cache.diffFiles = [ app.state.cache.diffFiles = [
{ id: 1, backup_type: 'export', file_name: 'allowed.rsc', device_type: 'routeros' }, { id: 1, router_id: 1, backup_type: 'export', file_name: 'allowed.rsc', device_type: 'routeros' },
{ id: 2, backup_type: 'binary', file_name: 'forbidden.backup' }, { id: 2, backup_type: 'binary', file_name: 'forbidden.backup' },
{ id: 3, backup_type: 'export', file_name: 'forbidden.swb', device_type: 'switchos' } { id: 3, backup_type: 'export', file_name: 'forbidden.swb', device_type: 'switchos' }
]; ];
app.state.diffSelection = { mode: 'same', router_id: 1, left: '', right: '' };
const diff = app.diffControls(); const diff = app.diffControls();
assert.ok(diff.includes('allowed.rsc')); assert.ok(diff.includes('allowed.rsc'));
assert.ok(!diff.includes('forbidden.')); assert.ok(!diff.includes('forbidden.'));
@@ -209,3 +212,42 @@ test('a late device response cannot overwrite the state after navigation', async
assert.equal(await load, ''); assert.equal(await load, '');
assert.equal(app.state.cache.device, undefined); assert.equal(app.state.cache.device, undefined);
}); });
test('diff controls support explicit cross-device pairs and full rendering on demand', () => {
const app = application();
app.state.cache.routers = [
{ id: 1, name: 'R1', host: '10.0.0.1', device_type: 'routeros' },
{ id: 2, name: 'R2', host: '10.0.0.2', device_type: 'routeros' }
];
app.state.cache.diffFiles = [
{ id: 11, router_id: 1, backup_type: 'export', device_type: 'routeros', file_name: 'r1.rsc', created_at: '2026-01-01T00:00:00Z' },
{ id: 22, router_id: 2, backup_type: 'export', device_type: 'routeros', file_name: 'r2.rsc', created_at: '2026-01-02T00:00:00Z' }
];
app.state.diffSelection = { mode: 'cross', left_router_id: 1, right_router_id: 2, left: 11, right: 22 };
const controls = app.diffControls();
assert.ok(controls.includes('cross-device'));
assert.ok(controls.includes('r1.rsc'));
assert.ok(controls.includes('r2.rsc'));
app.state.diffChangesOnly = false;
app.state.diff = {
left_backup_id: 11, right_backup_id: 22, left_file_name: 'r1.rsc', right_file_name: 'r2.rsc',
stats: { added: 250, removed: 0, modified: 0, context: 0 }, smart: null,
lines: Array.from({ length: 250 }, (_, i) => ({ type: 'added', left_number: null, right_number: i + 1, left_text: '', right_text: `line-${i + 1}` }))
};
assert.ok(app.diffResult().includes('data-action="diff-toggle-all"'));
app.state.diffShowAll = true;
const full = app.diffResult();
assert.ok(full.includes('line-250'));
assert.ok(!full.includes('data-action="diff-page-next"'));
assert.ok(full.includes('data-action="open-html-diff"'));
});
test('local file upload form is restricted to RouterOS backup script/binary extensions', () => {
const app = application();
const html = app.uploadFileForm([{ id: 7, name: 'R7', host: '10.0.0.7', device_type: 'routeros' }]);
assert.ok(html.includes('id="device-file-upload-form"'));
assert.ok(html.includes('accept=".rsc,.backup"'));
assert.ok(html.includes('value="7"'));
});