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

No files matched your search

+2 -1
View File
@@ -3,7 +3,8 @@ APP_NAME="MikroTik Backup System"
APP_ENV=production
APP_PORT=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
TIMEZONE=Europe/Warsaw
DATA_DIR=./storage
+8
View File
@@ -1,5 +1,13 @@
# 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
Initial Rust rewrite of the original Python/TypeScript MikroTik Backup System.
Generated
+1 -1
View File
@@ -1148,7 +1148,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mikrotik-backup"
version = "0.0.1"
version = "0.0.2"
dependencies = [
"anyhow",
"argon2",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mikrotik-backup"
version = "0.0.1"
version = "0.0.2"
edition = "2024"
rust-version = "1.99"
description = "Self-hosted MikroTik backup system: one Rust service with an embedded web interface"
+10 -2
View File
@@ -1,6 +1,6 @@
# MikroTik Backup System
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
@@ -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).
### 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 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;
- 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;
- masking of sensitive values in semantic summaries.
+36 -35
View File
@@ -1,48 +1,48 @@
b1fce4075b13be98f98bb97826e6f8503b6f8684ff409f45fccfee8b721cf574 CHANGELOG.md
341839930e9b231b548dbd1dbbc8e497531ff9c4903b3cf07722f3d9f024e2d8 Cargo.lock
b16e11cea88def78673ceacbb4704259f2e23fccc98645ed7a745435b06dbfbc Cargo.toml
d2dbe44f098b72c7d7c7326147e09e4ce1313a09995031baca1bc61d29d1cb6d Dockerfile
81013dfb92305dbda89ff1a1eee1216d3b2eef4290ba2896fb77cc1506d55218 README.md
b7cab07053ffd10954d69adbb654ae1c00be78ec6f56be6b423c5e65f2be0f0a build.rs
9fdd1d93ba6806b91c36bb295085b831fefd05af807df18442d7493342c22392 compose.yaml
77e0ff099a7d85890555e05b7e6e146832e477c7f3393d1ecdc82c5e50ead91e deploy/mikrotik-backup.service
b47ea5c16b7bb10bb41395c06e50602523ed0e338b8e4d05854c2dfcb130c964 CHANGELOG.md
503e7a205b33055d788377e72f26915326059a1748e501b24378dd813e4f9c78 Cargo.lock
662ce504da8a721dd1cfeada1cd4e3826f72acd5336587e15acea95b39fb1bfa Cargo.toml
107f2abdb9a030c5b7854357c89df0cc22bb42bad28092c15a67274779aa7c69 Dockerfile
20f0f53b627c5bbc19ed213a31eb623320b8fe7257ae7a10ac744a75b031a2f8 README.md
6da56598b57511885067788413b235b7a90a72cf95e7d14a06d9df800b39fdfd build.rs
3103411f556fd1eef92b178b9ad64967454b92a5779231441b491ebad41f7d8f compose.yaml
911d775c98604f8330eb08348d4960c62dfd6c8444abf60cfaf7fbf320c81a95 deploy/mikrotik-backup.service
2bf2eda4b31ab6c08a34b7800c072320e397b50d112858c840a408a58764d016 deploy/start.sh
4d0b41060eda3a422ff00d357fabf73bd3229e9df8a7f755e128c989eb59de93 docs/THIRD_PARTY_NOTICES.txt
13dcb5c1bdbc71e1902d7c1237ccf67a6ce32ce081de9dd35e6b3b4cb8ff7b0a docs/api-ui/index.html
9e07aa7d91594d7066470face7f43e3a8bbd69cbd88fd845343f5689c0ca7027 docs/api-ui/init.js
a5cea2ffa49b8d2a0f11ed99e9b9602f600799fb0bfffa57d844c2be773e34b9 docs/api-ui/style.css
a245139dd8e13ca4b8743077f6011a8f31dab48ff0569caa6e5b6e0ddf0e2021 docs/THIRD_PARTY_NOTICES.txt
b8b3bd39a1e792f0e1c63c6f095506e61a61dee9f1983bcb0391e12bbbbb93ad docs/api-ui/index.html
8d43b1349e3e30dbf1fb677fc310c919439fb318da016c8f1d1f0b46bca4c28c docs/api-ui/init.js
eb9f5f220a465faa34f1bce7a09169241604ae7a8e363efed111a38b6d3befa1 docs/api-ui/style.css
061a0b6a4a2ce5823edbcbf8d7f47a775d348d4cd4f125fd6145189e717feba1 docs/dependency-audit-metadata.json
49e44f55fdaf3e2aacd358d312489882a91147f8790a81348ffa3f9fd76e6634 docs/dependency-audit.json
dcebf59142a77d8f84950d535db0fa6b656519d530ccdfba2855f9ff0fc3f475 docs/migration.md
8d7b4db7302767a0b0d34df5c05270580ac6ae709e0b3ac002668a1a7b2aaf96 docs/openapi.json
cb90e9f1f1827770c67561ca68f5b30f5b025a50da6804ee8c59efa9abd1c716 docs/security.md
5dd31dff53db62fc97e94f02a52efb13c44b6a3950aa13416f4888ef215503e3 docs/switchos.md
c3a7e4304fa3960e4a65a5a1248b2e69f197e4bd60e51c11274ebec19634259f docs/migration.md
967ac1a063ca43017ad3c3850cbad72674c0c3150f6b1673e94ae53fa1f98e2d docs/openapi.json
e4f9b9d5d82edc66f395bd5c416974c9663088463153c533b28b84705f231e97 docs/security.md
9dc2baa1837a30c8794c9edaf05e4b2e2764259898a74cf511e40d618b92aa84 docs/switchos.md
1533deb562e6cd6379a3c8b7988f8b3170c6629a04c874d21a315bcc2ea94407 import-python.sh
a9c47bfae030614a21ea30893c285398251340624caec4d029c5b090b7431936 migrations/001_initial.sql
048c5f9d02a6c8f4dac0bbcc5b50650ff0ad036116ba463ae554f43dec750d54 migrations/002_switchos_credentials_source.sql
cb8a70125de6688e738ef27811d4297e575a35248b8c00f262fb86cff9ce03ce migrations/003_backup_layout.sql
4d71d9eb0fa89d7182b20fe448cae00c22a851258727451052acaf3145b29fbc release-manifest.json
7bf1f9f76c8a01c5f9cc464c1256d1cf5b6d05f379cecdac97eee5c07fa94175 release-manifest.json
638924b15afb3b6f88b83713e356162659987378c6731a2ea3403b90e31ae0eb rust-toolchain.toml
2883973832dd23f34889b1788ca7ddcd57e2410cf28245b8da55dacd45960d43 src/api/auth.rs
079473319684d445eb7b9b8b01df7207fd96eed5a98287fa541376692e6d1204 src/api/backups.rs
eda20155c645788ebc238a4553edaa90b7e5314782e82337125f41ba50b90234 src/api/devices.rs
aa876ec7f03c5be28801883319eb13c0152640579031d8bec50fc98c00684bc0 src/api/backups.rs
27d891210c8918373fbdb877f06dc9e900057850e50ef9ced4f8ff5aa20491b2 src/api/devices.rs
9827188b7a89573a1b108a3d0cb58e2f720d4b62549cf062b44be767f8aaafa5 src/api/documentation.rs
8f4f8ee55780e90d20c85920a471e4f72b3f17b92cb51469e4634176d6daba3b src/api/mod.rs
9e68ac135312bd7fc96c99b8eb06b93efb906c5d46bbe568ea897ef550e13f8f src/api/mod.rs
81ae453a28edaebf554e11324d088cfe95dbdc6625e388e7f1f5398d94d72e7b src/api/system.rs
7b6959c99e9eca52e1f0aa862c32bb7ca0f988b6b4d7b1c0d1eb8bbd448a5f25 src/backup.rs
5185c0e69a17d3805d6038208964d2dfb8b3a563ec6230d341dbccca20cb6cf8 src/backup.rs
1eee0ed74a61882247332dea93372cba54f310211ed05095712054b0432d507a src/config.rs
bd5d86db6192528ad4c7d79f422650317dd37bfd1e0b8ad623244b4ba93ef545 src/db.rs
30e35f94bf7d876ce0f8fc696f47e6e7680da7fe74e43d47408a0140dac6e5e1 src/diff.rs
87b2ad47c5ab7e33f4ca7ac823b6af006bfc087ee7d34459e79bca89b82429b1 src/db.rs
84db7474b9e218569229bdc466ad564691aec83ae16c322546c70ac7d904f44c src/diff.rs
18d23f46601f69dcf5ce817709e3b7e9ae08aa282bd29b2900946a81bdf78342 src/lib.rs
92c3b8afe80d5d36e21e5fb8da6d718a69cab677ae935528105b0dd026b13869 src/main.rs
89aad2d2795bfbfb18178818aa9908a01d010167d8647f7563aa85c0397e9622 src/models.rs
e570ce7cad4fe4f325419e6cae0d15ff6ecb4b27f79ef3e9c77508a4cdd279d9 src/notifications.rs
2db2f31fbf2c809c6a82cdf8a69dd0012570298b71bd2d2e5d7844ff39903a7c src/routeros.rs
9800272a2bfb4b9f8ea1c5fbb2c83953ea0db025786357d6c7daf26f204dfa5d src/models.rs
8318c912ecfe5214d1b2446e0e0be958ccacbc1700aac792a5f68a5c7b15f9a5 src/notifications.rs
2b63ef9bcfbd21687ff430dcb7f98c8a2e325336d1ec212d6414367430e2096e src/routeros.rs
733a77ddf1dbd6b64e6ec425ee4e2b6f842a0902c61c274f0a1171d69ea5a3ce src/scheduler.rs
6a002266e727e17a23260e25c151191889cee8a793e4412f57cd6ef35cc137e9 src/schema.sql
6bcbb64224105ace3c7711b6e82c034d393732bf8c8b7337511ed1fed8e55e32 src/schema.sql
77acdb3df08469f6834b952e2f75f0ec5198073f781630e16cd9b327f7757258 src/security.rs
ce3b2dd7e7005a80571494c954f18d6042ee88d055deab346b191ca9374daaf7 start.sh
b7e80c541653c41e0ebb1ab7c65e35ace5f58adc40f628292a6d18d10ba58dd9 tests/api_contract.rs
9348833266f61023f25101a069625db63bdfe52f08cc36af4d437e71b3cf2192 tests/api_contract.rs
dd4d70d7de7209d1113eebaa6799c39535a88ac2cc783445350283fb1c9c5f2e tests/fixtures/python-diff.json
bae48d41ce4561196c4428bcb274502e4d8e04fee28b9684c2c579ad82749b18 tests/import_launcher.rs
3cd3d0763f3b46b752720257b183669c602d39f1b07e35ddf07676cfbb5d0eae tests/openapi_contract.rs
@@ -144,12 +144,13 @@ c02dd4680423fdaffe96012cd012adda68b26281152a95355744ed4f6141c282 vendor/swagger
e5a4792feaa5332dfba37f1d7adf84308b93b4f2d8da0569a9f48a3cb61c969b vendor/swagger-ui/swagger-ui-bundle.js.LICENSE.txt
c3cfe8248c4b0b89673600fb829924eafbac6de4bc4b2b06f5f5243483074d0a vendor/swagger-ui/swagger-ui.css
32a37246461a62133053549be4917c5f879410440d67c2e24c76a294a06f9ddb web/assets.list
c88b1848a818c76a76cc4ca57759002fae88d3470afaa8d3a60bea3977d35b00 web/css/app.css
706eb87cc7a6c538f58df091f91cc223a9de4aed308717625a9625495bbde507 web/index.html
cc5062ef7c94e375bb67e5d1127458c0f02163d4bcc0f9e618b44dee458fd58c web/js/app.js
3d40fd0665a87e3d26cabfc20d0292498750c1b4974a69538eb0cb0e9344c4e3 web/js/core.js
8c8ed4800886c9aebb7ac52de8351b2fc26dbafc8977e89217c631b960c228cc web/js/i18n.js
510f9cc6137777e03062d23a5bf841f5b6033b1f976a4825bb4700dfba4d40fc web/js/views.js
5423de8d4b4da517aa2d740a08bbb6fcc6e1d03e0b0d8dd255a489d08c2365ef web/tests/core.test.cjs
9dc176bca56cda46f9d17d8c520469d583afbe2b1c3c75bdba8e82453a3ae62b web/css/app.css
5db148afe605137f20f70c5cfaabc69142415c546eea63a1aab8e7ae8aacdc77 web/index.html
9f72a910cab2e7f71dd4fbbcf2110648b8bad0cd4adf635183e310a7ca7756cf web/js/app.js
03a6dc408ee473c7fa5e1edd412147300dbe25436c0106f93d6ba7080ca41e01 web/js/core.js
2571bf7ae9882c2aee5cbfe33c59707f851c440c02083c897436e63ce80491d4 web/js/i18n.js
1921e3641aa920aba0f1f1e69fdedacfa02bf60717bca44d004b9e33a4d72374 web/js/views.js
b007dc2a47fb520c0ca3bdb4cb154e09da1090991c79d01e14ea35a74a81ecea web/tests/core.test.cjs
78955d3bb7eacc8f4144570df5d54b17b54419d403c6d98472d7314b21c8e377 web/tests/live.cjs
de7d09e3d50c339962aa75d4e7603fcd3457e1ba1c2c2c0d5c536c83a005c77d web/tests/smoke.cjs
686cfb85e9139ede4c2ddb395d2e44784c5bdb344aa2cb8928ab6eca35074b06 migrations/004_routeros_default_credentials.sql
+1 -1
View File
@@ -44,7 +44,7 @@ fn main() {
let manifest = fs::read_to_string("web/assets.list").expect("web/assets.list is required");
let 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();
for entry in manifest
.lines()
+1 -1
View File
@@ -1,7 +1,7 @@
services:
app:
build: .
image: mikrotik-backup:0.0.1
image: mikrotik-backup:0.0.2
restart: unless-stopped
ports:
- "${BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-5581}:5581"
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=MikroTik Backup System 0.0.1
Description=MikroTik Backup System
After=network-online.target
Wants=network-online.target
+1 -1
View File
@@ -1,4 +1,4 @@
THIRD-PARTY NOTICES — MikroTik Backup System 0.0.1
THIRD-PARTY NOTICES — MikroTik Backup System 0.0.2
Generated from the locked Cargo dependency graph for x86_64-unknown-linux-gnu.
This inventory includes build/test dependencies where they occur in that graph.
Original license and notice texts below are reproduced without alteration.
+1 -1
View File
@@ -1,4 +1,4 @@
# Interactive API documentation — 0.0.1
# Interactive API documentation — 0.0.2
## URLs
+2 -7
View File
@@ -1,4 +1,4 @@
# Architecture — release 0.0.1
# Architecture — release 0.0.2
## 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
`src/schema.sql` contains the complete current schema. If the database file does not
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.
`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.
The separate `import-python --source DIR [--database PATH] [--backup-root DIR]`
command imports into a new destination. The `import-python.sh` wrapper builds the
+6 -17
View File
@@ -1,8 +1,6 @@
# Fresh installation and explicit Python import
Version **0.0.1** starts with a clean installation. It does not update an old
schema or move existing backup files during startup. Importing data from the
Python/TypeScript application is a separate, explicit operation.
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.
## 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
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`
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.
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.
Before creating storage locks or secrets, the server performs a read-only
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
`-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.
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.
Python/TypeScript schemas, unsupported Rust work-in-progress schemas, unknown versions, and pre-existing empty database files are rejected. They are never imported automatically.
## Import command
+6 -10
View File
@@ -1,11 +1,10 @@
# Importing data from the Python/TypeScript release
# Data migration
The current release does not perform automatic data migration and does not expose the
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.
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.
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
DATA_DIR="$PWD/storage-imported" \
@@ -15,7 +14,4 @@ DATABASE_PATH="$PWD/storage-imported/routeros_backup_next.db" \
--backup-root docker-data
```
Stop the old application first. The destination directory must be new and must not
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.
Stop the old Python/TypeScript application first. The import destination must be new and must not contain an existing database.
+2 -2
View File
@@ -1,7 +1,7 @@
# Dependency review — release 0.0.1
# Dependency review — release 0.0.2
The audit documented here was performed on **27 October 2026 for source revision 2**
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
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).
+1 -1
View File
@@ -1,6 +1,6 @@
# 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
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"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."
},
"servers": [
+5 -10
View File
@@ -1,4 +1,4 @@
# Port coverage matrix — 0.0.1
# Port coverage matrix — 0.0.2
## Scope and source identity
@@ -86,9 +86,7 @@ contains `access_token`, `token_type=bearer`, and `user`.
## Models and source fields
Models are explicitly typed in `src/models.rs`. `src/db.rs` implements persistence,
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.
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.
| 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. |
| 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 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 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. |
| 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
ports, negative retention, and excessive request sizes are rejected. Jobs are
prevented from overlapping themselves.
- Revision 4 initializes only a fresh schema and validates an existing database
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).
- 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).
- Runtime is one Rust process with SQLite, scheduling, and an embedded interface.
Python, Node, Angular, and nginx are not required application processes.
+1 -1
View File
@@ -1,6 +1,6 @@
# 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:
+1 -1
View File
@@ -43,4 +43,4 @@ Do not treat an HTTP `200` login page as a valid backup.
## Scope
Version 0.0.1 downloads SwOS backups. It does not restore/upload a SwOS configuration or reboot a switch.
Version 0.0.2 downloads SwOS backups. It does not restore/upload a SwOS configuration or reboot a switch.
+1 -1
View File
@@ -1,4 +1,4 @@
# Interface — 0.0.1
# Interface — 0.0.2
The interface uses the application's real API. It does not populate sample devices,
simulate operation results, or present disconnected actions as working features.
+7 -7
View File
@@ -7,7 +7,8 @@ respective source revisions.
| Area | Current result | Scope |
| --- | --- | --- |
| 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 |
| 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 |
@@ -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
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 |
| 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` |
| 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 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 |
@@ -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.
The project contains the Rust, launcher, and interface regression tests.
The installation sequence is documented in [data-import.md](data-import.md).
There is no automatic upgrade, startup conversion, or migration recovery framework.
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.
## 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 |
| 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 |
| 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 |
| Final formatting | **PASS** | `cargo fmt --package mikrotik-backup -- --check` |
| 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:
- `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**.
- Playwright browser acceptance: **NOT RUN** because the Playwright Node module is not installed.
- Rust compiler/tests: **NOT RUN** because `cargo` and `rustc` are unavailable.
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
import os
import sys
import zipfile
import subprocess
from pathlib import Path
def run_git_command(args, repo_path: Path) -> bytes:
result = subprocess.run(
["git", *args],
cwd=repo_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
return result.stdout
def get_files_to_archive(repo_path: Path) -> list[str]:
output = run_git_command(
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
repo_path,
)
files = output.decode("utf-8", errors="surrogateescape").split("\0")
return [f for f in files if f]
def make_zip(repo_path: Path, output_zip: Path) -> None:
files = get_files_to_archive(repo_path)
output_zip = output_zip.resolve()
if output_zip.exists():
output_zip.unlink()
with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for rel_path in files:
abs_path = repo_path / rel_path
if not abs_path.exists():
continue
if abs_path.resolve() == output_zip:
continue
zf.write(abs_path, arcname=rel_path)
print(f"Utworzono archiwum: {output_zip}")
print(f"Dodano plików: {len(files)}")
def main():
repo_path = Path.cwd()
if len(sys.argv) > 1:
output_zip = Path(sys.argv[1])
else:
output_zip = repo_path / f"{repo_path.name}.zip"
try:
run_git_command(["rev-parse", "--show-toplevel"], repo_path)
except subprocess.CalledProcessError:
print("Błąd: ten katalog nie jest repozytorium Git.", file=sys.stderr)
sys.exit(1)
make_zip(repo_path, output_zip)
if __name__ == "__main__":
main()
@@ -0,0 +1,6 @@
-- Automatic migration from the 0.0.1 Rust schema (schema v1) to 0.0.2 (schema v2).
-- The application applies the equivalent steps transactionally at startup.
ALTER TABLE global_settings ADD COLUMN default_routeros_username TEXT;
ALTER TABLE global_settings ADD COLUMN default_routeros_password TEXT;
UPDATE schema_info SET schema_version = 2
WHERE id = 1 AND application = 'mikrotik-backup' AND schema_version = 1;
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "MikroTik Backup System",
"version": "0.0.1",
"version": "0.0.2",
"distribution": "source",
"prebuilt_binary": false,
"rust_toolchain": "1.99.1",
@@ -15,10 +15,12 @@
},
"data": {
"schema": "src/schema.sql",
"automatic_migrations": false,
"automatic_migrations": true,
"legacy_import": "explicit import-python command into a new destination",
"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": {
"strategy": "content-hashed filenames generated at build time",
@@ -34,10 +36,11 @@
},
"verification": {
"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",
"browser_tests": "NOT RUN in patch environment because Playwright is unavailable",
"physical_switchos_device": false,
"docker_runtime": false
"docker_runtime": false,
"sqlite_schema_migration": "PASS: schema v1 -> v2 preserves existing settings and matches fresh schema column layout"
}
}
+91 -1
View File
@@ -3,15 +3,18 @@ use crate::{AppState, backup, diff, models::BackupRecord};
use axum::{
Json,
body::Body,
extract::{Path, Query, State},
extract::{Path, Query, Request, State},
http::{StatusCode, header},
response::{Html, IntoResponse, Response},
};
use http_body_util::BodyExt;
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::io::AsyncWriteExt;
use std::{
collections::{HashMap, HashSet},
io::{Seek, SeekFrom},
path::Path as FsPath,
};
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))?;
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(
State(state): State<AppState>,
Auth(user): Auth,
-3
View File
@@ -144,9 +144,6 @@ fn apply(router: &mut RouterRecord, payload: RouterInput) -> ApiResult<()> {
if payload.clear_ssh_password {
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 {
router.disable_all_backups = value;
}
+9 -3
View File
@@ -49,6 +49,7 @@ impl ApiError {
if let Ok(settings) = state.db.get_settings() {
for secret in [
&settings.global_ssh_key,
&settings.default_routeros_password,
&settings.default_switchos_password,
&settings.smtp_password,
&settings.pushover_token,
@@ -138,6 +139,7 @@ pub fn router(state: AppState) -> Router {
"/backups/router/{id}/upload/{backup_id}",
post(backups::upload),
)
.route("/routers/{id}/files/upload", post(backups::upload_file))
.route("/backups/bulk", post(backups::bulk))
.route("/backups/{id}", delete(backups::remove))
.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("/favicon.ico", get(favicon))
.fallback(index)
.layer(DefaultBodyLimit::max(1024 * 1024))
.layer(DefaultBodyLimit::disable())
.layer(middleware::from_fn(bounded_request_body))
.layer(middleware::from_fn(security_headers))
.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> {
// All input endpoints accept small JSON/form bodies. Device uploads use
// already stored backups, so there is no streaming HTTP upload to interrupt.
// JSON/form endpoints are intentionally small. The dedicated device-file upload
// 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 {
Ok(Ok(bytes)) => Ok(bytes),
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 {
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();
match collect_request_body(body, Duration::from_secs(15)).await {
Ok(bytes) => {
+36 -9
View File
@@ -1,4 +1,3 @@
//! Backup lifecycle with device-level exclusion, bounded transfers and atomic files.
use crate::{
AppState,
config::{create_private_dir, protect_file},
@@ -108,8 +107,6 @@ fn resolve_path_in(data_dir: &Path, backup: &BackupRecord) -> Result<PathBuf> {
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> {
ensure!(router_id > 0, "Invalid backup device ID");
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");
let folder = layout_folder(&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 {
backup.file_name.clone()
} else {
@@ -309,7 +304,6 @@ pub async fn run(state: &AppState, router: &RouterRecord, kind: &str) -> Result<
let state = state.clone();
let router = router.clone();
let kind = kind.to_owned();
// This owned task holds the lock even if the requesting HTTP connection closes.
let workers = state.workers.clone();
workers
.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<()> {
ensure!(
router.device_type == "routeros",
@@ -540,7 +570,6 @@ pub fn delete(state: &AppState, backup: &BackupRecord) -> Result<()> {
let path = match resolve_path(state, backup) {
Ok(path) => Some(path),
Err(error) => {
// Missing files may have their stale metadata removed; unsafe paths must never be touched.
if error
.downcast_ref::<std::io::Error>()
.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<()> {
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);
bail!(
"Could not restore backup after failed metadata deletion; recovery file retained at {}: {error}",
+240 -42
View File
@@ -1,4 +1,3 @@
//! Current-schema SQLite storage and explicit, copy-only Python installation import.
use anyhow::{Context, Result, bail};
use fs2::FileExt;
use rusqlite::{Connection, OpenFlags, OptionalExtension, Row, params};
@@ -22,8 +21,6 @@ pub struct Db {
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 {
_files: Vec<fs::File>,
}
@@ -65,8 +62,6 @@ pub fn lock_storage(config: &Config) -> Result<StorageLock> {
}
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> {
let mut normalized = config.clone();
normalize_storage_paths(&mut normalized)?;
@@ -92,7 +87,7 @@ impl Db {
)
.context("Cannot open current SQLite database")?;
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)?;
protect_file(&config.database_path)?;
conn.pragma_update(None, "foreign_keys", true)?;
@@ -203,13 +198,14 @@ impl Db {
}
pub fn save_settings(&self, settings: &Settings) -> Result<()> {
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.retention_cron, settings.enable_auto_export, settings.connection_test_interval_minutes,
settings.global_ssh_key, settings.default_switchos_username, settings.default_switchos_password,
settings.pushover_token, settings.pushover_userkey, settings.notify_failures_only, settings.smtp_host,
settings.smtp_port, settings.smtp_login, settings.smtp_password, settings.smtp_notifications_enabled,
settings.recipient_email, settings.id])?;
settings.global_ssh_key, settings.default_routeros_username, settings.default_routeros_password,
settings.default_switchos_username, settings.default_switchos_password, settings.pushover_token,
settings.pushover_userkey, settings.notify_failures_only, settings.smtp_host, settings.smtp_port,
settings.smtp_login, settings.smtp_password, settings.smtp_notifications_enabled, settings.recipient_email,
settings.id])?;
if changed != 1 { bail!("Settings record no longer exists"); }
Ok(())
})
@@ -217,7 +213,9 @@ impl Db {
}
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] = [
"users",
"routers",
@@ -236,7 +234,6 @@ struct 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})"))?;
Ok(statement
.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<()> {
match fs::symlink_metadata(&config.database_path) {
Ok(metadata) => {
@@ -275,7 +270,7 @@ pub fn preflight_existing(config: &Config) -> Result<()> {
&snapshot,
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) => Err(error).context("Cannot inspect database path"),
@@ -313,8 +308,6 @@ fn future_path(path: &Path) -> Result<PathBuf> {
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<()> {
config.data_dir = future_path(&config.data_dir)?;
let parent = config
@@ -416,24 +409,19 @@ fn check_integrity(conn: &Connection) -> Result<()> {
Ok(())
}
fn validate_current_schema(conn: &Connection) -> Result<()> {
fn schema_marker(conn: &Connection) -> Result<(String, i64, i64)> {
anyhow::ensure!(
table_exists(conn, "schema_info")?,
"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)?)))?;
anyhow::ensure!(
marker == ("mikrotik-backup".into(), 1, 1),
"Unsupported application schema version"
);
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"
);
Ok(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)?)),
)?)
}
fn validate_data_integrity(conn: &Connection) -> Result<()> {
check_integrity(conn)?;
let users: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?;
let settings: i64 =
@@ -448,6 +436,114 @@ fn validate_current_schema(conn: &Connection) -> Result<()> {
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<()> {
let parent = config
.database_path
@@ -576,6 +672,8 @@ fn settings_row(row: &Row<'_>) -> rusqlite::Result<Settings> {
.get::<_, Option<i64>>("connection_test_interval_minutes")?
.unwrap_or(0),
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_password: row.get("default_switchos_password")?,
pushover_token: row.get("pushover_token")?,
@@ -925,8 +1023,6 @@ fn destination_is_separate(config: &Config, source: &Path, selected: &PythonSour
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(
config: &Config,
source: &Path,
@@ -958,8 +1054,6 @@ pub fn import_python(
);
destination_is_separate(config, &source, &selected)?;
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");
snapshot_stopped_database(&selected.database, &source_snapshot)?;
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<()> {
let parent = destination.parent().context("Destination has no 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)?;
std::io::copy(&mut fs::File::open(source)?, &mut target)?;
target.as_file().sync_all()?;
@@ -1085,8 +1176,6 @@ fn publish_copy_no_clobber(source: &Path, destination: &Path, published: &mut bo
target
.persist_noclobber(destination)
.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;
crate::backup::sync_directory(parent)?;
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) {
let source = root.join("python-app");
let storage = source.join("storage");
@@ -1194,7 +1313,7 @@ mod tests {
}
#[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 config = config(dir.path());
let db = Db::open(&config).unwrap();
@@ -1225,6 +1344,85 @@ mod tests {
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]
fn startup_rejects_python_database_without_changing_bytes_permissions_or_files() {
let dir = tempfile::tempdir().unwrap();
@@ -1238,7 +1436,7 @@ mod tests {
Ok(_) => panic!("legacy database was accepted"),
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_eq!(fs::read(&config.database_path).unwrap(), before);
assert_eq!(
+98 -32
View File
@@ -1,4 +1,3 @@
//! Text-only side-by-side and unified diffs; all HTML output is escaped.
use crate::{AppState, backup, models::BackupRecord};
use anyhow::{Result, ensure};
use serde_json::{Value, json};
@@ -14,19 +13,52 @@ pub fn escape(value: &str) -> String {
.replace('\'', "&#39;")
}
fn standalone_diff_html(diff_text: &str, left_name: &str, right_name: &str) -> String {
let mut html = format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Diff: {} ↔ {}</title><style>:root{{color-scheme:light dark}}*{{box-sizing:border-box}}body{{margin:0;background:#f3f3f3;color:#111;font:13px/1.45 ui-monospace,SFMono-Regular,Consolas,\"Liberation Mono\",monospace}}header{{position:sticky;top:0;padding:10px 14px;border-bottom:1px solid #aaa;background:#e7e7e7;z-index:1}}h1{{font:700 14px/1.2 inherit;margin:0 0 5px}}.meta{{color:#444;font-size:12px}}pre{{margin:0;padding:10px 0;overflow:auto;background:#fff;min-height:calc(100vh - 58px)}}.line{{display:block;min-width:max-content;padding:0 14px;white-space:pre}}.head{{font-weight:700;background:#ddd}}.hunk{{color:#0047a3;background:#e8f0ff}}.add{{background:#e4f7df;color:#164b0f}}.del{{background:#ffe0e0;color:#7d1717}}.empty{{padding:18px 14px;color:#555}}@media(prefers-color-scheme:dark){{body{{background:#141414;color:#ddd}}header{{background:#222;border-color:#444}}.meta{{color:#aaa}}pre{{background:#111}}.head{{background:#292929}}.hunk{{background:#14243d;color:#9fc4ff}}.add{{background:#18351c;color:#bde6b7}}.del{{background:#3b1b1b;color:#ffc0c0}}.empty{{color:#aaa}}}}</style></head><body><header><h1>Configuration diff</h1><div class=\"meta\">{} ↔ {}</div></header><pre>",
escape(left_name),
escape(right_name),
escape(left_name),
escape(right_name)
);
if diff_text.is_empty() {
html.push_str("<span class=\"empty\">No differences.</span>");
} else {
for line in diff_text.lines() {
let class = if line.starts_with("--- ") || line.starts_with("+++ ") {
"line head"
} else if line.starts_with("@@ ") {
"line hunk"
} else if line.starts_with('+') {
"line add"
} else if line.starts_with('-') {
"line del"
} else {
"line"
};
html.push_str(&format!(
"<span class=\"{}\">{}</span>\n",
class,
escape(line)
));
}
}
html.push_str("</pre></body></html>");
html
}
pub fn compare(state: &AppState, left: &BackupRecord, right: &BackupRecord) -> Result<Value> {
compare_files(state, left, right, true, true)
}
/// Compatibility payload used by the public API and tests: unified text is
/// retained, while the standalone HTML document is omitted.
pub fn compare_browser(state: &AppState, left: &BackupRecord, right: &BackupRecord) -> Result<Value> {
pub fn compare_browser(
state: &AppState,
left: &BackupRecord,
right: &BackupRecord,
) -> Result<Value> {
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(
state: &AppState,
left: &BackupRecord,
@@ -146,31 +178,28 @@ fn compare_text_options(
"lines": rows,
"smart": smart_analysis(left, right)
});
if include_text {
result["diff_text"] = json!(unified_diff(
let unified = (include_text || include_html).then(|| {
unified_diff(
&operations,
&left_lines,
&right_lines,
left_name,
right_name,
));
)
});
if include_text {
result["diff_text"] = json!(unified.as_deref().unwrap_or(""));
}
if include_html {
let mut html = format!(
"<!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>",
escape(left_name),
escape(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);
result["diff_html"] = json!(standalone_diff_html(
unified.as_deref().unwrap_or(""),
left_name,
right_name,
));
}
Ok(result)
}
#[derive(Clone, Debug)]
struct SmartCommand {
section: String,
@@ -192,11 +221,20 @@ fn smart_analysis(left: &str, right: &str) -> Value {
let mut attention_sections = 0usize;
for section in names {
let left_items: Vec<_> = left_commands.iter().filter(|item| item.section == section).collect();
let right_items: Vec<_> = right_commands.iter().filter(|item| item.section == section).collect();
let left_items: Vec<_> = left_commands
.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();
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 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 right_by_key: HashMap<String, Vec<&SmartCommand>> = HashMap::new();
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 {
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();
@@ -230,7 +274,9 @@ fn smart_analysis(left: &str, right: &str) -> Value {
let mut details = Vec::new();
let mut modified = 0usize;
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:") {
continue;
}
@@ -264,7 +310,10 @@ fn smart_analysis(left: &str, right: &str) -> Value {
if added == 0 && removed == 0 && modified == 0 {
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!({
"kind": "removed",
"verb": item.verb.as_str(),
@@ -272,7 +321,10 @@ fn smart_analysis(left: &str, right: &str) -> Value {
"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!({
"kind": "added",
"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 fields = assignment_fields(&tokens);
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)
}
@@ -440,7 +498,9 @@ fn shellish_tokens(value: &str) -> Vec<String> {
fn assignment_fields(tokens: &[String]) -> BTreeMap<String, String> {
let mut fields = BTreeMap::new();
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() {
fields.insert(key.to_string(), value.to_string());
}
@@ -568,7 +628,10 @@ fn classify_section(section: &str) -> (&'static str, &'static str) {
{
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");
}
("low", "other")
@@ -846,6 +909,9 @@ mod tests {
assert!(!html.contains("<script>"));
assert!(html.contains("&lt;script&gt;"));
assert!(!html.contains("<img src=x>"));
assert!(html.contains("<pre>"));
assert!(html.contains("line del"));
assert!(html.contains("line add"));
}
#[test]
fn final_newline_and_crlf_are_normalized() {
-7
View File
@@ -11,23 +11,16 @@ struct Arguments {
}
#[derive(Subcommand)]
enum Command {
/// Run API, web interface, and background schedules.
Serve,
/// Check the running service and exit with status 0/1.
Healthcheck,
/// Explicitly import a stopped Python/TypeScript installation into a new target.
ImportPython {
/// Python/TypeScript application directory.
#[arg(long)]
source: PathBuf,
/// Source SQLite file; relative paths are resolved against --source.
#[arg(long)]
database: Option<PathBuf>,
/// Source backup directory; relative paths are resolved against --source.
#[arg(long)]
backup_root: Option<PathBuf>,
},
/// Validate runtime configuration without starting the server.
CheckConfig,
}
+62 -9
View File
@@ -1,4 +1,3 @@
//! Database records. Secret fields are deliberately excluded from JSON and Debug output.
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -30,7 +29,6 @@ pub struct RouterRecord {
pub host: String,
pub port: i64,
pub ssh_user: String,
/// None preserves the original per-field fallback for migrated devices.
#[serde(default)]
pub use_global_switchos_credentials: Option<bool>,
#[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>(
&'a self,
settings: &'a Settings,
@@ -101,18 +97,56 @@ impl RouterRecord {
None => "SwitchOS username is not configured on device or globally",
})
})?;
// SwOS permits an empty password; it remains from the selected source.
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 {
let is_routeros = self.device_type == "routeros";
let own_key = present(&self.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_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) =
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 object = value.as_object_mut().expect("record is an object");
object.insert("ssh_key".into(), Value::Null);
@@ -137,11 +171,15 @@ impl RouterRecord {
object.insert(
"has_effective_password".into(),
json!(if is_routeros {
present(&self.ssh_password)
own_password.is_some() || default_routeros_password.is_some()
} else {
!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_restore_upload".into(), json!(is_routeros));
value
@@ -179,6 +217,9 @@ pub struct Settings {
pub connection_test_interval_minutes: i64,
#[serde(skip_serializing)]
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>,
#[serde(skip_serializing)]
pub default_switchos_password: Option<String>,
@@ -208,6 +249,8 @@ impl Default for Settings {
enable_auto_export: false,
connection_test_interval_minutes: 0,
global_ssh_key: None,
default_routeros_username: None,
default_routeros_password: None,
default_switchos_username: None,
default_switchos_password: None,
pushover_token: None,
@@ -229,6 +272,7 @@ impl Settings {
let object = value.as_object_mut().expect("record is an object");
for (name, field) in [
("global_ssh_key", &self.global_ssh_key),
("default_routeros_password", &self.default_routeros_password),
("default_switchos_password", &self.default_switchos_password),
("pushover_token", &self.pushover_token),
("pushover_userkey", &self.pushover_userkey),
@@ -237,6 +281,13 @@ impl Settings {
object.insert(name.into(), Value::Null);
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(
"has_default_switchos_credentials".into(),
json!(
@@ -247,8 +298,6 @@ impl Settings {
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<()> {
let object = patch
.as_object()
@@ -257,6 +306,7 @@ impl Settings {
let merged = value.as_object_mut().expect("record is an object");
for (name, field) in [
("global_ssh_key", &self.global_ssh_key),
("default_routeros_password", &self.default_routeros_password),
("default_switchos_password", &self.default_switchos_password),
("pushover_token", &self.pushover_token),
("pushover_userkey", &self.pushover_userkey),
@@ -266,6 +316,7 @@ impl Settings {
}
let secrets = [
"global_ssh_key",
"default_routeros_password",
"default_switchos_password",
"pushover_token",
"pushover_userkey",
@@ -293,6 +344,8 @@ impl Settings {
candidate.retention_cron = candidate.retention_cron.trim().into();
for item in [
&mut candidate.global_ssh_key,
&mut candidate.default_routeros_username,
&mut candidate.default_routeros_password,
&mut candidate.default_switchos_username,
&mut candidate.default_switchos_password,
&mut candidate.pushover_token,
+4 -4
View File
@@ -1,4 +1,3 @@
//! SMTP and Pushover providers. Notification failures never turn a completed backup into a failed one.
use crate::{
AppState,
models::{RouterRecord, Settings},
@@ -192,10 +191,10 @@ pub async fn test_pushover(state: &AppState) -> Result<()> {
.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 {
let mut secrets: Vec<&str> = [
settings.global_ssh_key.as_deref(),
settings.default_routeros_password.as_deref(),
settings.default_switchos_password.as_deref(),
settings.smtp_password.as_deref(),
settings.pushover_token.as_deref(),
@@ -238,13 +237,14 @@ mod tests {
fn error_redaction_covers_global_secret_fields() {
let s = Settings {
global_ssh_key: Some("private-key".into()),
default_routeros_password: Some("router-secret".into()),
smtp_password: Some("smtp-secret".into()),
pushover_token: Some("push-token".into()),
..Settings::default()
};
assert_eq!(
redact_error("private-key smtp-secret push-token", None, &s),
"[redacted] [redacted] [redacted]"
redact_error("private-key router-secret smtp-secret push-token", None, &s),
"[redacted] [redacted] [redacted] [redacted]"
);
}
#[tokio::test]
+63 -50
View File
@@ -1,4 +1,3 @@
//! Bounded RouterOS SSH/SCP and SwitchOS HTTP adapters. No commands run through a local shell.
use crate::{
AppState,
config::Config,
@@ -237,7 +236,6 @@ fn connect(
.context("SSH adapter requires a Tokio runtime")?
.spawn(async move {
tokio::time::sleep(timeout).await;
// Closing the shared socket interrupts a blocked libssh2 call, including slow streams.
let _ = peer.shutdown(Shutdown::Both);
});
let mut session = SshConnection { session, deadline };
@@ -254,16 +252,14 @@ fn connect(
.global_ssh_key
.as_deref()
.filter(|v| !v.trim().is_empty()));
let (username, password) = router.routeros_credentials(settings);
if let Some(key) = key {
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")?;
} else {
session
.userauth_password(
&router.ssh_user,
router.ssh_password.as_deref().unwrap_or(""),
)
.userauth_password(username, password)
.context("SSH password authentication failed")?;
}
ensure!(session.authenticated(), "SSH authentication failed");
@@ -381,7 +377,6 @@ pub fn binary_sync(
scp_download(&session, &remote, destination)
.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(
&session,
&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) {
ensure!(actual == length, "Incomplete SFTP upload");
}
// ssh2 File::flush is a no-op. CLOSE carries the server's final acknowledgement.
destination
.close()
.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])?;
let mut record = scp_record(&mut channel)?;
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..])?
.split_whitespace()
.collect::<Vec<_>>();
@@ -605,8 +598,7 @@ fn swos_url(router: &RouterRecord) -> Result<Url> {
&& url.password().is_none(),
"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
.split_once("://")
.map(|(_, rest)| rest)
@@ -691,7 +683,8 @@ fn digest_authorization(
let qop = fields.get("qop");
if let Some(qop) = qop {
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"
);
}
@@ -700,8 +693,7 @@ fn digest_authorization(
} else {
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!(
"Digest username=\"{}\", realm=\"{}\", nonce=\"{}\", uri=\"{}\", response=\"{}\"",
quote_auth(user),
@@ -732,12 +724,8 @@ fn swos_client(config: &Config) -> Result<Client> {
.timeout(Duration::from_secs(config.ssh_timeout_seconds))
.connect_timeout(Duration::from_secs(config.ssh_timeout_seconds))
.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_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())
.build()?)
}
@@ -773,11 +761,12 @@ fn swos_digest_challenge(response: &Response) -> Option<String> {
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
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<()> {
let mut total = 0usize;
while let Some(chunk) = response
@@ -832,15 +821,36 @@ impl<'a> SwosHttp<'a> {
&& name.len() <= 128
&& name.bytes().all(|byte| {
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 {
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();
} 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::Basic => request = request.basic_auth(self.user, Some(self.password)),
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 authorization =
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> {
// 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;
for attempt in 0..5 {
let response = self.send(path, self.auth.clone()).await?;
@@ -961,9 +966,6 @@ impl<'a> SwosHttp<'a> {
}
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.cookies.clear();
@@ -974,8 +976,7 @@ impl<'a> SwosHttp<'a> {
Ok(backup) => return Ok(backup),
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 {
let challenge_response = self.send("sys.b", SwosAuth::None).await?;
let digest = swos_digest_challenge(&challenge_response);
@@ -996,7 +997,9 @@ impl<'a> SwosHttp<'a> {
let mode = self.auth.mode();
Err(last_error
.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(),
"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 little_endian = bytes[0] == 0xff;
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>")?
.captures(trimmed)
.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())
.unwrap_or_else(|| "none".to_owned());
bail!(
@@ -1153,9 +1159,6 @@ async fn probe_swos(config: &Config, router: &RouterRecord, settings: &Settings)
let mut http_status = 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 {
Ok(mut response) => {
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();
packet[2..4].copy_from_slice(&checksum);
}
// Linux ping sockets fill the identifier and the ICMPv6 pseudo-header checksum.
let start = Instant::now();
socket.send(&packet).await?;
tokio::time::timeout(Duration::from_secs(1), async {
@@ -2118,7 +2120,11 @@ mod tests {
)
.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(
Router::new()
@@ -2129,7 +2135,9 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
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["backup_available"], true);
assert_eq!(probe["auth_mode"], "digest");
@@ -2147,8 +2155,7 @@ mod tests {
};
async fn handle(headers: HeaderMap, uri: Uri) -> axum::response::Response {
if uri.path() == "/backup.swb" {
return (StatusCode::FOUND, [("location", "/download.swb")], "")
.into_response();
return (StatusCode::FOUND, [("location", "/download.swb")], "").into_response();
}
let challenge =
"Digest realm=\"swos\", nonce=\"redirect-nonce\", qop=\"auth\", algorithm=MD5";
@@ -2157,7 +2164,9 @@ mod tests {
.and_then(|value| value.to_str().ok())
.unwrap_or("");
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| {
let host = headers.get("host").unwrap().to_str().unwrap();
let url = Url::parse(&format!("http://{host}/download.swb")).unwrap();
@@ -2172,7 +2181,11 @@ mod tests {
)
.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(
Router::new()
-3
View File
@@ -1,4 +1,3 @@
//! In-process scheduler, preserving APScheduler's Monday=0 weekday convention.
use crate::{AppState, backup, models::Settings, routeros};
use anyhow::{Context, Result, bail, ensure};
use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};
@@ -143,7 +142,6 @@ impl Cron {
.succ_opt()
.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()
&& let Some(midnight) = next_date.and_hms_opt(0, 0, 0)
&& 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() {}
})
}
+5 -3
View File
@@ -1,10 +1,10 @@
-- Current schema for a new installation. Never applied to an existing database.
-- Current schema for a new installation. Existing supported Rust schemas are migrated at startup.
CREATE TABLE schema_info (
id INTEGER PRIMARY KEY CHECK (id = 1),
application TEXT 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 (
id INTEGER PRIMARY KEY,
@@ -77,7 +77,9 @@ CREATE TABLE global_settings (
smtp_login TEXT,
smtp_password TEXT,
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_backups_router_id ON backups(router_id);
+2 -9
View File
@@ -1,4 +1,3 @@
//! Password migration and the original HS256 JWT contract.
use anyhow::{Context, Result, bail};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
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);
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;
let result = jsonwebtoken::decode::<Claims>(
token,
@@ -242,8 +239,7 @@ mod tests {
#[test]
fn signed_malformed_or_missing_expiration_is_rejected() {
let (_dir, config) = config();
// These are valid HMAC signatures: rejection must come from claim
// validation rather than an accidentally invalid signature.
for expiration in [
serde_json::json!("4102444800"),
serde_json::json!("never"),
@@ -297,10 +293,7 @@ mod tests {
fn verifies_independent_python_hmac_jwt_fixture() {
let (_dir, mut config) = config();
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";
assert_eq!(verify_token(&config, token).unwrap(), "operator");
let tampered = token.replace("s0s1opp9", "t0s1opp9");
+1 -1
View File
@@ -897,7 +897,7 @@ async fn logs_retention_and_embedded_web_do_not_expose_storage() {
}
assert_eq!(
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")]);
assert_eq!(
+15 -4
View File
@@ -560,7 +560,7 @@ pre {
line-height: 1.3;
white-space: nowrap;
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) {
@@ -577,8 +577,9 @@ pre {
}
.btn.primary:hover:not(:disabled) {
background: #499d91;
border-color: #73bcb1
background: linear-gradient(135deg, #3e9186, #32776e);
border-color: #73bcb1;
filter: brightness(1.08)
}
.btn.danger {
@@ -1688,7 +1689,7 @@ select {
.diff-controls {
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;
align-items: end;
padding: 16px;
@@ -1698,6 +1699,10 @@ select {
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 {
height: 35px;
font-size: 11px
@@ -2067,6 +2072,12 @@ html[data-page="auth"] body {
box-shadow: none
}
.auth-layout .btn.primary:hover:not(:disabled) {
background: var(--blue);
border-color: var(--blue);
filter: brightness(1.08)
}
.auth-center {
display: grid;
grid-template-columns: 1fr 410px;
+37 -14
View File
@@ -7,7 +7,7 @@
const navItems = [['/', 'nav.dashboard', 'home'], ['/devices', 'nav.routers', 'device'], ['/files', 'ui.backups', 'files'], ['/diff-configs', 'nav.diffConfigs', 'diff'], ['/logs', 'nav.logs', 'clock'], ['/settings', 'nav.settings', 'settings']];
M.shell = () => {
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) => {
if (!path.startsWith('/') || path.startsWith('//')) return;
@@ -21,7 +21,10 @@
const params = new URLSearchParams(location.search);
if (path === '/diff-configs') {
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 || '',
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 || '',
right: params.get('right') || M.state.diffSelection?.right || ''
};
@@ -41,7 +44,7 @@
const revision = ++M.state.routeId;
M.applyPreferences();
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; }
M.syncUrlState(path);
M.$('#app').innerHTML = M.shell(); M.updateHealth();
@@ -98,17 +101,26 @@
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); }
};
M.redrawDiffControls = () => {
const controls = M.$('#diff-controls');
if (controls) controls.innerHTML = M.diffControls();
};
M.compare = async (left, right) => {
left = Number(left); right = Number(right);
if (!left || !right || left === right) throw new Error(t('ui.selectPair'));
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 data = await M.api(`/backups/${left}/diff/${right}?compact=true`);
if (revision !== M.state.routeId) return;
M.state.diff = data; M.state.diffPage = 1; M.state.diffSelection = { ...(M.state.diffSelection || {}), left, right };
const query = new URLSearchParams({ left: String(left), right: String(right) });
if (M.state.diffSelection.router_id) query.set('router_id', M.state.diffSelection.router_id);
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), mode: M.state.diffSelection.mode === 'cross' ? 'cross' : 'same' });
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}`);
M.redrawDiff();
};
@@ -264,23 +276,26 @@
const latest = files[0], older = latest?.id === selected.id ? files[1] : selected;
if (!latest || !older) throw new Error(t('ui.noExportPair'));
M.state.diff = null;
M.state.diffSelection = { left: older.id, right: latest.id, router_id: selected.router_id };
await M.navigate(`/diff-configs?${new URLSearchParams(M.state.diffSelection)}`);
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({ 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}`),
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')); },
'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-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(); },
'page-prev': () => { M.state.page = Math.max(1, 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-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; },
'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; },
'diff-toggle-all': () => { M.state.diffShowAll = !M.state.diffShowAll; M.state.diffPage = 1; M.redrawDiff(); M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = 0; }); },
'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-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')); },
@@ -323,7 +338,9 @@
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 === '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.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]'));
@@ -388,6 +405,12 @@
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 === '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 === '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; }
@@ -395,9 +418,9 @@
if (form.id === 'settings-form') {
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 ['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 ['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[`clear_${key}`]) payload[`clear_${key}`] = true;
}
+5 -4
View File
@@ -9,7 +9,7 @@
theme: read(localStorage, 'routeros_theme') === 'light' ? 'light' : 'dark',
collapsed: read(localStorage, 'mbs_sidebar') === 'collapsed', mobile: false,
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' }
};
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.errorMessage = error => error && error.message ? error.message : M.t('ui.operationError');
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 timer = setTimeout(() => controller.abort(), timeout);
const forwardAbort = () => controller.abort();
if (signal) signal.addEventListener('abort', forwardAbort, { once: true });
const headers = { Accept: blob ? '*/*' : text ? 'text/html' : 'application/json' };
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;
try {
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'
});
if (!response.ok) {
+1 -2
View File
@@ -1,3 +1,2 @@
/* Source language dictionaries preserved from the original application. */
window.MBS = window.MBS || {};
window.MBS.translations = {"pl":{"app":{"menu":"Menu"},"sidebar":{"title":"Mikrotik Backup System","subtitle":"RouterOS/SwitchOS"},"topbar":{"caption":"mikrotik / control center","role":"administrator","languageSelector":"Wybór języka"},"common":{"apply":"Zastosuj","reset":"Resetuj","delete":"Usuń","confirm":"Potwierdź","cancel":"Anuluj","download":"Pobierz","email":"Wyślij e-mail","preview":"Podgląd","restore":"Przywróć","actions":"Akcje","open":"Otwórz","edit":"Edytuj","diff":"Diff","ok":"OK","idle":"Brak","asc":"Rosnąco","desc":"Malejąco","enabled":"Włączone","disabled":"Wyłączone","failed":"Błąd","save":"Zapisz"},"nav":{"dashboard":"Dashboard","routers":"Urządzenia","files":"Repozytorium","settings":"Ustawienia","logs":"Logi","logout":"Wyloguj","theme":"Motyw","changePassword":"Zmień hasło","diffConfigs":"Diff konfiguracji","switchosBeta":"SwitchOS beta"},"auth":{"username":"Użytkownik","password":"Hasło","login":"Zaloguj","register":"Rejestracja","confirmPassword":"Potwierdź hasło","changePassword":"Zmień hasło","currentPassword":"Obecne hasło","newPassword":"Nowe hasło","backToLogin":"Powrót do logowania","loginSubtitle":"Zaloguj się, aby kontynuować.","loginFailed":"Logowanie nie powiodło się","accountCreated":"Konto zostało utworzone","registrationFailed":"Rejestracja nie powiodła się","passwordsMismatch":"Hasła nie są takie same","changePasswordFailed":"Zmiana hasła nie powiodła się","securityEyebrow":"konto / bezpieczeństwo","changePasswordSubtitle":"Zaktualizuj hasło administratora bez zbędnych ustawień dodatkowych.","changePasswordCardSubtitle":"Podaj obecne hasło i ustaw nowe dane logowania.","passwordPanelSubtitle":"Szybki podgląd siły hasła i zgodności pól przed zapisem.","passwordStrength":"Siła hasła","passwordWeak":"Słabe","passwordMedium":"Średnie","passwordStrong":"Mocne","ruleLength":"Minimum 8 znaków","ruleDigit":"Przynajmniej jedna cyfra","ruleMatch":"Oba pola są zgodne","passwordsMatchHint":"Nowe hasło i potwierdzenie są zgodne."},"dashboard":{"title":"Dashboard","eyebrow":"strona główna / dashboard","subtitle":"Przegląd backupów, eksportów i aktywności systemu.","exportAll":"Exportuj wszystko","binaryAll":"Backup binarny","managedRouters":"Urządzenia","managedRoutersHint":"Wszystkie zarządzane urządzenia","inventoryTag":"Flota","exportsCard":"Exporty","exportsHint":"Czytelne snapshoty konfiguracji","textTag":"Tekst","binaryCard":"Backupy binarne","binaryHint":"Punkty odtworzenia","binaryTag":"Binary","allFilesCard":"Wszystkie pliki","allFilesHint":"Artefakty w repozytorium","archiveTag":"Archiwum","storageTitle":"Wykorzystanie przestrzeni","storageSubtitle":"Bieżący podgląd zajętości repozytorium i wolnego miejsca.","folderUsage":"Zajętość katalogu","diskUsage":"Użycie dysku","totalDisk":"Cały dysk","freeSpace":"Wolne miejsce","activityTitle":"Ostatnia aktywność","activitySubtitle":"Najnowsze zdarzenia operacyjne z backendu.","noActivity":"Brak ostatnich zdarzeń do wyświetlenia.","avgBackupsPerRouter":"Śr. backupów / urządzenie","activitySuccess":"Zadanie zakończone","activityFailure":"Wymaga uwagi","activityMaintenance":"Utrzymanie","activityDelivery":"Dystrybucja","operationsTitle":"Centrum operacji","operationsSubtitle":"Główne akcje i szybkie wskaźniki pracy repozytorium.","latestSnapshot":"Najnowszy snapshot","coverageLabel":"Pokrycie floty","coverageHint":"Urządzenia z co najmniej jednym backupem","weeklyActivityLabel":"Aktywność 7 dni","weeklyActivityHint":"Nowe backupy z ostatniego tygodnia","busiestRouterLabel":"Najaktywniejsze urządzenie","routerSnapshotsHint":"{{count}} snapshotów w repozytorium","exportShareLabel":"Udział exportów","activityTodayLabel":"Zdarzenia dzisiaj","noneLabel":"Brak","activityTodayHint":"Wpisy z bieżącego dnia","usedSpace":"Zajęte miejsce","storageViewCapacity":"Pojemność","storageViewCapacityHint":"Widok dysku, zajętości repozytorium i wolnego miejsca w jednej skali.","storageViewMix":"Typy backupów","storageViewMixHint":"Podział wszystkich kopii na exporty tekstowe i backupy binarne.","storageViewActivity":"Aktywność 7 dni","storageViewActivityHint":"Liczba nowych backupów z ostatnich siedmiu dni.","storageViewRouters":"Top urządzenia","storageViewRoutersHint":"Urządzenia z największą liczbą snapshotów w repozytorium.","storageChartEmpty":"Brak danych do narysowania wykresu.","storageSnapshotTitle":"Metryki repozytorium","storageSnapshotHint":"Szybki podgląd najważniejszych wskaźników przestrzeni i backupów."},"routers":{"title":"Urządzenia","detailTitle":"Szczegóły urządzenia","add":"Dodaj urządzenie","eyebrow":"inwentaryzacja urządzeń","subtitle":"Zarządzaj urządzeniami RouterOS i SwitchOS oraz ich kopiami.","registeredDevices":"Zarejestrowane urządzenia","fleetTag":"Flota","sshPassword":"Hasło","passwordHint":"Dostęp hasłem","credsTag":"Dostęp","sshKey":"Klucz SSH","keyHint":"Dostęp kluczem","securityTag":"BezLine truncated
window.MBS.translations = { "pl": { "app": { "menu": "Menu" }, "sidebar": { "title": "Mikrotik Backup System", "subtitle": "RouterOS/SwitchOS" }, "topbar": { "caption": "mikrotik / control center", "role": "administrator", "languageSelector": "Wybór języka" }, "common": { "apply": "Zastosuj", "reset": "Resetuj", "delete": "Usuń", "confirm": "Potwierdź", "cancel": "Anuluj", "download": "Pobierz", "email": "Wyślij e-mail", "preview": "Podgląd", "restore": "Przywróć", "actions": "Akcje", "open": "Otwórz", "edit": "Edytuj", "diff": "Diff", "ok": "OK", "idle": "Brak", "asc": "Rosnąco", "desc": "Malejąco", "enabled": "Włączone", "disabled": "Wyłączone", "failed": "Błąd", "save": "Zapisz" }, "nav": { "dashboard": "Dashboard", "routers": "Urządzenia", "files": "Repozytorium", "settings": "Ustawienia", "logs": "Logi", "logout": "Wyloguj", "theme": "Motyw", "changePassword": "Zmień hasło", "diffConfigs": "Diff konfiguracji", "switchosBeta": "SwitchOS beta" }, "auth": { "username": "Użytkownik", "password": "Hasło", "login": "Zaloguj", "register": "Rejestracja", "confirmPassword": "Potwierdź hasło", "changePassword": "Zmień hasło", "currentPassword": "Obecne hasło", "newPassword": "Nowe hasło", "backToLogin": "Powrót do logowania", "loginSubtitle": "Zaloguj się, aby kontynuować.", "loginFailed": "Logowanie nie powiodło się", "accountCreated": "Konto zostało utworzone", "registrationFailed": "Rejestracja nie powiodła się", "passwordsMismatch": "Hasła nie są takie same", "changePasswordFailed": "Zmiana hasła nie powiodła się", "securityEyebrow": "konto / bezpieczeństwo", "changePasswordSubtitle": "Zaktualizuj hasło administratora bez zbędnych ustawień dodatkowych.", "changePasswordCardSubtitle": "Podaj obecne hasło i ustaw nowe dane logowania.", "passwordPanelSubtitle": "Szybki podgląd siły hasła i zgodności pól przed zapisem.", "passwordStrength": "Siła hasła", "passwordWeak": "Słabe", "passwordMedium": "Średnie", "passwordStrong": "Mocne", "ruleLength": "Minimum 8 znaków", "ruleDigit": "Przynajmniej jedna cyfra", "ruleMatch": "Oba pola są zgodne", "passwordsMatchHint": "Nowe hasło i potwierdzenie są zgodne." }, "dashboard": { "title": "Dashboard", "eyebrow": "strona główna / dashboard", "subtitle": "Przegląd backupów, eksportów i aktywności systemu.", "exportAll": "Exportuj wszystko", "binaryAll": "Backup binarny", "managedRouters": "Urządzenia", "managedRoutersHint": "Wszystkie zarządzane urządzenia", "inventoryTag": "Flota", "exportsCard": "Exporty", "exportsHint": "Czytelne snapshoty konfiguracji", "textTag": "Tekst", "binaryCard": "Backupy binarne", "binaryHint": "Punkty odtworzenia", "binaryTag": "Binary", "allFilesCard": "Wszystkie pliki", "allFilesHint": "Artefakty w repozytorium", "archiveTag": "Archiwum", "storageTitle": "Wykorzystanie przestrzeni", "storageSubtitle": "Bieżący podgląd zajętości repozytorium i wolnego miejsca.", "folderUsage": "Zajętość katalogu", "diskUsage": "Użycie dysku", "totalDisk": "Cały dysk", "freeSpace": "Wolne miejsce", "activityTitle": "Ostatnia aktywność", "activitySubtitle": "Najnowsze zdarzenia operacyjne z backendu.", "noActivity": "Brak ostatnich zdarzeń do wyświetlenia.", "avgBackupsPerRouter": "Śr. backupów / urządzenie", "activitySuccess": "Zadanie zakończone", "activityFailure": "Wymaga uwagi", "activityMaintenance": "Utrzymanie", "activityDelivery": "Dystrybucja", "operationsTitle": "Centrum operacji", "operationsSubtitle": "Główne akcje i szybkie wskaźniki pracy repozytorium.", "latestSnapshot": "Najnowszy snapshot", "coverageLabel": "Pokrycie floty", "coverageHint": "Urządzenia z co najmniej jednym backupem", "weeklyActivityLabel": "Aktywność 7 dni", "weeklyActivityHint": "Nowe backupy z ostatniego tygodnia", "busiestRouterLabel": "Najaktywniejsze urządzenie", "routerSnapshotsHint": "{{count}} snapshotów w repozytorium", "exportShareLabel": "Udział exportów", "activityTodayLabel": "Zdarzenia dzisiaj", "noneLabel": "Brak", "activityTodayHint": "Wpisy z bieżącego dnia", "usedSpace": "Zajęte miejsce", "storageViewCapacity": "Pojemność", "storageViewCapacityHint": "Widok dysku, zajętości repozytorium i wolnego miejsca w jednej skali.", "storageViewMix": "Typy backupów", "storageViewMixHint": "Podział wszystkich kopii na exporty tekstowe i backupy binarne.", "storageViewActivity": "Aktywność 7 dni", "storageViewActivityHint": "Liczba nowych backupów z ostatnich siedmiu dni.", "storageViewRouters": "Top urządzenia", "storageViewRoutersHint": "Urządzenia z największą liczbą snapshotów w repozytorium.", "storageChartEmpty": "Brak danych do narysowania wykresu.", "storageSnapshotTitle": "Metryki repozytorium", "storageSnapshotHint": "Szybki podgląd najważniejszych wskaźników przestrzeni i backupów." }, "routers": { "title": "Urządzenia", "detailTitle": "Szczegóły urządzenia", "add": "Dodaj urządzenie", "eyebrow": "inwentaryzacja urządzeń", "subtitle": "ZLine truncated
+43 -22
View File
@@ -1,4 +1,3 @@
/* Views contain presentation only; mutations live in app.js. */
(function (M) {
'use strict';
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>`;
};
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 legacy = editing && switchos && r.use_global_switchos_credentials == null;
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"' })}
<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' : ''}>
${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>
<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 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]]);
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 => {
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)); });
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 () => {
const revision = M.state.routeId;
const f = M.state.filters;
@@ -171,8 +171,8 @@
const pageSize = Math.max(80, Number(M.state.diffPageSize) || 200);
const pages = Math.max(1, Math.ceil(allRows.length / pageSize));
M.state.diffPage = Math.min(pages, Math.max(1, Number(M.state.diffPage) || 1));
const start = (M.state.diffPage - 1) * pageSize;
const rows = allRows.slice(start, start + pageSize);
const start = M.state.diffShowAll ? 0 : (M.state.diffPage - 1) * 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 text = row[`${side}_text`] || '', n = row[`${side}_number`];
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]); }
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>`;
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>`;
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 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>`;
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 = () => {
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}`])];
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 mode = s.mode === 'cross' ? 'cross' : 'same';
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 () => {
const revision = M.state.routeId;
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?${query}`), M.api('/routers')]);
let [files, routers] = await Promise.all([M.api('/backups?backup_type=export&sort_by=created_at&order=desc'), M.api('/routers')]);
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;
const selection = M.state.diffSelection || s;
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;
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 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 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 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>`;
+44 -2
View File
@@ -50,11 +50,13 @@ test('settings and device forms never prefill secret values returned by a legacy
const app = application();
app.state.settingsTab = 'credentials';
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 });
assert.ok(!settings.includes(secret));
assert.ok(!device.includes(secret));
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'));
});
@@ -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"'));
app.state.cache.routers = [routeros, switchos];
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: 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();
assert.ok(diff.includes('allowed.rsc'));
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(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"'));
});