v0.0.2
This commit is contained in:
1 parent
c7d09077fc
commit
f813441bf0
45 files changed
+936
-332
No files matched your search
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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,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,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,4 +1,4 @@
|
|||||||
# Interactive API documentation — 0.0.1
|
# Interactive API documentation — 0.0.2
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}",
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
anyhow::ensure!(
|
|
||||||
table_exists(conn, table)? && columns(conn, table)? == columns(&expected, table)?,
|
|
||||||
"Current schema table {table} is missing or incompatible"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_data_integrity(conn: &Connection) -> Result<()> {
|
||||||
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
@@ -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('\'', "'")
|
.replace('\'', "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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("<script>"));
|
assert!(html.contains("<script>"));
|
||||||
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() {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"'));
|
||||||
|
});
|
||||||
Reference in new issue
Block a user