first commit
This commit is contained in:
commit
32a7ba8e8f
168 files changed
+123856
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
target
|
||||||
|
storage
|
||||||
|
bin
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
tests
|
||||||
|
docs
|
||||||
|
*.zip
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Canonical configuration. Rust reads .env without executing its contents.
|
||||||
|
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
|
||||||
|
API_PREFIX=/api
|
||||||
|
TIMEZONE=Europe/Warsaw
|
||||||
|
DATA_DIR=./storage
|
||||||
|
# DATABASE_PATH=./storage/routeros_backup_next.db
|
||||||
|
# Legacy alias also supported: DATABASE_URL=sqlite:///./storage/routeros_backup_next.db
|
||||||
|
|
||||||
|
# Empty SECRET_KEY generates a persistent random key in DATA_DIR/secrets/jwt.key.
|
||||||
|
# Set the old strong (32+ characters) key to keep existing JWTs valid during migration.
|
||||||
|
SECRET_KEY=
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||||
|
ALLOW_REGISTRATION=false
|
||||||
|
DEFAULT_ADMIN_USERNAME=admin
|
||||||
|
# Empty: random first-start password in DATA_DIR/secrets/initial-admin-password.
|
||||||
|
# Applied only if the database has no users.
|
||||||
|
DEFAULT_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
SSH_TIMEOUT_SECONDS=30
|
||||||
|
# strict: require DATA_DIR/ssh_known_hosts. accept-new: pin first observed key;
|
||||||
|
# changed keys are always rejected. Verify fingerprints outside this application.
|
||||||
|
SSH_HOST_KEY_POLICY=strict
|
||||||
|
MAX_PARALLEL_BACKUPS=4
|
||||||
|
SMTP_STARTTLS=true
|
||||||
|
SMTP_TIMEOUT_SECONDS=20
|
||||||
|
CORS_ORIGINS=[]
|
||||||
|
RUST_LOG=mikrotik_backup=info,tower_http=warn
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/target/
|
||||||
|
/storage/
|
||||||
|
/bin/
|
||||||
|
.env
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
node_modules/
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.0.1 — Initial release
|
||||||
|
|
||||||
|
Initial Rust rewrite of the original Python/TypeScript MikroTik Backup System.
|
||||||
|
|
||||||
|
- Reimplemented the API, scheduler, SQLite data model, RouterOS SSH/SFTP/SCP workflows, SwOS web backup download, retention, notifications, and embedded web interface in Rust.
|
||||||
|
- Added an explicit importer for the previous Python/TypeScript installation, including users, devices, settings, logs, backup records, and physical backup files with checksum verification.
|
||||||
|
- Added RouterOS `.rsc` comparison with split/unified views and semantic change analysis grouped by configuration section.
|
||||||
|
- Added RouterOS and SwOS device handling, connection tests, backup policies, credential selection, and SwOS Digest/Basic authentication fallback.
|
||||||
|
- Added responsive dark/light themes, account preferences, automation settings, API documentation, and a compact dashboard.
|
||||||
|
- Added local Swagger UI/OpenAPI documentation without a CDN.
|
||||||
|
- Embedded frontend assets into the binary. CSS and JavaScript filenames are content-fingerprinted at build time and served with immutable long-term cache headers.
|
||||||
|
- Added hardened defaults for authentication, request limits, SSH host-key handling, secret redaction, storage permissions, and container execution.
|
||||||
|
- Added a multi-stage Alpine Linux container build and non-root runtime image.
|
||||||
Generated
+2639
File diff suppressed because it is too large.
Load diff
+54
@@ -0,0 +1,54 @@
|
|||||||
|
[package]
|
||||||
|
name = "mikrotik-backup"
|
||||||
|
version = "0.0.1"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.99"
|
||||||
|
description = "Self-hosted MikroTik backup system: one Rust service with an embedded web interface"
|
||||||
|
publish = false
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
argon2 = "0.5"
|
||||||
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
chrono-tz = "0.10"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
fs2 = "0.4"
|
||||||
|
http-body-util = "0.1"
|
||||||
|
jsonwebtoken = { version = "11", default-features = false, features = ["aws_lc_rs"] }
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] }
|
||||||
|
libc = "0.2"
|
||||||
|
md-5 = "0.10"
|
||||||
|
pbkdf2 = "0.12"
|
||||||
|
rand = "0.8"
|
||||||
|
regex = "1"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
|
rusqlite = { version = "0.37", features = ["bundled", "backup"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
serde_urlencoded = "0.7"
|
||||||
|
sha2 = "0.10"
|
||||||
|
socket2 = { version = "0.6", features = ["all"] }
|
||||||
|
ssh2 = "0.9"
|
||||||
|
subtle = "2"
|
||||||
|
tempfile = "3"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io", "rt"] }
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
# Audited upstream libssh2 security fixes; see docs/security.md.
|
||||||
|
libssh2-sys = { path = "vendor/libssh2-sys" }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
FROM rust:1.99.1-alpine3.24 AS builder
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base \
|
||||||
|
pkgconf \
|
||||||
|
openssl-dev \
|
||||||
|
zlib-dev \
|
||||||
|
cmake \
|
||||||
|
clang \
|
||||||
|
perl
|
||||||
|
WORKDIR /build
|
||||||
|
COPY Cargo.toml Cargo.lock rust-toolchain.toml build.rs ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY web ./web
|
||||||
|
COPY vendor ./vendor
|
||||||
|
COPY docs ./docs
|
||||||
|
RUN cargo build --release --locked
|
||||||
|
|
||||||
|
FROM alpine:3.24 AS runtime
|
||||||
|
RUN apk add --no-cache ca-certificates libssl3 libcrypto3 zlib libgcc \
|
||||||
|
&& addgroup -S -g 10001 app \
|
||||||
|
&& adduser -S -D -H -u 10001 -G app -h /data -s /sbin/nologin app \
|
||||||
|
&& install -d -m 0700 -o app -g app /data
|
||||||
|
COPY --from=builder --chmod=0755 /build/target/release/mikrotik-backup /usr/local/bin/mikrotik-backup
|
||||||
|
ENV DATA_DIR=/data LISTEN_ADDR=0.0.0.0:5581 APP_ENV=production
|
||||||
|
USER 10001:10001
|
||||||
|
WORKDIR /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 5581
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD ["/usr/local/bin/mikrotik-backup", "healthcheck"]
|
||||||
|
ENTRYPOINT ["/usr/local/bin/mikrotik-backup"]
|
||||||
|
CMD ["serve"]
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build app
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The default published address is `127.0.0.1:5581`. Persistent application data is stored in the `data` volume.
|
||||||
|
|
||||||
|
The image uses `rust:1.99.1-alpine3.24` for the build stage and `alpine:3.24` for runtime. The service runs as UID/GID `10001` with a read-only root filesystem.
|
||||||
|
|
||||||
|
### Native build
|
||||||
|
|
||||||
|
Requires Rust 1.99.1 or compatible toolchain and the native dependencies needed by the crates in `Cargo.lock`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --locked
|
||||||
|
./target/release/mikrotik-backup serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the launcher:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## First account
|
||||||
|
|
||||||
|
Registration is disabled by default. Set `DEFAULT_ADMIN_PASSWORD` before first startup, or let the application create the initial administrator password file under `DATA_DIR/secrets/initial-admin-password`.
|
||||||
|
|
||||||
|
New installations and new accounts default to **English**. Imported accounts preserve their saved language preference. The UI also supports Polish, Spanish, and Norwegian.
|
||||||
|
|
||||||
|
## Main configuration
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `DATA_DIR` | `./storage` | Database, backups, secrets, SSH known hosts |
|
||||||
|
| `DATABASE_PATH` | `<DATA_DIR>/routeros_backup_next.db` | SQLite database path |
|
||||||
|
| `LISTEN_ADDR` | `127.0.0.1:5581` | HTTP listener |
|
||||||
|
| `API_PREFIX` | `/api` | API base path |
|
||||||
|
| `TIMEZONE` | `Europe/Warsaw` | Scheduler timezone |
|
||||||
|
| `SECRET_KEY` | generated/persisted when absent | JWT signing secret |
|
||||||
|
| `ALLOW_REGISTRATION` | `false` | Allow account registration |
|
||||||
|
| `DEFAULT_ADMIN_USERNAME` | `admin` | Initial administrator name |
|
||||||
|
| `DEFAULT_ADMIN_PASSWORD` | empty | Initial administrator password |
|
||||||
|
| `SSH_HOST_KEY_POLICY` | `accept-new` | `accept-new` or `strict` |
|
||||||
|
| `SSH_TIMEOUT_SECONDS` | `30` | Device SSH timeout |
|
||||||
|
| `MAX_PARALLEL_BACKUPS` | `4` | Concurrent backup limit |
|
||||||
|
| `CORS_ORIGINS` | `[]` | Explicit allowed HTTP(S) origins |
|
||||||
|
|
||||||
|
Run configuration validation without starting the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/mikrotik-backup check-config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
DATA_DIR/
|
||||||
|
├── routeros_backup_next.db
|
||||||
|
├── backups/
|
||||||
|
│ ├── exports/<device-id>/
|
||||||
|
│ └── binary/<device-id>/
|
||||||
|
├── secrets/
|
||||||
|
└── ssh/
|
||||||
|
```
|
||||||
|
|
||||||
|
RouterOS text exports are stored under `backups/exports`; binary RouterOS backups and SwOS `.swb` files are stored under `backups/binary`.
|
||||||
|
|
||||||
|
## Legacy Python/TypeScript import
|
||||||
|
|
||||||
|
The importer is explicit and never runs during normal startup. Stop the old application first and import into a **new** Rust destination.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./import-python.sh /srv/mikrotik-backup-old
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Compose example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm \
|
||||||
|
-v /srv/mikrotik-backup-old:/legacy:ro \
|
||||||
|
app import-python --source /legacy
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
SwOS details and troubleshooting are in [docs/switchos.md](docs/switchos.md).
|
||||||
|
|
||||||
|
## Configuration comparison
|
||||||
|
|
||||||
|
`.rsc` files support:
|
||||||
|
|
||||||
|
- side-by-side and unified diff;
|
||||||
|
- changed-lines-only mode and search;
|
||||||
|
- paged browser rendering to limit DOM size;
|
||||||
|
- semantic RouterOS analysis that groups changes by configuration section and identifies added, removed, and modified commands/fields;
|
||||||
|
- masking of sensitive values in semantic summaries.
|
||||||
|
|
||||||
|
Binary backups are not diffed.
|
||||||
|
|
||||||
|
## Embedded web assets and caching
|
||||||
|
|
||||||
|
`build.rs` combines the UI sources listed in `web/assets.list`. Each compiled CSS/JavaScript bundle receives a content hash in its filename, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/assets/app.<hash>.js
|
||||||
|
/assets/app.<hash>.css
|
||||||
|
/assets/docs.<hash>.js
|
||||||
|
/assets/docs.<hash>.css
|
||||||
|
```
|
||||||
|
|
||||||
|
The HTML shell is generated with those exact filenames. Hashed assets use:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML and API responses remain non-cacheable by default. `/favicon.ico` intentionally returns `204 No Content`.
|
||||||
|
|
||||||
|
## API documentation
|
||||||
|
|
||||||
|
Interactive local documentation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenAPI JSON:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/openapi.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Swagger UI is embedded locally and does not require a CDN.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Frontend unit tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node web/tests/core.test.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
JavaScript syntax:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --check web/js/i18n.js
|
||||||
|
node --check web/js/core.js
|
||||||
|
node --check web/js/views.js
|
||||||
|
node --check web/js/app.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust checks when a toolchain is available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --check
|
||||||
|
cargo test --locked
|
||||||
|
cargo clippy --locked --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser smoke tests under `web/tests/` require Playwright.
|
||||||
|
|
||||||
|
## Security and dependencies
|
||||||
|
|
||||||
|
The service uses request size/time limits, JWT validation, password hashing, SSH host-key verification, secret redaction, restricted filesystem permissions, and a non-root container runtime. Dependency audit notes and the local `libssh2-sys` patch provenance are summarized in [docs/security.md](docs/security.md).
|
||||||
|
|
||||||
|
Third-party license notices are in `docs/THIRD_PARTY_NOTICES.txt`.
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
638924b15afb3b6f88b83713e356162659987378c6731a2ea3403b90e31ae0eb rust-toolchain.toml
|
||||||
|
2883973832dd23f34889b1788ca7ddcd57e2410cf28245b8da55dacd45960d43 src/api/auth.rs
|
||||||
|
079473319684d445eb7b9b8b01df7207fd96eed5a98287fa541376692e6d1204 src/api/backups.rs
|
||||||
|
eda20155c645788ebc238a4553edaa90b7e5314782e82337125f41ba50b90234 src/api/devices.rs
|
||||||
|
9827188b7a89573a1b108a3d0cb58e2f720d4b62549cf062b44be767f8aaafa5 src/api/documentation.rs
|
||||||
|
8f4f8ee55780e90d20c85920a471e4f72b3f17b92cb51469e4634176d6daba3b src/api/mod.rs
|
||||||
|
81ae453a28edaebf554e11324d088cfe95dbdc6625e388e7f1f5398d94d72e7b src/api/system.rs
|
||||||
|
7b6959c99e9eca52e1f0aa862c32bb7ca0f988b6b4d7b1c0d1eb8bbd448a5f25 src/backup.rs
|
||||||
|
1eee0ed74a61882247332dea93372cba54f310211ed05095712054b0432d507a src/config.rs
|
||||||
|
bd5d86db6192528ad4c7d79f422650317dd37bfd1e0b8ad623244b4ba93ef545 src/db.rs
|
||||||
|
30e35f94bf7d876ce0f8fc696f47e6e7680da7fe74e43d47408a0140dac6e5e1 src/diff.rs
|
||||||
|
18d23f46601f69dcf5ce817709e3b7e9ae08aa282bd29b2900946a81bdf78342 src/lib.rs
|
||||||
|
92c3b8afe80d5d36e21e5fb8da6d718a69cab677ae935528105b0dd026b13869 src/main.rs
|
||||||
|
89aad2d2795bfbfb18178818aa9908a01d010167d8647f7563aa85c0397e9622 src/models.rs
|
||||||
|
e570ce7cad4fe4f325419e6cae0d15ff6ecb4b27f79ef3e9c77508a4cdd279d9 src/notifications.rs
|
||||||
|
2db2f31fbf2c809c6a82cdf8a69dd0012570298b71bd2d2e5d7844ff39903a7c src/routeros.rs
|
||||||
|
733a77ddf1dbd6b64e6ec425ee4e2b6f842a0902c61c274f0a1171d69ea5a3ce src/scheduler.rs
|
||||||
|
6a002266e727e17a23260e25c151191889cee8a793e4412f57cd6ef35cc137e9 src/schema.sql
|
||||||
|
77acdb3df08469f6834b952e2f75f0ec5198073f781630e16cd9b327f7757258 src/security.rs
|
||||||
|
ce3b2dd7e7005a80571494c954f18d6042ee88d055deab346b191ca9374daaf7 start.sh
|
||||||
|
b7e80c541653c41e0ebb1ab7c65e35ace5f58adc40f628292a6d18d10ba58dd9 tests/api_contract.rs
|
||||||
|
dd4d70d7de7209d1113eebaa6799c39535a88ac2cc783445350283fb1c9c5f2e tests/fixtures/python-diff.json
|
||||||
|
bae48d41ce4561196c4428bcb274502e4d8e04fee28b9684c2c579ad82749b18 tests/import_launcher.rs
|
||||||
|
3cd3d0763f3b46b752720257b183669c602d39f1b07e35ddf07676cfbb5d0eae tests/openapi_contract.rs
|
||||||
|
67553c86d569577e9ab8548c8d819ef71760d73d34374f23c0c0cd21c8c1f8ee vendor/libssh2-sys/Cargo.toml
|
||||||
|
a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2 vendor/libssh2-sys/LICENSE-APACHE
|
||||||
|
378f5840b258e2779c39418f3f2d7b2ba96f1c7917dd6be0713f88305dbda397 vendor/libssh2-sys/LICENSE-MIT
|
||||||
|
717792180fe14e4ba853aba4a7f70332309ce1343b50b8231ebaea1ac94fd8aa vendor/libssh2-sys/PROVENANCE.md
|
||||||
|
f4db47339ee857174c97128c78bd9b9abc95220b2eb4cc64e9dc4d9e38dae341 vendor/libssh2-sys/build.rs
|
||||||
|
d7b081dc4c07012e5e125352e2f2b939cce93a7a70ae657c77655aa93a9f05ec vendor/libssh2-sys/lib.rs
|
||||||
|
f7f9633cf9ff2f1333f3d7ce46973a8716a4d2a2815ad56f30d437d5fea7bafe vendor/libssh2-sys/libssh2/COPYING
|
||||||
|
3aedabc2618dd11c18747e1ccc680b2841b4aa414de661ae9dc1d4602c0b93f0 vendor/libssh2-sys/libssh2/LICENSES/BSD-2-Clause.txt
|
||||||
|
3cf06aba3588c41c514f6946bb2d757b413ff6491647d474800f55edca75dcb4 vendor/libssh2-sys/libssh2/LICENSES/BSD-3-Clause.txt
|
||||||
|
ffff0ec9c19fc90efbad30c66b7e335ab1f824a7ef806d943b45d28b67c75f9a vendor/libssh2-sys/libssh2/LICENSES/FSFULLR.txt
|
||||||
|
002c2696d92b5c8cf956c11072baa58eaf9f6ade995c031ea635c6a1ee342ad1 vendor/libssh2-sys/libssh2/LICENSES/MIT.txt
|
||||||
|
49f2fc047b5f5cc66be8a70dd65825a37386f9bb8d08d6ed939351a3a7750b90 vendor/libssh2-sys/libssh2/LICENSES/SAX-PD-2.0.txt
|
||||||
|
d3483fbbf511197d9b8402e48bf4bb5dc8c082abef0ae957334a2c3e8f32e976 vendor/libssh2-sys/libssh2/LICENSES/curl.txt
|
||||||
|
767d54ff5ae2cd0dab48895ccbcb8ee045a17f826f77a24c06461634fef62778 vendor/libssh2-sys/libssh2/include/libssh2.h
|
||||||
|
f1cd086f3950e65635827ee3332c1c6ca62887c0f84369ec58e31974debb36e9 vendor/libssh2-sys/libssh2/include/libssh2_publickey.h
|
||||||
|
b5d864f19af69521278ed953b20b76a32cfdc08014da81d38f59964e7e2e2575 vendor/libssh2-sys/libssh2/include/libssh2_sftp.h
|
||||||
|
938b40033559b6b82d8f98872180dc9c0d7953b4dbd2f9217b3b77e506bac330 vendor/libssh2-sys/libssh2/libssh2.pc.in
|
||||||
|
11691629d5f88a4031d5ccf6ac756a66d1b1abfe6e138e81161e82b3ff4cbbbf vendor/libssh2-sys/libssh2/src/.gitignore
|
||||||
|
3857309526ae9794db496de79b509bda925798fcc857f5b30c50509823ac7841 vendor/libssh2-sys/libssh2/src/CMakeLists.txt
|
||||||
|
a71aa8dde39f76e726d57514f71c88402523f241a6c3edae8b269664e8ea0aa5 vendor/libssh2-sys/libssh2/src/Makefile.am
|
||||||
|
ed8a644a5c476a0eea976189d1dc277af23426046cadfbd0095db44305fdbd6f vendor/libssh2-sys/libssh2/src/Makefile.inc
|
||||||
|
a2e9b5a7c2bcba8fd252219f4308ddf5c192695a0957e1934000a9d3a6cbd470 vendor/libssh2-sys/libssh2/src/agent.c
|
||||||
|
3236e068f656cb381fac00350f14b281cb55cc84e1495af41dfec930e85c2f5d vendor/libssh2-sys/libssh2/src/agent_win.c
|
||||||
|
8df5f079c77ae9e5b452a575e2ecf33bf4478b356d2975c9a55eef364c84a415 vendor/libssh2-sys/libssh2/src/bcrypt_pbkdf.c
|
||||||
|
baeedac57701200ce12ab4beef6ff56dd6bfb787eaa9d9c1f4fe16fbdf690a32 vendor/libssh2-sys/libssh2/src/blowfish.c
|
||||||
|
0a1efb5884580f0b279fcd8782e455f8e3ca07bbdfebc457706f6e8bf18f296c vendor/libssh2-sys/libssh2/src/chacha.c
|
||||||
|
b42346b01acd60d6502e689802059648205c118035ede40bec8e0d60cf238518 vendor/libssh2-sys/libssh2/src/chacha.h
|
||||||
|
79c49c36e65178847087eb3a1c140772c291899b838c705b579214929a14742e vendor/libssh2-sys/libssh2/src/channel.c
|
||||||
|
0ed48da58c98ab7afcbb9a86177a29c92bb66dbd8793e1a8e477da0ef2f66e6d vendor/libssh2-sys/libssh2/src/channel.h
|
||||||
|
d9f806b81541de62184e3190b7e1c4f7ab576fd290e3558fe25bf0e00f115f5d vendor/libssh2-sys/libssh2/src/cipher-chachapoly.c
|
||||||
|
f6a69fb48994da6bf7f286884ed473ee0546027bba3af433b38b95d1b4442f5f vendor/libssh2-sys/libssh2/src/cipher-chachapoly.h
|
||||||
|
b2d1642bc02e0b50b02f2619393ac9afd15985bdbc088231a6ea876a82f23226 vendor/libssh2-sys/libssh2/src/comp.c
|
||||||
|
95aa07dee4c6f2c33f6bc762c6532997e37303bf1be1bb1276f4ecd5e7092443 vendor/libssh2-sys/libssh2/src/comp.h
|
||||||
|
2d69312c6059192560bb72e4bfa967e4b6e7c4453383580da90ab86233e98aa4 vendor/libssh2-sys/libssh2/src/crypt.c
|
||||||
|
f3eaf5a3a69d91b144f3d4c2f36313dc3264aada85aa1f42d7c1f6bbf969116f vendor/libssh2-sys/libssh2/src/crypto.c
|
||||||
|
1d3d7a56800f0b5b0d008a68d09aaaa26e326879cfd8ace7df8b99406e97f659 vendor/libssh2-sys/libssh2/src/crypto.h
|
||||||
|
e541d1a3b2a5e74733ce745a4d016175fa4ed1fb6571b8a4d69fdf5efdb576c2 vendor/libssh2-sys/libssh2/src/crypto_config.h
|
||||||
|
a6c325a335875d6d0fa0dbe544334a469cec32b45879b9372a49419da13bb492 vendor/libssh2-sys/libssh2/src/global.c
|
||||||
|
32edcf5a3023c67740b8a5e290a2db4957a547be7fb9f1778c143f08a622b65f vendor/libssh2-sys/libssh2/src/hostkey.c
|
||||||
|
93c0427472296781c0e01275e60b1d74c0f678112c45eebd5e233b31f4e4bbca vendor/libssh2-sys/libssh2/src/keepalive.c
|
||||||
|
50b90f522575d794b853802221e8b2f2cc99c3b68ff191e1252d70ed1276b6b5 vendor/libssh2-sys/libssh2/src/kex.c
|
||||||
|
f466648bff0197c06e3ea2380b9083b634f0002d41293f1102341247dd208872 vendor/libssh2-sys/libssh2/src/knownhost.c
|
||||||
|
90c46b20d6cc0288f4f3f272d5a4e8d2c0495d3f728d0b62ed9e0e026baf2532 vendor/libssh2-sys/libssh2/src/libgcrypt.c
|
||||||
|
0ae0284295424a04dba948432527b70fb12d447e8be8ce28e2be1e480323d4ac vendor/libssh2-sys/libssh2/src/libgcrypt.h
|
||||||
|
26d6cae6049b6fb7d32df0ea8a30ffbc0c52f1871be257bcb2986f40555ddada vendor/libssh2-sys/libssh2/src/libssh2.rc
|
||||||
|
a2d971a484dc63bea9cc46693ffc3de6227e118a32153df7db3df9139be14b00 vendor/libssh2-sys/libssh2/src/libssh2_config_cmake.h.in
|
||||||
|
c3da0fff8726b52b3dd2429bbaf26f75d93f0188367d650557d6710017ebb890 vendor/libssh2-sys/libssh2/src/libssh2_priv.h
|
||||||
|
d5d1c31758f49ef67e6afac92a7c8fc0ccb53b1fd2bba550e57a3042c17ba55b vendor/libssh2-sys/libssh2/src/libssh2_setup.h
|
||||||
|
b21e1b85582e43c9696c74fce826c664003326522d1bafb7a9ba6b28a0a6f64d vendor/libssh2-sys/libssh2/src/mac.c
|
||||||
|
acdc44807370d226c64958e870f3daec767378b45246bbcdee37db87120dab61 vendor/libssh2-sys/libssh2/src/mac.h
|
||||||
|
e56b0dafc27140de389926bb7e8417ab3e5255e74126ee4a588c18d70ad188cd vendor/libssh2-sys/libssh2/src/mbedtls.c
|
||||||
|
f26002e949125aac18471f10e590cbb5bee47c70de8a7798763604ef0ddcd44c vendor/libssh2-sys/libssh2/src/mbedtls.h
|
||||||
|
c4e749f78c4f5224fcb02eb50de65dc804ed4aaaa59a734b10a12f54fe2fa085 vendor/libssh2-sys/libssh2/src/misc.c
|
||||||
|
608eb5736eb367b82e93c96b8bc94fb87b23141f4658ee991307e64a721c4fa9 vendor/libssh2-sys/libssh2/src/misc.h
|
||||||
|
2df90530491643625921e42abbab3f7c4ffc981ce2276e1d0826cc38e896f484 vendor/libssh2-sys/libssh2/src/openssl.c
|
||||||
|
bdc6cc39f5a075d4497cc0222892ac371418133e6cfcaa357b131cd62d54e82a vendor/libssh2-sys/libssh2/src/openssl.h
|
||||||
|
31e230fdef9acdb23cd2de667b532f7b6853aacd403f54dbf1f700d2e68dbaca vendor/libssh2-sys/libssh2/src/os400qc3.c
|
||||||
|
c1f8a950a42a8a05ca00ac142915d9fd5bd99ef0c9a3545a12823c03ccb8673d vendor/libssh2-sys/libssh2/src/os400qc3.h
|
||||||
|
6ec4270e721ec1e0a5e57af52f59006b87438568bcca6057eb76cdcfd3a0b6ca vendor/libssh2-sys/libssh2/src/packet.c
|
||||||
|
66ded0eea0be5b4c1f5217e053941a848e5106311eb9338dd39f1bc48713a4c4 vendor/libssh2-sys/libssh2/src/packet.h
|
||||||
|
eba1a89ba4beb0aa9aaf4e4da68be5f2f890e7367001e8bb705c697b9485d416 vendor/libssh2-sys/libssh2/src/pem.c
|
||||||
|
cc0597e7de057ab22ab988bb98d4418d716ea26bb2ad418879007dd38dce2ebf vendor/libssh2-sys/libssh2/src/poly1305.c
|
||||||
|
58a34be15d390cae4cb5a215ccf139f0ca359cdb47ea846e6e7e1e6141d3409e vendor/libssh2-sys/libssh2/src/poly1305.h
|
||||||
|
ba80e17329a880d5bea1f8cf035dcf140c002ba1ed83eceaaec8dc352b6b3d28 vendor/libssh2-sys/libssh2/src/publickey.c
|
||||||
|
6a12ddc4c246b510ff61337d908c683714d4223fdb27f1256605a29f3925dc1a vendor/libssh2-sys/libssh2/src/scp.c
|
||||||
|
9f9c0a55ee7ca5204f73f2eb6e6c37957d1fe3f160ea7a819248310e1a58d8aa vendor/libssh2-sys/libssh2/src/session.c
|
||||||
|
19ebee873e9f5e1f56850bf263053d2527c28a184f30ac42f66aa09fb38d1b5a vendor/libssh2-sys/libssh2/src/session.h
|
||||||
|
93d9ed061a7b050649804e13aa208e9a726a6e694d696926bffddd5f011d84ec vendor/libssh2-sys/libssh2/src/sftp.c
|
||||||
|
9c53d613cfdb14d4244ab0643918de60363d9a360a40413cd2e4d682f3e32125 vendor/libssh2-sys/libssh2/src/sftp.h
|
||||||
|
5291489a6da89018354d67edb1675cec9ea14dcae232628527078e7094b6cae2 vendor/libssh2-sys/libssh2/src/transport.c
|
||||||
|
f2a55152b28ab3290d426767d9e33b053312300b0b4105cc0d20814c1b133ff3 vendor/libssh2-sys/libssh2/src/transport.h
|
||||||
|
4fff87fde157b9936c536fe545c2966a1b2d7f4e387db352849220020f98eadc vendor/libssh2-sys/libssh2/src/userauth.c
|
||||||
|
23eb97c342ab6bc51606fe917828e8c01cd4bda4c956d5f22b66d86b6db0d1a3 vendor/libssh2-sys/libssh2/src/userauth.h
|
||||||
|
e58477ff04c25b7a7f3c0654e36cbc483eef6a4ccc2d66b88223678d757502d2 vendor/libssh2-sys/libssh2/src/userauth_kbd_packet.c
|
||||||
|
570d9cbca0e3969d1f7dac2e3d89b0ad3b79bd9f0993d60b830a3b30113ae9f3 vendor/libssh2-sys/libssh2/src/userauth_kbd_packet.h
|
||||||
|
2710abb5561557c62613f1780fb593d9bc423105bdee81d0ace9bc1ab3b5fb23 vendor/libssh2-sys/libssh2/src/version.c
|
||||||
|
cd98e55761c4adebec10256ebe0f0b4f59ab23605d5663f5ccb7e7e2827debfe vendor/libssh2-sys/libssh2/src/wincng.c
|
||||||
|
78deb417e2cb9e514ad9da35db2f918d2649034318959b2b7a0fa15a9e5f960a vendor/libssh2-sys/libssh2/src/wincng.h
|
||||||
|
b6f970cfc5749e0ba35695b7d49b0774a191779d159a438249f9065ecc8a88bd vendor/libssh2-sys/patches/security-backports.patch
|
||||||
|
3c238183c67b3aad6497cdc8368d32cde5a4392dcba3134da0fa40c50882963e vendor/libssh2-sys/patches/upstream-manifest.json
|
||||||
|
9cb033e07004a755675e2095eb7f05ebeaafa70b0920a1fdb8f6ad23498047d9 vendor/libssh2-sys/patches/upstream/58050-34497525929b.patch
|
||||||
|
0aee020bbc88acd2a3d828f1c85861f9f5a36204c07543cddc0abd68d4b5ed0a vendor/libssh2-sys/patches/upstream/58051-a9758da45a52.patch
|
||||||
|
a70ff256ce24fc5e4144fcc4409a5874c397e1f9e1b1d437120e4be3f5fc051d vendor/libssh2-sys/patches/upstream/66032-5e4776146552.patch
|
||||||
|
f59ff6d6575ec8f17af41825aac4dc6c8ddcdd6909dc6ae3a535fa13dba8a290 vendor/libssh2-sys/patches/upstream/66033-a2ed82d40964.patch
|
||||||
|
896ff800992af462519f0b2d2f47a84f5096728f27c9faf36b1676eaa9c6620f vendor/libssh2-sys/patches/upstream/66034-a13bb6c773f0.patch
|
||||||
|
0422c11e22df21dc8154fa0aeb812157d0798d990bd9ab2cd05844dc21653693 vendor/libssh2-sys/patches/upstream/66035-42e33d81577e.patch
|
||||||
|
fd4775228fe04a11532043355099fe2643993aae4fb63576620b1941f9a80645 vendor/libssh2-sys/patches/upstream/7598-256d04b60d80.patch
|
||||||
|
8d0e86562978b93406b85531e1f391d2f03c0387c057fb8b3a6e14b228aabdb5 vendor/libssh2-sys/patches/upstream/publickey-leak-c2f1a3a21b4c.patch
|
||||||
|
6464c70d8d34249e5d0a0969136f35c1a2a0e3d0424c2889c5b8e6fbe874f232 vendor/libssh2-sys/patches/upstream/publickey-terminator-ad16eb112f4a.patch
|
||||||
|
00641403f44417872ef9fe45fe2a581451e4b9fd12305d1b3902f1433036edcc vendor/libssh2-sys/tests/run-security-regressions.sh
|
||||||
|
4d21796cd08f3dbf44664f3e53122960847940d306792ac7efcb8720794320cf vendor/libssh2-sys/tests/security-regression.c
|
||||||
|
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 vendor/swagger-ui/LICENSE
|
||||||
|
0d20d1adef18aee3f40dd258172155521ce702ac445cb5f7b7d60ed32dad2fb2 vendor/swagger-ui/NOTICE
|
||||||
|
c02dd4680423fdaffe96012cd012adda68b26281152a95355744ed4f6141c282 vendor/swagger-ui/PROVENANCE.json
|
||||||
|
3f6abf70480db7e07914df4c98a77385f22ea585eaf457a3e6415ba0eae2d167 vendor/swagger-ui/swagger-ui-bundle.js
|
||||||
|
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
|
||||||
|
78955d3bb7eacc8f4144570df5d54b17b54419d403c6d98472d7314b21c8e377 web/tests/live.cjs
|
||||||
|
de7d09e3d50c339962aa75d4e7603fcd3457e1ba1c2c2c0d5c536c83a005c77d web/tests/smoke.cjs
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn content_hash(content: &str) -> String {
|
||||||
|
let value = content.bytes().fold(0xcbf29ce484222325_u64, |hash, byte| {
|
||||||
|
(hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
|
||||||
|
});
|
||||||
|
format!("{value:016x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_asset(output: &Path, stem: &str, extension: &str, content: &str) -> String {
|
||||||
|
let file_name = format!("{stem}.{}.{extension}", content_hash(content));
|
||||||
|
fs::write(output.join(&file_name), content)
|
||||||
|
.unwrap_or_else(|error| panic!("write {file_name}: {error}"));
|
||||||
|
file_name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cargo_asset_env(name: &str, file_name: &str) {
|
||||||
|
println!("cargo:rustc-env={name}_FILE={file_name}");
|
||||||
|
println!("cargo:rustc-env={name}_ROUTE=/assets/{file_name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let output = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"));
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
println!("cargo:rerun-if-changed=web");
|
||||||
|
println!("cargo:rerun-if-changed=docs/api-ui");
|
||||||
|
println!("cargo:rerun-if-changed=vendor/swagger-ui/swagger-ui-bundle.js");
|
||||||
|
println!("cargo:rerun-if-changed=vendor/swagger-ui/swagger-ui.css");
|
||||||
|
|
||||||
|
if let Ok(entries) = fs::read_dir(&output) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
let generated_asset = (name.starts_with("app.") || name.starts_with("docs."))
|
||||||
|
&& (name.ends_with(".js") || name.ends_with(".css"));
|
||||||
|
if generated_asset {
|
||||||
|
let _ = fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
let mut css = String::new();
|
||||||
|
for entry in manifest
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && !value.starts_with('#'))
|
||||||
|
{
|
||||||
|
let source = fs::read_to_string(PathBuf::from("web").join(entry))
|
||||||
|
.unwrap_or_else(|error| panic!("{entry}: {error}"));
|
||||||
|
if entry.ends_with(".js") {
|
||||||
|
js.push_str(&source);
|
||||||
|
js.push_str("\n;\n");
|
||||||
|
} else if entry.ends_with(".css") {
|
||||||
|
css.push_str(&source);
|
||||||
|
css.push('\n');
|
||||||
|
} else {
|
||||||
|
panic!("Unsupported bundle input: {entry}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_js_file = write_asset(&output, "app", "js", &js);
|
||||||
|
let app_css_file = write_asset(&output, "app", "css", &css);
|
||||||
|
cargo_asset_env("APP_JS", &app_js_file);
|
||||||
|
cargo_asset_env("APP_CSS", &app_css_file);
|
||||||
|
|
||||||
|
let html = fs::read_to_string("web/index.html")
|
||||||
|
.expect("web/index.html is required")
|
||||||
|
.replace("/assets/app.js", &format!("/assets/{app_js_file}"))
|
||||||
|
.replace("/assets/app.css", &format!("/assets/{app_css_file}"));
|
||||||
|
fs::write(output.join("index.html"), html).expect("write HTML shell");
|
||||||
|
|
||||||
|
let mut docs_js = fs::read_to_string("vendor/swagger-ui/swagger-ui-bundle.js")
|
||||||
|
.expect("Swagger UI JavaScript is required")
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.starts_with("//# sourceMappingURL="))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
docs_js.push_str("\n;\n");
|
||||||
|
docs_js.push_str(&fs::read_to_string("docs/api-ui/init.js").expect("docs API initializer"));
|
||||||
|
|
||||||
|
let mut docs_css = fs::read_to_string("vendor/swagger-ui/swagger-ui.css")
|
||||||
|
.expect("Swagger UI stylesheet is required")
|
||||||
|
.replace("/*# sourceMappingURL=swagger-ui.css.map*/", "");
|
||||||
|
docs_css.push('\n');
|
||||||
|
docs_css.push_str(&fs::read_to_string("docs/api-ui/style.css").expect("docs API stylesheet"));
|
||||||
|
|
||||||
|
let docs_js_file = write_asset(&output, "docs", "js", &docs_js);
|
||||||
|
let docs_css_file = write_asset(&output, "docs", "css", &docs_css);
|
||||||
|
cargo_asset_env("DOCS_JS", &docs_js_file);
|
||||||
|
cargo_asset_env("DOCS_CSS", &docs_css_file);
|
||||||
|
|
||||||
|
let docs_html = fs::read_to_string("docs/api-ui/index.html")
|
||||||
|
.expect("docs API HTML is required")
|
||||||
|
.replace("/assets/docs.js", &format!("/assets/{docs_js_file}"))
|
||||||
|
.replace("/assets/docs.css", &format!("/assets/{docs_css_file}"));
|
||||||
|
fs::write(output.join("docs.html"), docs_html).expect("write API documentation HTML");
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
image: mikrotik-backup:0.0.1
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-5581}:5581"
|
||||||
|
environment:
|
||||||
|
APP_ENV: production
|
||||||
|
APP_NAME: "${APP_NAME:-MikroTik Backup System}"
|
||||||
|
LISTEN_ADDR: "0.0.0.0:5581"
|
||||||
|
DATA_DIR: /data
|
||||||
|
API_PREFIX: "${API_PREFIX:-/api}"
|
||||||
|
TIMEZONE: "${TIMEZONE:-Europe/Warsaw}"
|
||||||
|
SECRET_KEY: "${SECRET_KEY:-}"
|
||||||
|
ALLOW_REGISTRATION: "${ALLOW_REGISTRATION:-false}"
|
||||||
|
DEFAULT_ADMIN_USERNAME: "${DEFAULT_ADMIN_USERNAME:-admin}"
|
||||||
|
DEFAULT_ADMIN_PASSWORD: "${DEFAULT_ADMIN_PASSWORD:-}"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: "${ACCESS_TOKEN_EXPIRE_MINUTES:-1440}"
|
||||||
|
SSH_HOST_KEY_POLICY: "${SSH_HOST_KEY_POLICY:-accept-new}"
|
||||||
|
SSH_TIMEOUT_SECONDS: "${SSH_TIMEOUT_SECONDS:-30}"
|
||||||
|
MAX_PARALLEL_BACKUPS: "${MAX_PARALLEL_BACKUPS:-4}"
|
||||||
|
SMTP_STARTTLS: "${SMTP_STARTTLS:-true}"
|
||||||
|
SMTP_TIMEOUT_SECONDS: "${SMTP_TIMEOUT_SECONDS:-20}"
|
||||||
|
CORS_ORIGINS: "${CORS_ORIGINS:-[]}"
|
||||||
|
RUST_LOG: "${RUST_LOG:-mikrotik_backup=info,tower_http=warn}"
|
||||||
|
volumes:
|
||||||
|
- data:/data
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=2304m,mode=1777
|
||||||
|
cap_drop: [ ALL ]
|
||||||
|
security_opt: [ no-new-privileges:true ]
|
||||||
|
sysctls:
|
||||||
|
net.ipv4.ping_group_range: "0 2147483647"
|
||||||
|
stop_grace_period: 5m
|
||||||
|
healthcheck:
|
||||||
|
test: [ CMD, /usr/local/bin/mikrotik-backup, healthcheck ]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 20s
|
||||||
|
retries: 3
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=MikroTik Backup System 0.0.1
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=mikrotik-backup
|
||||||
|
Group=mikrotik-backup
|
||||||
|
WorkingDirectory=/opt/mikrotik-backup
|
||||||
|
Environment=DATA_DIR=/var/lib/mikrotik-backup
|
||||||
|
Environment=LISTEN_ADDR=127.0.0.1:5581
|
||||||
|
EnvironmentFile=-/etc/mikrotik-backup.env
|
||||||
|
ExecStart=/opt/mikrotik-backup/mikrotik-backup serve
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
TimeoutStopSec=300
|
||||||
|
StateDirectory=mikrotik-backup
|
||||||
|
StateDirectoryMode=0700
|
||||||
|
UMask=0077
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Convenience entry point for a Docker Compose build from these sources.
|
||||||
|
set -euo pipefail
|
||||||
|
project_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
exec "$project_dir/start.sh" --docker "$@"
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,82 @@
|
|||||||
|
# Interactive API documentation — 0.0.1
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
| URL | Content |
|
||||||
|
| --- | --- |
|
||||||
|
| `/docs`, `/docs/` | Local Swagger UI with the complete contract and request execution |
|
||||||
|
| `/openapi.json` | OpenAPI 3.1.0 with 36 operations and input/output models |
|
||||||
|
| `/redoc` | Compatibility redirect to `/docs` |
|
||||||
|
|
||||||
|
The default API base path is `/api`. The server applies the configured `API_PREFIX`
|
||||||
|
to `servers` and the OAuth2 login URL, and includes the application name and
|
||||||
|
version in the document metadata. Changing the prefix requires no JSON edits.
|
||||||
|
|
||||||
|
## Authentication and examples
|
||||||
|
|
||||||
|
In the **Authorize** dialog, select one authentication method:
|
||||||
|
|
||||||
|
- `OAuth2PasswordBearer`: the username and password of an existing account. The
|
||||||
|
documentation submits a form to `/api/auth/login` and uses the returned token.
|
||||||
|
- `BearerAuth`: the `access_token` value, without the `Bearer` prefix.
|
||||||
|
|
||||||
|
For an operation, select **Try it out**, fill in the fields, and select **Execute**.
|
||||||
|
Requests perform real operations on the current instance. Updates, outgoing
|
||||||
|
messages, backups, and deletions use the same permissions and limits as the
|
||||||
|
application interface. Registration requires an explicit `ALLOW_REGISTRATION=true`.
|
||||||
|
|
||||||
|
Add a SwitchOS device using the global username/password pair:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "switch-01",
|
||||||
|
"device_type": "switchos",
|
||||||
|
"host": "192.0.2.10",
|
||||||
|
"port": 80,
|
||||||
|
"use_global_switchos_credentials": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update only the binary backup schedule:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"binary_cron": "0 2 * * *"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitted fields remain unchanged. Empty or null secret fields preserve stored
|
||||||
|
values; explicit `clear_*` flags remove them. Public responses do not expose stored
|
||||||
|
passwords or keys. A separate endpoint reveals the global SSH key after the
|
||||||
|
account password is entered again.
|
||||||
|
|
||||||
|
## Assets and maintenance
|
||||||
|
|
||||||
|
`docs/openapi.json` is the contract template. `src/api/documentation.rs` serves it
|
||||||
|
with the current instance configuration. The HTML, initializer, stylesheet, and
|
||||||
|
local Swagger UI distribution are embedded directly from the checked-in source
|
||||||
|
files at compile time. On first use, the application combines the embedded assets
|
||||||
|
into one `docs.js` and one `docs.css`. Their URLs contain content hashes for browser
|
||||||
|
caching. Documentation assets do not depend on generated files in `OUT_DIR`;
|
||||||
|
`build.rs` builds only the main application assets.
|
||||||
|
|
||||||
|
The official `swagger-ui-dist` **5.34.0** distribution is pinned and was downloaded
|
||||||
|
from its publisher registry. The archive SHA-512 was checked against the published
|
||||||
|
integrity value. Original assets, the Apache-2.0 license, NOTICE, and bundled
|
||||||
|
library licenses are in `vendor/swagger-ui/`. Source details and individual file
|
||||||
|
checksums are recorded in `vendor/swagger-ui/PROVENANCE.json`.
|
||||||
|
|
||||||
|
Upstream sources: [Swagger UI installation](https://github.com/swagger-api/swagger-ui/blob/main/docs/usage/installation.md),
|
||||||
|
[configuration](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/),
|
||||||
|
[swagger-ui-dist package](https://registry.npmjs.org/swagger-ui-dist/5.34.0).
|
||||||
|
|
||||||
|
The remote validator, query-string configuration, and persistent browser token
|
||||||
|
storage are disabled. The request interceptor permits only same-origin requests;
|
||||||
|
the Content Security Policy disallows external scripts. Building and running the
|
||||||
|
documentation requires neither Node nor a CDN connection.
|
||||||
|
|
||||||
|
`tests/openapi_contract.rs` checks every documented method and path against the
|
||||||
|
actual Axum router, authentication requirements, local schema references, a custom
|
||||||
|
API prefix, English documentation, and embedded assets. It also checks that the
|
||||||
|
cache keys in the HTML match the served asset contents. Browser testing covers
|
||||||
|
rendering and request execution on an isolated instance.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<title>MikroTik Backup System — API documentation</title>
|
||||||
|
<link rel="stylesheet" href="/assets/docs.css">
|
||||||
|
<script src="/assets/docs.js" defer></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header class="docs-header">
|
||||||
|
<a href="/">MikroTik Backup System</a>
|
||||||
|
<nav aria-label="Documentation"><a href="/openapi.json" download="mikrotik-backup-openapi.json">OpenAPI JSON</a><a
|
||||||
|
href="/">Back to the application</a></nav>
|
||||||
|
</header>
|
||||||
|
<main id="swagger-ui" aria-label="API documentation"></main>
|
||||||
|
<noscript>Enable JavaScript to use the interactive documentation. The JSON specification is available at
|
||||||
|
/openapi.json.</noscript>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
window.apiDocs = SwaggerUIBundle({
|
||||||
|
url: '/openapi.json',
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
docExpansion: 'list',
|
||||||
|
filter: true,
|
||||||
|
defaultModelsExpandDepth: 0,
|
||||||
|
persistAuthorization: false,
|
||||||
|
validatorUrl: null,
|
||||||
|
queryConfigEnabled: false,
|
||||||
|
supportedSubmitMethods: ['get', 'post', 'put', 'delete'],
|
||||||
|
requestInterceptor: function (request) {
|
||||||
|
if (new URL(request.url, location.href).origin !== location.origin) {
|
||||||
|
throw new Error('API documentation can send requests only to this instance.');
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.apiDocs.initOAuth({ clientId: 'swagger-ui', appName: 'MikroTik Backup System' });
|
||||||
|
}());
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
html {
|
||||||
|
background: #fafafa;
|
||||||
|
color: #1b2734;
|
||||||
|
color-scheme: light
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, sans-serif
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 17px 24px;
|
||||||
|
background: #101e2b;
|
||||||
|
color: #e4edf5
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header>a {
|
||||||
|
font-weight: 600
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 22px
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header a:focus-visible {
|
||||||
|
outline: 2px solid #36b8f4;
|
||||||
|
outline-offset: 5px
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header a:hover {
|
||||||
|
text-decoration: underline
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .info {
|
||||||
|
margin: 30px 0
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .wrapper {
|
||||||
|
max-width: 1440px
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .info .description p {
|
||||||
|
line-height: 1.6
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .scheme-container {
|
||||||
|
box-shadow: none;
|
||||||
|
border-top: 1px solid #dce1e6;
|
||||||
|
border-bottom: 1px solid #dce1e6
|
||||||
|
}
|
||||||
|
|
||||||
|
noscript {
|
||||||
|
display: block;
|
||||||
|
padding: 24px
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(max-width:640px) {
|
||||||
|
.docs-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header nav {
|
||||||
|
gap: 18px
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header a {
|
||||||
|
font-size: 12px
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .info .title {
|
||||||
|
font-size: 26px
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-ui .wrapper {
|
||||||
|
padding: 0 12px
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Architecture — release 0.0.1
|
||||||
|
|
||||||
|
## One process with explicit module boundaries
|
||||||
|
|
||||||
|
Axum serves the HTTP API and embedded interface assets. Tokio manages requests,
|
||||||
|
scheduling, and notification tasks. Local SQLite access uses `rusqlite`; a mutex
|
||||||
|
protects a single connection configured with foreign keys, WAL, and a busy timeout.
|
||||||
|
The SSH library is synchronous, so blocking sessions run through `spawn_blocking`.
|
||||||
|
The application does not launch Python, Node, `ssh`, `scp`, `ping`, or system cron
|
||||||
|
subprocesses.
|
||||||
|
|
||||||
|
Interface sources remain separated by responsibility. `web/assets.list` specifies
|
||||||
|
concatenation order. `build.rs` produces one `app.js`, one `app.css`, and the HTML in
|
||||||
|
Cargo's `OUT_DIR`; the compiler embeds them in the executable. Deployment does not
|
||||||
|
read `web/` from disk or fetch UI assets from a CDN. A source change requires a new
|
||||||
|
build. The distribution contains source only, and `start.sh` builds before launching.
|
||||||
|
CSS and JavaScript URLs include content-derived identifiers so browsers fetch changed
|
||||||
|
assets after an update without requiring manual cache clearing.
|
||||||
|
|
||||||
|
The same process serves `/docs` and `/openapi.json`. The API documentation module
|
||||||
|
embeds checked-in files directly with `include_str!`, combines the local Swagger UI
|
||||||
|
and integration assets into one CSS and one JavaScript response, and supplies
|
||||||
|
content-derived cache identifiers. This path does not depend on generated
|
||||||
|
`OUT_DIR/docs.*` files. Documentation assets load only on the documentation page.
|
||||||
|
The specification covers 36 operations and uses the configured API prefix. Assets
|
||||||
|
and validators are not fetched from external services. See
|
||||||
|
[API documentation integration](api-documentation.md).
|
||||||
|
|
||||||
|
## Operations and data consistency
|
||||||
|
|
||||||
|
Requests validate an HS256 JWT, the current user's existence, and device/backup
|
||||||
|
ownership. Password verification and hashing have a separate four-operation pool;
|
||||||
|
saturation returns 429 without blocking the rest of the API. Login throttling also
|
||||||
|
limits incorrect password attempts. Global settings and logs retain the source
|
||||||
|
application's scope: authenticated operators.
|
||||||
|
|
||||||
|
Reading a complete request body has a 1 MiB limit and a 15-second deadline. An
|
||||||
|
incomplete request cannot indefinitely prevent shutdown. This deadline does not
|
||||||
|
limit the execution time of a backup operation.
|
||||||
|
|
||||||
|
A backup acquires a device lock and a slot in the operation pool. A tracked task owns
|
||||||
|
the lock, so a disconnected HTTP client cannot release it halfway through a transfer.
|
||||||
|
Data is written to a private temporary file under `DATA_DIR`. After the transfer,
|
||||||
|
the application validates size, synchronizes the file, calculates SHA-256, and
|
||||||
|
publishes it. The SQLite record is committed last; a database failure removes the
|
||||||
|
published file. Deletion stages the file under a temporary name and compensates if
|
||||||
|
the database update fails.
|
||||||
|
|
||||||
|
Each backup access checks `DATA_DIR` boundaries, names, and symlinks. Bulk ZIP creation
|
||||||
|
has an aggregate size limit and uses a temporary file; the response streams that
|
||||||
|
file instead of holding the complete archive in memory. The data directory must
|
||||||
|
remain private: protection against a local user concurrently changing files depends
|
||||||
|
on preventing shared write access to it.
|
||||||
|
|
||||||
|
## Protocols and error handling
|
||||||
|
|
||||||
|
RouterOS operations use SSH, pinned host keys, password/private-key authentication,
|
||||||
|
`/export`, `/system backup save`, and SFTP with an SCP fallback. Device-side temporary
|
||||||
|
files are removed after transfer. Upload stops after writing the file; it never
|
||||||
|
issues a restore command. The default `accept-new` policy records the first observed
|
||||||
|
host key and requires the same key later. Explicit `strict` requires an already
|
||||||
|
trusted entry. Host identity keys and client authentication keys serve different
|
||||||
|
purposes. A timeout covers the entire session; a separate timer closes the socket
|
||||||
|
if the peer stalls an operation.
|
||||||
|
|
||||||
|
SwitchOS uses HTTP(S), Digest or Basic authentication, response validation, and a
|
||||||
|
size limit. Actual HTML is rejected as a backup regardless of its MIME type, while
|
||||||
|
otherwise nonempty firmware-specific `.swb` content is not rejected by MIME/signature
|
||||||
|
heuristics. A public HTTP 200 page does not prove backup authentication. When needed,
|
||||||
|
the adapter obtains a Digest challenge from `/sys.b` and signs the actual
|
||||||
|
`/backup.swb` request URI. Connection tests report web reachability separately from
|
||||||
|
backup availability. Bounded same-host redirects are followed; redirects to a
|
||||||
|
different host or an HTTPS-to-HTTP downgrade are stopped.
|
||||||
|
|
||||||
|
SwitchOS credentials have an explicit global or device-local source. Imported rows
|
||||||
|
without a selection preserve their original field-by-field fallback. Selecting a
|
||||||
|
source does not erase the other stored credentials. The global mode reads current
|
||||||
|
settings on each connection. See [protocol details and sources](switchos.md).
|
||||||
|
|
||||||
|
ICMP uses a native socket; missing socket permissions produce an explicit diagnostic
|
||||||
|
error. Device connections are initiated only to hosts configured by operators.
|
||||||
|
Known secrets are redacted from public responses and logs. Notification failures do
|
||||||
|
not invalidate a successfully stored backup. Batch operations return a result for
|
||||||
|
each device, including failures and skips.
|
||||||
|
|
||||||
|
## Scheduling and shutdown
|
||||||
|
|
||||||
|
The scheduler reads settings once per second, evaluates five-field cron expressions
|
||||||
|
in the configured timezone, and prevents overlapping instances of the same job.
|
||||||
|
Numeric weekdays preserve APScheduler semantics: Monday is 0. Export and binary
|
||||||
|
jobs respect device policy flags. After a restart, the next execution is calculated;
|
||||||
|
the application does not replay a complete queue of jobs missed while it was off.
|
||||||
|
A delayed tick tolerates a scheduling delay of up to five minutes.
|
||||||
|
|
||||||
|
`SIGTERM` and `Ctrl+C` stop accepting HTTP requests, prevent new pooled operations,
|
||||||
|
stop scheduling, and wait for operations already in progress. A WAL checkpoint runs
|
||||||
|
before exit. The process holds `application.lock`; the explicit importer uses the
|
||||||
|
same storage lock. Deployments should allow in-progress operations to finish.
|
||||||
|
Compose and systemd use a 300-second stop timeout by default; increase this if
|
||||||
|
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.
|
||||||
|
|
||||||
|
The separate `import-python --source DIR [--database PATH] [--backup-root DIR]`
|
||||||
|
command imports into a new destination. The `import-python.sh` wrapper builds the
|
||||||
|
application and invokes that command. Import opens the source read-only, preserves
|
||||||
|
IDs, ownership, password hashes, settings, stored credentials, and operation logs,
|
||||||
|
and copies validated backup content to the destination. Existing destination
|
||||||
|
databases or files are not overwritten. See the full
|
||||||
|
[Python import procedure](data-import.md).
|
||||||
|
|
||||||
|
`DATA_DIR` contains the database by default, backups, the JWT secret, trusted host
|
||||||
|
keys, and locks. A separately configured `DATABASE_PATH` must also be included in
|
||||||
|
operational backups. These files are deployment data, not release source. Persistent
|
||||||
|
changes should go through the application; a consistent copy of an active database
|
||||||
|
requires the SQLite Online Backup API or stopping the service first.
|
||||||
|
|
||||||
|
Binary files use `backups/binary/<router_id>/`, and text exports use
|
||||||
|
`backups/exports/<router_id>/`. Stable device IDs keep paths unchanged when devices
|
||||||
|
are renamed. Fresh backups and explicitly imported files use this layout; startup
|
||||||
|
does not convert an older layout automatically.
|
||||||
|
|
||||||
|
`Cargo.lock`, the pinned toolchain, the schema/import implementation, and documented
|
||||||
|
libssh2 backports are part of the release. [The port matrix](port-matrix.md) identifies
|
||||||
|
both source archives by SHA-256. Dependency checks must be repeated for future
|
||||||
|
releases; an audit result is evidence for a specific dependency set and date, not a
|
||||||
|
claim that every possible defect has been excluded.
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Choose one installation path
|
||||||
|
|
||||||
|
For an empty installation, configure the new application's `.env` and run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
A database is initialized only when the selected database file does not exist.
|
||||||
|
The complete current schema is in `src/schema.sql`. The initial account uses
|
||||||
|
`DEFAULT_ADMIN_USERNAME` and `DEFAULT_ADMIN_PASSWORD`; if the password is omitted,
|
||||||
|
a generated value is stored in `DATA_DIR/secrets/initial-admin-password`.
|
||||||
|
Registration remains controlled by the new application's configuration.
|
||||||
|
|
||||||
|
If you intend to import Python data, **run the import before starting the new
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Import command
|
||||||
|
|
||||||
|
Stop the old Python application, workers, and schedules. Keep its database, WAL,
|
||||||
|
rollback journal, and backup files together. Make an independent copy of the old
|
||||||
|
installation before switching services. This is an offline import, not a live
|
||||||
|
replication mechanism.
|
||||||
|
|
||||||
|
Use the helper from the new Rust project:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./import-python.sh /srv/old-mikrotik-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Its equivalent application command is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./start.sh import-python --source /srv/old-mikrotik-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional source overrides are available when paths are customized or discovery
|
||||||
|
finds more than one candidate:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./import-python.sh /srv/old-mikrotik-app \
|
||||||
|
--database docker-data/db/routeros_backup_next.db \
|
||||||
|
--backup-root docker-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `--database` and `--backup-root` are resolved **relative to `--source`**, unless
|
||||||
|
an absolute path is supplied. They select the old source, not the destination.
|
||||||
|
The helper resolves its first directory argument relative to the caller's working
|
||||||
|
directory before invoking the project's starter.
|
||||||
|
|
||||||
|
The destination comes from the new application's configuration. An explicit
|
||||||
|
example is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DATA_DIR="$PWD/storage-imported" \
|
||||||
|
DATABASE_PATH="$PWD/storage-imported/routeros_backup_next.db" \
|
||||||
|
./import-python.sh /srv/old-mikrotik-app \
|
||||||
|
--database docker-data/db/routeros_backup_next.db \
|
||||||
|
--backup-root docker-data
|
||||||
|
```
|
||||||
|
|
||||||
|
After a successful import, use the same destination to start the server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DATA_DIR="$PWD/storage-imported" \
|
||||||
|
DATABASE_PATH="$PWD/storage-imported/routeros_backup_next.db" \
|
||||||
|
./start.sh serve
|
||||||
|
```
|
||||||
|
|
||||||
|
The `migrate` command from previous revisions has been removed. The importer does
|
||||||
|
not start HTTP or background schedules. It does not add a new administrator when
|
||||||
|
users have been imported. Log in with an imported account and its existing password.
|
||||||
|
|
||||||
|
## Source discovery
|
||||||
|
|
||||||
|
Discovery follows the layouts present in the supplied Python/TypeScript project:
|
||||||
|
|
||||||
|
| Source layout | Recognized database location |
|
||||||
|
| --- | --- |
|
||||||
|
| Shipped Compose bind mount `./docker-data:/app/storage` | `docker-data/db/routeros_backup_next.db` or `docker-data/routeros_backup_next.db` |
|
||||||
|
| Local application working directory | `storage/routeros_backup_next.db` |
|
||||||
|
| Local backend working directory | `backend/storage/routeros_backup_next.db` |
|
||||||
|
| Explicit source configuration | SQLite `DATABASE_URL` and `DATA_DIR` in `.env` or `backend/.env` |
|
||||||
|
|
||||||
|
The known data directories are also checked for the documented `db/` database
|
||||||
|
subdirectory. Container paths under `/app/storage` are mapped to the shipped
|
||||||
|
`docker-data` mount. A customized Compose volume mapping is not executed or
|
||||||
|
interpreted as arbitrary code; supply explicit paths when it differs.
|
||||||
|
|
||||||
|
Only actual existing candidates are considered. **Multiple different database
|
||||||
|
files require `--database`**; discovery never chooses the first, newest, or largest
|
||||||
|
file. A configured database or data directory that is unavailable produces an
|
||||||
|
error instead of silently substituting another installation. Source overrides
|
||||||
|
allow the operator to resolve that situation explicitly.
|
||||||
|
|
||||||
|
The importer reads only `DATABASE_URL` and `DATA_DIR` from source environment
|
||||||
|
files. It does not execute shell expressions, expand variables, import the Python
|
||||||
|
application, or copy the source `.env`. Dynamic expressions and unsupported SQLite
|
||||||
|
URL options require explicit source paths. Providing both overrides bypasses
|
||||||
|
source environment-path discovery.
|
||||||
|
|
||||||
|
For each backup record, the importer resolves the recorded path or its preserved
|
||||||
|
filename within the selected or recognized source data roots. It supports the
|
||||||
|
original flat layout and older device-name subdirectories. Missing files,
|
||||||
|
multiple distinct matches, parent traversal, and symlinks escaping a selected
|
||||||
|
root are errors. Choose an unambiguous `--backup-root` when necessary; no source
|
||||||
|
file is silently skipped.
|
||||||
|
|
||||||
|
## What is preserved
|
||||||
|
|
||||||
|
The import creates a new database using the current schema and copies supported
|
||||||
|
records logically from a private source snapshot. It preserves:
|
||||||
|
|
||||||
|
- User, device, backup, settings, and operation-log IDs; device ownership.
|
||||||
|
- Existing password hashes, usernames, preferences, timestamps, and backup metadata.
|
||||||
|
- RouterOS/SwitchOS connection information, per-device credentials, and backup policies.
|
||||||
|
- Global settings, schedules, SSH keys, SwitchOS credentials, SMTP settings/secrets,
|
||||||
|
and Pushover settings/secrets stored in SQLite.
|
||||||
|
- Every referenced backup's bytes, original `file_name`, and existing checksum.
|
||||||
|
|
||||||
|
Fields absent in an older supported Python schema receive current defaults in the
|
||||||
|
new database only. A missing old `file_name` is derived from its recorded path.
|
||||||
|
Missing/null historical timestamps use the current schema's default when that
|
||||||
|
older schema did not store a usable value. A missing password hash, orphaned
|
||||||
|
ownership, or unsupported source structure causes an error; import never assigns
|
||||||
|
an arbitrary owner or replaces a missing password with an administrator password.
|
||||||
|
An empty source settings table receives the normal default settings row.
|
||||||
|
|
||||||
|
Imported SwitchOS records have `use_global_switchos_credentials = null` when the
|
||||||
|
Python schema has no explicit source selector. This preserves the former per-field
|
||||||
|
inheritance. Choosing global or device credentials later uses a complete pair
|
||||||
|
from that source without deleting saved device credentials. Secrets are not
|
||||||
|
returned in the public API or prefilled into the interface.
|
||||||
|
|
||||||
|
Passlib PBKDF2-SHA256 and supported Werkzeug PBKDF2 hashes remain usable without a
|
||||||
|
reset. New and changed passwords use Argon2id. The source environment's JWT secret
|
||||||
|
is not copied. Unless `SECRET_KEY` is explicitly configured for the new server,
|
||||||
|
its first startup creates a new `DATA_DIR/secrets/jwt.key`, and old sessions must
|
||||||
|
log in again. The import itself does not generate a JWT key.
|
||||||
|
|
||||||
|
## Destination layout and publication
|
||||||
|
|
||||||
|
The destination must be separate from the source application and source backup
|
||||||
|
roots. Its database must not exist, and its `backups/` tree must contain no existing
|
||||||
|
files. Existing empty directories, the new configuration's private files, and
|
||||||
|
storage locks do not authorize overwriting a database or backup. Relative paths,
|
||||||
|
parent components, and existing symlinks are resolved before any directory or lock
|
||||||
|
is created, using the same normalized paths for validation and subsequent writes.
|
||||||
|
|
||||||
|
Imported and newly created files use stable numeric device IDs:
|
||||||
|
|
||||||
|
| Backup type | Directory relative to `DATA_DIR` |
|
||||||
|
| --- | --- |
|
||||||
|
| RouterOS or SwitchOS binary | `backups/binary/<device-id>/` |
|
||||||
|
| RouterOS text export | `backups/exports/<device-id>/` |
|
||||||
|
|
||||||
|
An imported physical filename receives the backup ID as a prefix. Its metadata
|
||||||
|
`file_name` remains unchanged for downloads and RouterOS uploads. Names are bounded
|
||||||
|
and published without replacing existing files or symlinks. Renaming a device does
|
||||||
|
not rename its numeric directory. Database paths to these files are relative to
|
||||||
|
`DATA_DIR`.
|
||||||
|
|
||||||
|
The importer reads the stopped source database and any `-wal` or `-journal` as
|
||||||
|
ordinary files, copying them into private staging. It compares metadata and SHA-256
|
||||||
|
before and after copying and refuses a detected change. SQLite opens only this
|
||||||
|
private copy and includes committed WAL records. The source `-shm` is neither opened
|
||||||
|
by SQLite nor recreated; staging reconstructs its own coordination files when
|
||||||
|
needed. Keep the source stopped throughout the operation, because these checks
|
||||||
|
are not a substitute for coordinating concurrent writers.
|
||||||
|
|
||||||
|
All source rows and backup files are validated before the target database is
|
||||||
|
published. Every copied file is checked against its recorded SHA-256 when present
|
||||||
|
and against the copy's calculated SHA-256. Publication uses private temporary
|
||||||
|
files, file synchronization, atomic no-clobber publication, and directory
|
||||||
|
synchronization. The destination database is published last.
|
||||||
|
|
||||||
|
Before database publication, error cleanup makes a best-effort attempt to remove
|
||||||
|
files recorded as installed by that attempt; source files remain unchanged. If
|
||||||
|
directory synchronization fails after database publication, the database and its
|
||||||
|
referenced backups are retained, and the error asks the operator to inspect the
|
||||||
|
destination. An interrupted process or filesystem I/O failure can leave staging
|
||||||
|
or unreferenced files: inspect or choose
|
||||||
|
another new destination instead of expecting automatic startup recovery. There is
|
||||||
|
no migration journal or automatic resumption framework.
|
||||||
|
|
||||||
|
The CLI prints a JSON summary with the selected source database and counts of
|
||||||
|
users, devices, backup records, copied files, and logs. It never prints saved
|
||||||
|
passwords or keys. Check that report, then start the new service, inspect device
|
||||||
|
lists, preview an export, and download representative binary and text files.
|
||||||
|
Do not run both installations' schedules for the same devices during cutover.
|
||||||
|
|
||||||
|
## Rollback and operational limits
|
||||||
|
|
||||||
|
The original installation remains the rollback source. To return to it, stop the
|
||||||
|
new service and restart the old application on its unchanged data. New backups
|
||||||
|
created after cutover exist only in the new installation; preserve them and its
|
||||||
|
database before discarding or reverting anything. Do not exchange a SQLite main
|
||||||
|
file under a running process or mix unrelated main/WAL/journal files.
|
||||||
|
|
||||||
|
The importer does not copy operating-system permissions, service definitions,
|
||||||
|
external environment secrets, or SSH host-trust files. Review the new environment
|
||||||
|
configuration and host-key policy before first device contact. Imported SQLite
|
||||||
|
secrets retain their original representation; target database/secret files use
|
||||||
|
private permissions, and deployment storage must protect their contents.
|
||||||
|
|
||||||
|
Regression tests cover fresh initialization/restart, rejection without source
|
||||||
|
changes, explicit path selection and ambiguity, record/secret preservation, new
|
||||||
|
file layout, checksums, locks, symlinks, committed WAL data, and source-sidecar
|
||||||
|
immutability. Tests use generated fixtures; no production database or physical
|
||||||
|
MikroTik was supplied. Current verification results are recorded separately in
|
||||||
|
[validation.md](validation.md).
|
||||||
|
|
||||||
|
## Import with Docker Compose only
|
||||||
|
|
||||||
|
If the host does not have a Rust toolchain, run the importer from the same image as
|
||||||
|
the server. Stop the old application and make sure the new application's target
|
||||||
|
volume does not already contain a database. Mount the legacy project read-only:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose build app
|
||||||
|
docker compose run --rm \
|
||||||
|
-v /srv/old-mikrotik-app:/legacy:ro \
|
||||||
|
app import-python \
|
||||||
|
--source /legacy \
|
||||||
|
--database docker-data/db/routeros_backup_next.db \
|
||||||
|
--backup-root docker-data
|
||||||
|
```
|
||||||
|
|
||||||
|
After the importer prints a successful JSON report, start the service:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
If the legacy directory uses a different layout, change only the source paths passed
|
||||||
|
to `--database` and `--backup-root`. The target data remains in the new `/data`
|
||||||
|
volume.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Importing data from the Python/TypeScript release
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
For the standard layout of the supplied legacy project:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DATA_DIR="$PWD/storage-imported" \
|
||||||
|
DATABASE_PATH="$PWD/storage-imported/routeros_backup_next.db" \
|
||||||
|
./import-python.sh /srv/old-mikrotik-app \
|
||||||
|
--database docker-data/db/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.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"checked_at_utc": "2026-10-27T21:39:43.856005+00:00",
|
||||||
|
"tool": "cargo-audit 0.22.2",
|
||||||
|
"lockfile": "Cargo.lock",
|
||||||
|
"lockfile_sha256": "341839930e9b231b548dbd1dbbc8e497531ff9c4903b3cf07722f3d9f024e2d8",
|
||||||
|
"command": "cargo audit --file Cargo.lock --json",
|
||||||
|
"advisory_database": {
|
||||||
|
"advisory-count": 1380,
|
||||||
|
"last-commit": "99670cfc0bc574433034b75876a4b791ccaa2efb",
|
||||||
|
"last-updated": "2026-10-27T11:02:42+01:00"
|
||||||
|
},
|
||||||
|
"report_sha256": "49e44f55fdaf3e2aacd358d312489882a91147f8790a81348ffa3f9fd76e6634",
|
||||||
|
"note": "Retained advisory scan metadata. No ignored advisories; vendored C backports are summarized in security.md."
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"database":{"advisory-count":1380,"last-commit":"99670cfc0bc574433034b75876a4b791ccaa2efb","last-updated":"2026-10-27T11:02:42+01:00"},"lockfile":{"dependency-count":270},"settings":{"target_arch":[],"target_os":[],"severity":null,"ignore":[],"informational_warnings":["unmaintained","unsound","notice"]},"vulnerabilities":{"found":false,"count":0,"list":[]},"warnings":{}}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Dependency review — release 0.0.1
|
||||||
|
|
||||||
|
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
|
||||||
|
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).
|
||||||
|
A scanner result is limited to its advisory database and examined versions and does
|
||||||
|
not establish the absence of every vulnerability.
|
||||||
|
|
||||||
|
## RustSec and dependency changes
|
||||||
|
|
||||||
|
The recorded run used **cargo-audit 0.22.2** and a RustSec database containing
|
||||||
|
**1,380 advisories**, at commit `99670cfc0bc574433034b75876a4b791ccaa2efb`, last updated
|
||||||
|
`2026-10-27T11:02:42+01:00`. The audited dependency set produced **0 reported
|
||||||
|
vulnerabilities and 0 warnings**. The complete JSON result is preserved in
|
||||||
|
[`dependency-audit.json`](dependency-audit.json); the lockfile identity is recorded in
|
||||||
|
[`dependency-audit-metadata.json`](dependency-audit-metadata.json).
|
||||||
|
|
||||||
|
The original final revision 2 scan JSON is unchanged. Its `last-commit` and
|
||||||
|
`last-updated` fields contain the database revision and timestamp above. Metadata
|
||||||
|
records the audit time, tool version, report SHA-256, and the audited `Cargo.lock`
|
||||||
|
SHA-256: `341839930e9b231b548dbd1dbbc8e497531ff9c4903b3cf07722f3d9f024e2d8`.
|
||||||
|
|
||||||
|
To repeat the audit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo audit --file Cargo.lock --json
|
||||||
|
```
|
||||||
|
|
||||||
|
No `--ignore` exceptions were used. The scan did not modify the manifest or lockfile.
|
||||||
|
|
||||||
|
| Dependency | Release version | Reason |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `zip` | 9.1.2 | Replaced 2.6.1, affected by RUSTSEC-2026-0367, -0368, and -0369. The application creates archives rather than invoking the affected extraction functions, but the release still uses the corrected library. |
|
||||||
|
| `jsonwebtoken` | 11.1.0 with `aws_lc_rs` | Replaced 9.3.1, affected by CVE-2026-25537. Corrected validation rejects invalid date types; additional application tests cover `exp`, `nbf`, algorithm, signature, and compatibility with Python HS256 tokens. |
|
||||||
|
| `tokio` | 1.54.1 | Includes fixes for RUSTSEC-2026-0384 and RUSTSEC-2026-0386. |
|
||||||
|
| `rustls-webpki` | 0.103.16 | Uses the corrected version assessed by the recorded RustSec database. |
|
||||||
|
| `ssh2` / `libssh2-sys` | 0.9.6 / locally patched 0.3.3 | Adds missing fixes to the bundled C code, described below. |
|
||||||
|
|
||||||
|
Sources: [RustSec 0367](https://rustsec.org/advisories/RUSTSEC-2026-0367.html),
|
||||||
|
[0368](https://rustsec.org/advisories/RUSTSEC-2026-0368.html),
|
||||||
|
[0369](https://rustsec.org/advisories/RUSTSEC-2026-0369.html),
|
||||||
|
[jsonwebtoken maintainer advisory](https://github.com/Keats/jsonwebtoken/security/advisories/GHSA-h395-gr6q-cpjc),
|
||||||
|
[RustSec 0384](https://rustsec.org/advisories/RUSTSEC-2026-0384.html), and
|
||||||
|
[0386](https://rustsec.org/advisories/RUSTSEC-2026-0386.html).
|
||||||
|
|
||||||
|
## Local API documentation assets — revision 3
|
||||||
|
|
||||||
|
Swagger UI comes from the pinned official `swagger-ui-dist 5.34.0` package. The
|
||||||
|
archive's integrity was checked against its published npm SHA-512 value. The retained
|
||||||
|
files' SHA-256 values and source are recorded in `vendor/swagger-ui/PROVENANCE.json`.
|
||||||
|
The license, NOTICE, and embedded-library license information are retained. Neither
|
||||||
|
the application nor the browser fetches these assets from a CDN.
|
||||||
|
|
||||||
|
The documentation loads only its local specification. The online validator,
|
||||||
|
query-string configuration, and persistent authorization are disabled. A same-origin
|
||||||
|
request interceptor and CSP restrict requests. The RustSec audit above covers Rust
|
||||||
|
dependencies; it is not a JavaScript security scan of this package. See
|
||||||
|
[API documentation integration](api-documentation.md) for provenance and behavior.
|
||||||
|
Revision 4 changes how these checked-in files are embedded, without replacing the
|
||||||
|
vendored Swagger UI files.
|
||||||
|
|
||||||
|
## Bundled libssh2 fixes
|
||||||
|
|
||||||
|
A current crate version alone was insufficient. Published `libssh2-sys 0.3.3`
|
||||||
|
contained a libssh2 1.11.1 base with four earlier backports, but omitted later fixes.
|
||||||
|
Specific functions were compared against upstream libssh2 patches. This confirmed
|
||||||
|
missing AES-GCM bounds checks, short ETM packet checks, and the other corrections
|
||||||
|
listed below. The application uses the affected encryption negotiation and SFTP
|
||||||
|
code, so the release includes code fixes rather than only documenting the findings.
|
||||||
|
|
||||||
|
`vendor/libssh2-sys` contains the original sources needed for compilation, original
|
||||||
|
licenses, preserved upstream patches, their SHA-256 values, and a patch reproducing
|
||||||
|
the changes. Cargo uses this local dependency. Compilation always uses the patched
|
||||||
|
code; it does not silently substitute a system libssh2.
|
||||||
|
|
||||||
|
| Advisory | Fix scope | Upstream source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| CVE-2026-7598 | Username and authentication data lengths | [256d04b60d80](https://github.com/libssh2/libssh2/commit/256d04b60d80bf1190e96b0ad1e91b2174d744b1) |
|
||||||
|
| CVE-2026-66032 | Double-free of an SFTP file-open response | [5e4776146552](https://github.com/libssh2/libssh2/commit/5e4776146552d898b9c0e1b313cd093fa8dc92d0) |
|
||||||
|
| CVE-2026-66033 | AES-GCM buffer bounds and release-build length calculation | [a2ed82d40964](https://github.com/libssh2/libssh2/commit/a2ed82d40964bbc0d64cd717aa0a5a892117d2e6) |
|
||||||
|
| CVE-2026-66034 | Comment length in a publickey subsystem response | [a13bb6c773f0](https://github.com/libssh2/libssh2/commit/a13bb6c773f0d55ad1628cede57e99803cd898d9) |
|
||||||
|
| CVE-2026-66035 | Minimum ETM packet length before decryption | [42e33d81577e](https://github.com/libssh2/libssh2/commit/42e33d81577ed4b95d4b4f6f845e5ee8efe5eeb4) |
|
||||||
|
| CVE-2026-58050 | Publickey attribute limit before allocation | [34497525929b](https://github.com/libssh2/libssh2/commit/34497525929b9a47f03dfb81887ac896202b7e12) |
|
||||||
|
| CVE-2026-58051 | Publickey list entry initialization before cleanup | [a9758da45a52](https://github.com/libssh2/libssh2/commit/a9758da45a52bc8c630ec9493804d0c6ea30b24a) |
|
||||||
|
|
||||||
|
Two upstream follow-up fixes for freeing partially created attributes and initializing
|
||||||
|
the list tail are also included. Full commit IDs, private-function name adaptations,
|
||||||
|
and reproduction instructions are in
|
||||||
|
[`PROVENANCE.md`](../vendor/libssh2-sys/PROVENANCE.md). Distribution notices
|
||||||
|
[USN-8722-1](https://ubuntu.com/security/notices/USN-8722-1) and
|
||||||
|
[USN-8799-1](https://ubuntu.com/security/notices/USN-8799-1) independently describe
|
||||||
|
these advisory families.
|
||||||
|
|
||||||
|
## Verification and limits
|
||||||
|
|
||||||
|
The patch was reproduced on a fresh copy of the published crate; all seven modified
|
||||||
|
files matched the bundled code byte for byte. `vendor/libssh2-sys/SHA256SUMS` supports
|
||||||
|
integrity checks of the included files.
|
||||||
|
|
||||||
|
A separate C regression program in the source tree invokes the same static library
|
||||||
|
with invalid AES-GCM lengths and overflowing username lengths. Its execution
|
||||||
|
instructions are in `PROVENANCE.md`. Actual build, Rust-test, C-test, and SSH-session
|
||||||
|
results are recorded by revision in [the validation report](validation.md).
|
||||||
|
|
||||||
|
RustSec checks Rust dependency identities and versions. It does not replace C code
|
||||||
|
review, container image scanning, or updates to system OpenSSL and zlib libraries.
|
||||||
|
Local SSH-server tests exercise the real protocol, but do not reproduce every attack
|
||||||
|
family or establish compatibility with every physical MikroTik model. Dependency
|
||||||
|
updates require a new scan, relevant tests, and comparison of these backports with
|
||||||
|
the newer upstream release.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## What is imported
|
||||||
|
|
||||||
|
The importer copies supported data from:
|
||||||
|
|
||||||
|
- `users`;
|
||||||
|
- `routers`;
|
||||||
|
- `backups`;
|
||||||
|
- `operation_logs`;
|
||||||
|
- `global_settings`.
|
||||||
|
|
||||||
|
Existing IDs, password hashes, credentials, timestamps, settings, and saved language/font preferences are retained when represented by the current schema.
|
||||||
|
|
||||||
|
Every backup record must resolve to exactly one physical source file. Referenced `.rsc`, RouterOS binary backups, and SwOS backup files are copied into the new layout. Existing SHA-256 values are verified when present. The resulting report includes both `backups` and `files_copied`; for a complete import they are equal.
|
||||||
|
|
||||||
|
Files that exist in an old backup directory but have **no record in the old `backups` table** are not imported.
|
||||||
|
|
||||||
|
## Before importing
|
||||||
|
|
||||||
|
1. Stop the old application so its SQLite database and WAL/journal files are stable.
|
||||||
|
2. Choose a new/empty Rust `DATA_DIR` or `DATABASE_PATH`.
|
||||||
|
3. Keep the source installation read-only if possible.
|
||||||
|
|
||||||
|
The importer refuses to merge into an existing destination database and refuses a destination inside the source tree.
|
||||||
|
|
||||||
|
## Recommended command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./import-python.sh /srv/mikrotik-backup-old
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent direct command after building:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/mikrotik-backup import-python \
|
||||||
|
--source /srv/mikrotik-backup-old
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm \
|
||||||
|
-v /srv/mikrotik-backup-old:/legacy:ro \
|
||||||
|
app import-python --source /legacy
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the same destination volume/environment later with `docker compose up -d`.
|
||||||
|
|
||||||
|
## Source discovery
|
||||||
|
|
||||||
|
The importer reads plain path values from `.env` or `backend/.env` when available and also checks common paths from the previous application:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docker-data/routeros_backup_next.db
|
||||||
|
docker-data/db/routeros_backup_next.db
|
||||||
|
storage/routeros_backup_next.db
|
||||||
|
storage/db/routeros_backup_next.db
|
||||||
|
backend/storage/routeros_backup_next.db
|
||||||
|
backend/storage/db/routeros_backup_next.db
|
||||||
|
```
|
||||||
|
|
||||||
|
If multiple databases exist, select one explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./import-python.sh /srv/mikrotik-backup-old \
|
||||||
|
--database docker-data/db/routeros_backup_next.db
|
||||||
|
```
|
||||||
|
|
||||||
|
If backup files live outside the detected data directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./import-python.sh /srv/mikrotik-backup-old \
|
||||||
|
--backup-root /absolute/path/to/old/backups
|
||||||
|
```
|
||||||
|
|
||||||
|
Paths supplied to `--database` and `--backup-root` may be absolute or relative to `--source`.
|
||||||
|
|
||||||
|
## Failure behavior
|
||||||
|
|
||||||
|
Import stops without publishing the new database when, for example:
|
||||||
|
|
||||||
|
- the source database is missing, ambiguous, corrupt, or is already a Rust database;
|
||||||
|
- a required source table/field is missing;
|
||||||
|
- a backup record has no matching file;
|
||||||
|
- more than one candidate file matches a backup record;
|
||||||
|
- a stored checksum does not match the source file;
|
||||||
|
- the destination database already exists.
|
||||||
|
|
||||||
|
The original source is never modified. The source SQLite database is first copied to private staging storage before records are read.
|
||||||
|
|
||||||
|
## After import
|
||||||
|
|
||||||
|
Check the JSON report. In particular:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backups": 152,
|
||||||
|
"files_copied": 152
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then start the Rust application with the same destination configuration and verify at least one device connection, one text export, one binary/SwOS backup, and file download.
|
||||||
+7246
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,222 @@
|
|||||||
|
# Port coverage matrix — 0.0.1
|
||||||
|
|
||||||
|
## Scope and source identity
|
||||||
|
|
||||||
|
The supplied source code, rather than the interface concept alone, defines functional
|
||||||
|
coverage. The archive contains no `.git` metadata, so the supplied archive SHA-256
|
||||||
|
values identify the reviewed inputs:
|
||||||
|
|
||||||
|
| Archive | SHA-256 |
|
||||||
|
| --- | --- |
|
||||||
|
| `mikrotik_backup_system-master.zip` | `3be24e72b1816070a78ec7ee7336ae0a7f884db070dc019a8afdd9c229c05f47` |
|
||||||
|
| `mikrotik_backup_system_rust_migration.zip` | `65e4ee36cd60e81e4ff25a6d12d5fd7913b9360b61c96b7301b3de550c00a104` |
|
||||||
|
|
||||||
|
The source has **36 active API method/path combinations**, all represented in the
|
||||||
|
Axum router. Paths below use the default `/api` prefix; `API_PREFIX` remains
|
||||||
|
configurable.
|
||||||
|
|
||||||
|
The test column identifies source tests and their intended coverage. A test's
|
||||||
|
presence is not proof of a successful run. The revision-specific
|
||||||
|
[validation report](validation.md) records actual test, interface, build, and process
|
||||||
|
checks. Controlled fixture servers exercise protocols without contacting production
|
||||||
|
devices. No physical MikroTik or live Pushover account was supplied.
|
||||||
|
|
||||||
|
## Contract test key
|
||||||
|
|
||||||
|
These test names are in `tests/api_contract.rs`.
|
||||||
|
|
||||||
|
| Key | Full test name |
|
||||||
|
| --- | --- |
|
||||||
|
| A | `auth_json_form_preferences_password_and_expiry` |
|
||||||
|
| R | `registration_and_login_rate_limit` |
|
||||||
|
| D | `devices_defaults_validation_secrets_and_disabled_operations` |
|
||||||
|
| O | `ownership_applies_to_every_device_and_backup_operation` |
|
||||||
|
| F | `files_filters_preview_diff_download_zip_and_bulk_deletion` |
|
||||||
|
| S | `settings_secrets_validation_reveal_scheduler_and_notification_errors` |
|
||||||
|
| L | `logs_retention_and_embedded_web_do_not_expose_storage` |
|
||||||
|
|
||||||
|
## API operations
|
||||||
|
|
||||||
|
| Method and path | Rust file / handler | Test and coverage |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /api/auth/register` | `src/api/auth.rs` / `register` | R: registration disabled/enabled, conflicts, validation |
|
||||||
|
| `POST /api/auth/login` | `src/api/auth.rs` / `login` | A: JSON and form; R: failed-login throttling |
|
||||||
|
| `GET /api/auth/me` | `src/api/auth.rs` / `me` | A: account, missing/invalid token, no password hash in JSON |
|
||||||
|
| `PUT /api/auth/preferences` | `src/api/auth.rs` / `preferences` | A: four languages, font, unsupported values |
|
||||||
|
| `POST /api/auth/change-password` | `src/api/auth.rs` / `change_password` | A: current password, new login, rejection of old password |
|
||||||
|
| `GET /api/dashboard` | `src/api/system.rs` / `dashboard` | O: owner-scoped counts; F: counts updated after deletion |
|
||||||
|
| `GET /api/routers` | `src/api/devices.rs` / `list` | O: user list isolation |
|
||||||
|
| `GET /api/routers/ping-statuses` | `src/api/devices.rs` / `ping` | D: disabled flag; ICMP tests in `routeros.rs` |
|
||||||
|
| `POST /api/routers` | `src/api/devices.rs` / `create` | D: RouterOS/SwOS, default ports, names, secrets |
|
||||||
|
| `GET /api/routers/{id}` | `src/api/devices.rs` / `get_one` | D: metadata and flags; O: another user's record |
|
||||||
|
| `PUT /api/routers/{id}` | `src/api/devices.rs` / `update` | D: validation, flags, secret preservation/clearing; O |
|
||||||
|
| `DELETE /api/routers/{id}` | `src/api/devices.rs` / `remove` | D: deletion and 404; O: ownership protection |
|
||||||
|
| `GET /api/routers/{id}/test-connection` | `src/api/devices.rs` / `test_connection` | O: authorization; local protocol fixtures in `routeros.rs` |
|
||||||
|
| `GET /api/backups` | `src/api/backups.rs` / `list` | F: type, device, day, search, sorting; O |
|
||||||
|
| `GET /api/backups/router/{id}` | `src/api/backups.rs` / `for_router` | O: device authorization |
|
||||||
|
| `POST /api/backups/routers/export-all` | `src/api/backups.rs` / `export_all` | D: disabled devices skipped; `backup::run_all` returns per-device results |
|
||||||
|
| `POST /api/backups/routers/binary-all` | `src/api/backups.rs` / `binary_all` | D: disabled devices skipped; `backup::run_all` returns per-device results |
|
||||||
|
| `POST /api/backups/router/{id}/export` | `src/api/backups.rs` / `export` | D: policies and unsupported SwOS export; O: ownership; SSH adapter results in validation report |
|
||||||
|
| `POST /api/backups/router/{id}/binary` | `src/api/backups.rs` / `binary` | D: policies; O: ownership; SwOS fixture and SSH adapter checks |
|
||||||
|
| `POST /api/backups/router/{id}/upload/{backup_id}` | `src/api/backups.rs` / `upload` | O: both resources owned; SSH/SFTP adapter check; upload does not restore |
|
||||||
|
| `DELETE /api/backups/{id}` | `src/api/backups.rs` / `remove` | F: file and metadata deletion; O: ownership |
|
||||||
|
| `GET /api/backups/{id}/download` | `src/api/backups.rs` / `download` | F: exact binary bytes and Content-Type; O |
|
||||||
|
| `GET /api/backups/{id}/view` | `src/api/backups.rs` / `view` | F: export text and rejection of binary content; O |
|
||||||
|
| `POST /api/backups/{id}/email` | `src/api/backups.rs` / `email` | O: ownership; `smtp_delivery_with_attachment_against_local_fixture` for SMTP |
|
||||||
|
| `GET /api/backups/{left}/diff/{right}` | `src/api/backups.rs` / `diff_json` | F: statistics/text and rejection of binary content; O: both resources |
|
||||||
|
| `GET /api/backups/{left}/diff/{right}/html` | `src/api/backups.rs` / `diff_html` | F: HTML; O; `escapes_configuration_and_file_names` |
|
||||||
|
| `POST /api/backups/bulk` | `src/api/backups.rs` / `bulk` | F: ZIP, no partial mutation for an invalid ID, deduplication; O |
|
||||||
|
| `GET /api/settings` | `src/api/system.rs` / `settings` | `settings_secrets_round_trip_without_public_exposure`; S: saved response |
|
||||||
|
| `GET /api/settings/scheduler-status` | `src/api/system.rs` / `scheduler_status` | S: timezone and five jobs; cron/DST tests in `scheduler.rs` |
|
||||||
|
| `PUT /api/settings` | `src/api/system.rs` / `update_settings` | S: cron, preserve/clear secrets, invalid input rejected before save |
|
||||||
|
| `POST /api/settings/reveal-ssh-key` | `src/api/system.rs` / `reveal_ssh_key` | S: incorrect and correct account password |
|
||||||
|
| `POST /api/settings/test-email` | `src/api/system.rs` / `test_email` | S: configuration errors; local SMTP provider fixture |
|
||||||
|
| `POST /api/settings/test-pushover` | `src/api/system.rs` / `test_pushover` | S and `missing_pushover_settings_fail_before_network`; no live account delivery |
|
||||||
|
| `GET /api/logs` | `src/api/system.rs` / `logs` | L: limit; original id/message/timestamp model |
|
||||||
|
| `DELETE /api/logs/older-than/{days}` | `src/api/system.rs` / `delete_logs` | L: validation and actual removal of old records |
|
||||||
|
| `GET /api/health` | `src/api/system.rs` / `health` | L: public health check, version, configurable prefix |
|
||||||
|
|
||||||
|
Backup list parameters are `search`, `backup_type=export|binary`, `router_id`,
|
||||||
|
`created_on=YYYY-MM-DD`, `sort_by=created_at|file_name|backup_type|router_name`, and
|
||||||
|
`order=asc|desc`. Logs retain `limit` in the range 1–500. Bulk operations accept
|
||||||
|
`action=delete|download` and `backup_ids`. Login accepts `username`/`password`;
|
||||||
|
password change uses `current_password`/`new_password`; SSH key reveal uses
|
||||||
|
`password`; preferences use `preferred_language`/`preferred_font`. A login response
|
||||||
|
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.
|
||||||
|
|
||||||
|
| Model / group | Preserved or added fields | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| User | `id`, `username`, `password_hash`, `created_at`, `preferred_language`, `preferred_font` | Password hashes are never serialized. The user DTO has the same four public fields as the source. |
|
||||||
|
| Router identity | `id`, `owner_id`, `name`, `device_type`, `host`, `port`, `ssh_user`, `created_at` | Every device/backup access checks ownership. |
|
||||||
|
| Router credentials | `ssh_key`, `ssh_password` | Public responses contain null and presence flags; values remain stored in the database. |
|
||||||
|
| Router SwitchOS source | `use_global_switchos_credentials` | Nullable addition: true selects the global pair, false selects the local pair, null preserves legacy fallback. Changing mode preserves local secrets. |
|
||||||
|
| Router connection result | `last_connection_status`, `last_connection_tested_at`, `last_connection_error`, `last_connection_hostname`, `last_connection_model`, `last_connection_version`, `last_connection_uptime`, `last_connection_transport`, `last_connection_server`, `last_connection_auth_mode`, `last_connection_http_status`, `last_connection_backup_available` | Unknown values remain null; errors are not presented as success. |
|
||||||
|
| Router policies | `disable_all_backups`, `disable_export_backups`, `disable_binary_backups`, `disable_ping` | Enforced server-side, including scheduled and batch operations. |
|
||||||
|
| Router DTO | `effective_username`, `uses_global_ssh_key`, `has_effective_ssh_key`, `uses_global_switchos_credentials`, `has_effective_password`, `has_own_password`, `supports_export`, `supports_restore_upload` | Derived from the selected credential source and device type without disclosing secrets. |
|
||||||
|
| RouterTestConnection | `success`, `tested_at`, `model`, `uptime`, `hostname`, `version`, `error`, `transport`, `server`, `auth_mode`, `http_status`, `backup_available` | The same result is persisted in the device's last_connection fields. |
|
||||||
|
| RouterPingStatus | `router_id`, `reachable`, `latency_ms`, `disabled`; optional `error` | Batch response contains `items`. Diagnostic errors distinguish missing ICMP permissions from an ordinary timeout. |
|
||||||
|
| Backup | `id`, `router_id`, `file_path`, `file_name`, `backup_type`, `checksum`, `created_at` | Additional DTO fields: `router_name`, `device_type`, `file_size`. Import copies existing bytes without converting their format. |
|
||||||
|
| OperationLog | `id`, `message`, `timestamp` | No invented level/component/router_id fields. |
|
||||||
|
| Dashboard | `routers_count`, `export_count`, `binary_count`, `total_backups`, `storage`, `recent_logs` | Device/backup counts are owner-scoped; logs remain global as in the source. |
|
||||||
|
| StorageStats | `total`, `used`, `free`, `folder_used`, `usage_percent` | `usage_percent` measures the data directory against total disk capacity, matching the source. |
|
||||||
|
| Settings retention | `backup_retention_days`, `log_retention_days` | 0 means an age threshold of zero days, matching the original retention function; it does not disable cleanup. |
|
||||||
|
| Settings 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 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. |
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
## Client views and behavior
|
||||||
|
|
||||||
|
| Source view / behavior | Implementation |
|
||||||
|
| --- | --- |
|
||||||
|
| `/login`, `/register`, session restoration, auth/guest guards, Bearer token | `web/js/core.js`, `web/js/views.js`; actual auth API |
|
||||||
|
| `/` dashboard | API counts, storage, recent backups/logs, and batch actions |
|
||||||
|
| `/devices`, alias `/routers` | List, search, device state, create, and edit |
|
||||||
|
| `/devices/:id`, alias `/routers/:id` | Connection test, metadata, and backups; no RSC exports or diff for SwitchOS; comparison only for RouterOS text exports |
|
||||||
|
| `/files` | Filters, sorting, selection, ZIP, deletion, preview, email, and diff |
|
||||||
|
| `/diff-configs` | Two exports, counts, line numbers, side-by-side/unified views, and search |
|
||||||
|
| `/logs` | Original messages and timestamps, cleanup by age in days |
|
||||||
|
| `/settings` | Retention, cron, global credentials, SMTP/Pushover, tests, and next runs |
|
||||||
|
| `/settings?tab=automation`, alias `/schedule` | Flat schedule controls; no separate Schedule navigation item |
|
||||||
|
| `/settings?tab=account`, alias `/change-password` | Password change in account settings; old route redirects to that tab |
|
||||||
|
| `/docs`, `/openapi.json`, alias `/redoc` | Local Swagger UI, full contract, authentication, and real API requests |
|
||||||
|
| Language and font | `pl`, `en`, `es`, `no`; `default`, `adwaita_mono`, `hack`; persisted user preferences |
|
||||||
|
| Theme, sidebar, responsive layout, toasts, confirmations | `web/css/app.css`, `web/js/core.js`, `web/js/views.js`; no sample devices inserted into the database |
|
||||||
|
| CSS/JavaScript delivery | Modular `web/` sources bundled into one `app.css` and one `app.js`, embedded in the executable |
|
||||||
|
|
||||||
|
Default font families preserve the source stacks: Inter/system UI for content and
|
||||||
|
Roboto Mono/IBM Plex Mono/SFMono-Regular/Consolas for headings. Adwaita Mono and Hack
|
||||||
|
preferences select their respective stacks. Specific fonts depend on what is
|
||||||
|
installed on the client device.
|
||||||
|
|
||||||
|
## Services, configuration, and data
|
||||||
|
|
||||||
|
| Area | Implementation | Tests / evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Argon2id and Python password compatibility | `security.rs` | `new_passwords_use_argon2id`, `verifies_python_passlib_fixture_without_reset`, `verifies_legacy_werkzeug_fixture`, `malformed_hashes_fail_closed` |
|
||||||
|
| JWT HS256, username subject, expiration, and nbf | `security.rs` | `jwt_has_original_username_subject`, `expired_and_wrong_algorithm_tokens_are_rejected`, `signed_malformed_or_missing_expiration_is_rejected`, `not_before_is_optional_but_must_be_valid_and_reached`, `verifies_independent_python_hmac_jwt_fixture` |
|
||||||
|
| Environment, SQLite URL, timezone, initial secret | `config.rs` | `generated_secret_survives_restart`, `accepts_legacy_sqlite_url_and_custom_prefix`, `rejects_unsafe_configuration` |
|
||||||
|
| Fresh schema and existing-schema validation | `db.rs`, `src/schema.sql` | Fresh initialization and incompatible-schema rejection regressions in `db.rs`; `generated_bootstrap_password_is_saved_and_valid` |
|
||||||
|
| Explicit database/file import | `db.rs` | `import_copies_files_and_preserves_source`, `failed_import_does_not_publish_partial_database`, `orphaned_legacy_backups_are_not_silently_skipped`, `import_rejects_corrupted_backup_before_publication`, `import_rejects_backup_symlink_outside_selected_root` |
|
||||||
|
| Import exclusivity and atomic publication | `db.rs` | `import_obeys_storage_lock_and_keeps_source_untouched`, `atomic_copy_publication_refuses_existing_destination` |
|
||||||
|
| Import wrapper | `import-python.sh`, `tests/import_launcher.rs` | `help_and_invalid_sources_do_not_build_or_import`, `resolves_source_and_preserves_arguments_and_destination_settings`, `forwards_import_failure_without_falling_back_to_serve` |
|
||||||
|
| Secret redaction and preservation | `models.rs`, `db.rs` | `settings_keep_secrets_and_clear_explicitly`, `invalid_patch_is_atomic`, `generic_serialization_does_not_leak_secrets`, `settings_secrets_round_trip_without_public_exposure` |
|
||||||
|
| RouterOS SSH export/binary, SFTP/SCP, host keys, timeout | `routeros.rs`, `backup.rs` | Controlled SSH/SFTP server checks in the validation report; no physical MikroTik supplied |
|
||||||
|
| Native SSH library fixes | `vendor/libssh2-sys` | Patch reproduction and `tests/run-security-regressions.sh`; sources, hashes, and limits in [dependency-security.md](dependency-security.md) |
|
||||||
|
| SwOS through the existing device model | `routeros.rs` | `switchos_digest_probe_and_binary_transfer_against_local_fixture`, `switchos_basic_fallback_and_html_rejection` |
|
||||||
|
| ICMP ping | `routeros.rs` | `native_icmp_loopback_and_disabled_flag`, `icmp_checksum_verifies_packet`; non-root Linux requires configured `net.ipv4.ping_group_range` |
|
||||||
|
| Atomic files, checksums, directory boundaries, device locks | `backup.rs` | `checksum_matches_sha256`, `file_names_cannot_escape`, `deletion_allows_missing_file_but_rejects_escape`, `symlink_cannot_escape_data_directory`, `device_lock_releases_on_drop` |
|
||||||
|
| Python/Rust timestamp retention | `backup.rs` | `supports_python_and_rust_timestamps`, `retention_uses_age_and_zero_is_not_disabled` |
|
||||||
|
| Diff and escaped HTML | `diff.rs` | `reports_all_line_types`, `escapes_configuration_and_file_names`, `final_newline_and_crlf_are_normalized`, `tracks_blank_lines_in_replacements`, `unified_output_matches_python_difflib_golden_fixtures` against 32 Python difflib fixtures |
|
||||||
|
| Cron and DST | `scheduler.rs` | `validates_cron_and_ranges`, `monday_zero_matches_apscheduler`, `timezone_and_dst_preview`, `named_weekdays_and_last_day`, `rare_calendar_schedule_is_not_lost` |
|
||||||
|
| SMTP/Pushover/error redaction | `notifications.rs` | `smtp_delivery_with_attachment_against_local_fixture`, `missing_pushover_settings_fail_before_network`, `error_redaction_covers_global_secret_fields` |
|
||||||
|
|
||||||
|
All source environment variables are mapped: `APP_NAME`, `APP_ENV`, `SECRET_KEY`,
|
||||||
|
`JWT_ALGORITHM` (HS256 only), `ACCESS_TOKEN_EXPIRE_MINUTES`, `DATABASE_URL`, `DATA_DIR`,
|
||||||
|
`ALLOW_REGISTRATION`, `API_PREFIX`, `TIMEZONE`, `DEFAULT_ADMIN_USERNAME`,
|
||||||
|
`DEFAULT_ADMIN_PASSWORD`, `SMTP_STARTTLS`, `SMTP_TIMEOUT_SECONDS`, and `CORS_ORIGINS`.
|
||||||
|
`APP_PORT` controls the default port. `LISTEN_ADDR`, `DATABASE_PATH`,
|
||||||
|
`SSH_TIMEOUT_SECONDS`, `SSH_HOST_KEY_POLICY`, and `MAX_PARALLEL_BACKUPS` are explicit
|
||||||
|
deployment extensions.
|
||||||
|
|
||||||
|
## Intentional security and reliability changes
|
||||||
|
|
||||||
|
- No shared `admin/admin` password. A fresh installation generates an initial
|
||||||
|
password in a private file unless one is configured. Registration is disabled
|
||||||
|
by default and can be explicitly enabled with `ALLOW_REGISTRATION=true`.
|
||||||
|
- Public responses redact secrets. Blank/null secret input preserves the value;
|
||||||
|
explicit `clear_*` removes it. Upload and diff check ownership of both resources.
|
||||||
|
- SSH host keys are verified. Default `accept-new` permits the first connection,
|
||||||
|
records the key, and rejects changes. Explicit `strict` requires a trusted
|
||||||
|
known_hosts entry. A host key is distinct from a user's authentication private key.
|
||||||
|
- Directory and symlink checks protect backups. ZIP responses stream a temporary
|
||||||
|
file rather than buffering the complete archive in memory. Bulk downloads are
|
||||||
|
limited to 2 GiB, and email attachments to 25 MiB; exceeding a limit is an error.
|
||||||
|
- New backup filenames include a finer-grained timestamp and random suffix so
|
||||||
|
concurrent operations cannot overwrite each other. Imported names and contents
|
||||||
|
remain supported.
|
||||||
|
- External service failures return controlled HTTP 502 responses with secret values
|
||||||
|
redacted. A notification failure does not remove or invalidate a successful backup.
|
||||||
|
- Naive SQLite timestamps are presented as UTC ISO 8601, preserving the source's
|
||||||
|
actual time without relying on the browser timezone to interpret it.
|
||||||
|
- Structured diff corrects old blank-line numbering in replacement blocks: an empty
|
||||||
|
existing line is not treated as a missing line. Unified output is checked against
|
||||||
|
32 file-based fixtures generated by Python difflib.
|
||||||
|
- 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).
|
||||||
|
- Runtime is one Rust process with SQLite, scheduling, and an embedded interface.
|
||||||
|
Python, Node, Angular, and nginx are not required application processes.
|
||||||
|
|
||||||
|
## Concept image and inactive source code
|
||||||
|
|
||||||
|
| Item | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| Git integration / commits / synchronization | No source model, endpoint, or service exists; the concept screen is not represented as a working feature. |
|
||||||
|
| Arbitrary job CRUD | The source has system jobs controlled through settings; that behavior is preserved. |
|
||||||
|
| `/swos-beta/probe`, `/swos-beta/download` | The router was not included in `backend/app/api/__init__.py`; no new public beta API is activated. |
|
||||||
|
| Existing `switchos` type | Preserved: ordinary connection tests and binary backups invoke SwOS code through the original RouterService despite the inactive separate beta router. |
|
||||||
|
| Concept's restore action | The source only transfers a binary file to RouterOS. Upload is preserved without automatic restore or restart. |
|
||||||
|
| Invented network traffic or log levels | Views use actual API data rather than demo measurements or fields absent from the model. |
|
||||||
|
|
||||||
|
## Evidence limits
|
||||||
|
|
||||||
|
No production database, physical routers, device host keys, or provider accounts were
|
||||||
|
supplied. Local fixture checks do not establish compatibility with every firmware
|
||||||
|
version. For a deployment transition, follow the explicit
|
||||||
|
[import procedure](data-import.md), verify a connection to one real device, and
|
||||||
|
confirm that its backup can be downloaded. Starting the new server activates
|
||||||
|
schedules from the imported settings, so stop the old processes first. Release tests
|
||||||
|
do not automatically restore any device configuration.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Security and dependency notes
|
||||||
|
|
||||||
|
Version 0.0.1 is distributed as source and uses the dependency versions pinned in `Cargo.lock`.
|
||||||
|
|
||||||
|
Key controls include:
|
||||||
|
|
||||||
|
- Argon2id for new passwords while retaining verification of imported PBKDF2 hashes;
|
||||||
|
- HS256 JWT validation with bounded expiry;
|
||||||
|
- request body size/deadline limits and login throttling;
|
||||||
|
- escaped UI output and restrictive response security headers;
|
||||||
|
- redaction of configured secrets from device-operation errors;
|
||||||
|
- SSH host-key verification with `accept-new` or `strict` policy;
|
||||||
|
- private data/storage permissions and a single-writer storage lock;
|
||||||
|
- non-root Alpine container runtime with dropped Linux capabilities and a read-only root filesystem;
|
||||||
|
- local Swagger UI without CDN dependencies.
|
||||||
|
|
||||||
|
The project patches `libssh2-sys` locally for audited upstream fixes. Provenance is documented in `vendor/libssh2-sys/PROVENANCE.md`.
|
||||||
|
|
||||||
|
The retained advisory scan artifacts are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/dependency-audit.json
|
||||||
|
docs/dependency-audit-metadata.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Third-party license text is in `docs/THIRD_PARTY_NOTICES.txt`.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# SwOS backup support
|
||||||
|
|
||||||
|
SwOS/SwOS Lite devices are handled through their HTTP/HTTPS web interface rather than RouterOS SSH.
|
||||||
|
|
||||||
|
## Backup flow
|
||||||
|
|
||||||
|
The adapter:
|
||||||
|
|
||||||
|
1. normalizes the configured device URL;
|
||||||
|
2. probes the web interface;
|
||||||
|
3. obtains the authentication challenge when required;
|
||||||
|
4. attempts HTTP Digest authentication, then Basic authentication as fallback;
|
||||||
|
5. follows bounded same-device redirects and re-authenticates for the final URI when needed;
|
||||||
|
6. downloads `GET /backup.swb`;
|
||||||
|
7. rejects empty responses and obvious HTML/login/error pages before saving the `.swb` file.
|
||||||
|
|
||||||
|
Digest challenge responses are fully consumed and challenge cookies are preserved before retrying. `/backup.swb` starts with a fresh authentication state instead of reusing an unrelated homepage nonce.
|
||||||
|
|
||||||
|
## Connection test
|
||||||
|
|
||||||
|
The connection test reports web reachability separately from backup availability. A device can therefore be online while `/backup.swb` is unavailable or rejected.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
A SwOS device may use either:
|
||||||
|
|
||||||
|
- global SwOS credentials from Settings; or
|
||||||
|
- credentials saved specifically for that device.
|
||||||
|
|
||||||
|
Legacy imported records retain the previous fallback behavior until an explicit source is selected.
|
||||||
|
|
||||||
|
## Troubleshooting HTML instead of `.swb`
|
||||||
|
|
||||||
|
If the application reports that SwOS returned HTML instead of `/backup.swb`:
|
||||||
|
|
||||||
|
- verify the configured scheme/host/port;
|
||||||
|
- verify the selected credential source and credentials;
|
||||||
|
- confirm that **Save Backup** works in the device web interface;
|
||||||
|
- confirm `/backup.swb` works with the same account/firmware;
|
||||||
|
- inspect the returned status/content type/title included in the application diagnostic.
|
||||||
|
|
||||||
|
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.
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
# Interface — 0.0.1
|
||||||
|
|
||||||
|
The interface uses the application's real API. It does not populate sample devices,
|
||||||
|
simulate operation results, or present disconnected actions as working features.
|
||||||
|
|
||||||
|
## Build and runtime
|
||||||
|
|
||||||
|
`build.rs` reads `web/assets.list` and combines the sources into **one `app.js` and
|
||||||
|
one `app.css`**. Both files and `index.html` are embedded in the Rust executable.
|
||||||
|
Building or running the application does not require Node, Angular, a frontend
|
||||||
|
server, nginx, an external CDN, or a font service.
|
||||||
|
|
||||||
|
| Source file | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `web/js/i18n.js` | Polish, English, Spanish, and Norwegian dictionaries, including completed missing source translations |
|
||||||
|
| `web/js/core.js` | API access, formatting, HTML escaping, forms, and dialogs |
|
||||||
|
| `web/js/views.js` | Views and data presentation |
|
||||||
|
| `web/js/app.js` | Routing, sessions, and user actions |
|
||||||
|
| `web/css/app.css` | Responsive layout, themes, components, and diff styling |
|
||||||
|
|
||||||
|
Revision 4 translates project documentation into English. It does not change the
|
||||||
|
interface's four supported languages or users' saved language preferences.
|
||||||
|
|
||||||
|
## Views and actions
|
||||||
|
|
||||||
|
| Route | Features |
|
||||||
|
| --- | --- |
|
||||||
|
| `/login`, `/register` | Sign-in, registration only when enabled on the server, and validation errors |
|
||||||
|
| `/` | Counts, recent backups, saved connection status, disk usage, seven-day activity, logs, and batch actions with partial results |
|
||||||
|
| `/devices` | Device search/filtering, creation, editing, ping, and connection tests |
|
||||||
|
| `/devices/:id` | Metadata, the latest test, backups, policies, and device deletion; RSC exports only for RouterOS |
|
||||||
|
| `/files` | Filtering, sorting, download, RSC preview, email, binary upload, ZIP, and bulk deletion |
|
||||||
|
| `/diff-configs` | Export selection, side-by-side and unified views, line numbers, statistics, search, changes-only mode, synchronized scrolling, and diff/HTML download |
|
||||||
|
| `/logs` | Actual messages and timestamps, search, pagination, and confirmed cleanup |
|
||||||
|
| `/settings` | Retention, automation, credentials, SMTP, Pushover, and account preferences |
|
||||||
|
| `/settings?tab=automation` | Flat scheduled-task controls, cron expressions, and actual next execution times |
|
||||||
|
| `/settings?tab=account` | Account tab with password change, matching-password confirmation, and language/font preferences |
|
||||||
|
| `/schedule` | Compatibility redirect to `/settings?tab=automation` |
|
||||||
|
| `/change-password` | Compatibility redirect to `/settings?tab=account` |
|
||||||
|
|
||||||
|
`/routers` and `/routers/:id` are device-route aliases. A Git screen and a standalone
|
||||||
|
SwOS beta module were not part of the active source API, so they are not presented
|
||||||
|
as available features. Binary upload writes a file to the device; it does not
|
||||||
|
perform a restore or restart automatically.
|
||||||
|
|
||||||
|
## Behavior details
|
||||||
|
|
||||||
|
- Operations show results after receiving an API response. Deletion and upload
|
||||||
|
require confirmation. Batch backup results include failures and skipped devices.
|
||||||
|
- Comparison is available only for RSC exports. The bulk compare action appears
|
||||||
|
when exactly two exports are selected. Binary and SwitchOS files have no diff
|
||||||
|
action. SwitchOS has no exports tab, export action, or export policy control.
|
||||||
|
Diff errors use the existing toast notification without an extra error block
|
||||||
|
inside the form.
|
||||||
|
- Secret fields are never prefilled. A blank field preserves the secret; a separate
|
||||||
|
explicit control clears it. Revealing the global SSH key requires the account
|
||||||
|
password. Closing the dialog removes the revealed text from the interface.
|
||||||
|
- The SwitchOS form has an explicit credential-source selector.
|
||||||
|
`use_global_switchos_credentials=true` uses only the current global credentials;
|
||||||
|
`false` uses only the device's credentials. In global mode, the UI sends neither
|
||||||
|
`ssh_user` nor `ssh_password` nor their clear controls. Stored local values are
|
||||||
|
retained. `has_own_password` supplies a presence hint for the empty password field,
|
||||||
|
never the password itself. A legacy record with `null` preserves its original
|
||||||
|
fallback behavior on an ordinary save until the user explicitly selects a source.
|
||||||
|
- The Bearer token is kept in the current tab's `sessionStorage`. An existing
|
||||||
|
`routeros_token` from the old UI is moved into the session only after successful
|
||||||
|
verification through `/auth/me`. Signing out clears the client session.
|
||||||
|
- `API_PREFIX` comes from page metadata inserted by the server.
|
||||||
|
- The server supplies `ALLOW_REGISTRATION` through
|
||||||
|
`<meta name="allow-registration" content="false">`. If the tag is absent or its
|
||||||
|
value is anything other than `true`, the registration link is hidden and
|
||||||
|
`/register` redirects to sign-in. The API independently enforces the same setting.
|
||||||
|
- Password changes live in **Settings → Account**, without a separate navigation
|
||||||
|
item. The previous direct route continues to work through a redirect.
|
||||||
|
- Language and font are saved through `/auth/preferences`. Theme and sidebar width
|
||||||
|
are browser preferences. Font stacks preserve Inter/system UI, Roboto Mono/IBM
|
||||||
|
Plex Mono, Adwaita Mono, and Hack. If a font is not installed, the browser uses
|
||||||
|
the configured fallback; no font is downloaded at runtime.
|
||||||
|
- Names, logs, exports, and diff content are escaped as text before presentation.
|
||||||
|
Server-generated diff HTML is downloaded as a file, not inserted into the app DOM.
|
||||||
|
- Weekly scheduling writes weekday names `mon`–`sun`. Imported numbers preserve
|
||||||
|
APScheduler's actual semantics: **0 = Monday**, **6 = Sunday**. The old interface
|
||||||
|
described these numbers inconsistently; this editor shows the actual scheduled day.
|
||||||
|
- Automation is part of Settings without a separate navigation item.
|
||||||
|
`/settings?tab=schedule` also redirects to `automation`. Schedule fields and next-run
|
||||||
|
information use plain rows rather than nested cards or bordered panels.
|
||||||
|
- A small **API documentation** footer link opens local `/docs`. The backend serves
|
||||||
|
this page separately from the SPA router.
|
||||||
|
- If the host disallows ICMP socket creation, ping shows an unknown state with the
|
||||||
|
diagnostic reason. Missing process permissions are not reported as a router outage.
|
||||||
|
|
||||||
|
## Interface tests
|
||||||
|
|
||||||
|
These are development tools. Node and Playwright are not application dependencies.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node web/tests/core.test.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The core suite runs without npm packages. It covers translations, HTML injection
|
||||||
|
protection, secret handling in forms, UTC dates, cron semantics, and presentation of
|
||||||
|
actual data structures. The browser suite requires Playwright and Chromium. It
|
||||||
|
starts an isolated server with controlled API responses and exercises sign-in,
|
||||||
|
device CRUD, backups, diff, settings, languages, policies, confirmation dialogs, and
|
||||||
|
mobile layout. It does not connect to routers or notification services.
|
||||||
|
|
||||||
|
For an optional, repeatable browser-tool installation outside the project, run the
|
||||||
|
following from the project root in one shell. Node.js and npm must be available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MBS_UI_TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mbs-ui-tests.XXXXXX")"
|
||||||
|
npm install --prefix "$MBS_UI_TEST_DIR" --no-audit --no-fund --save-exact playwright@1.62.1
|
||||||
|
export NODE_PATH="$MBS_UI_TEST_DIR/node_modules${NODE_PATH:+:$NODE_PATH}"
|
||||||
|
export PLAYWRIGHT_BROWSERS_PATH="$MBS_UI_TEST_DIR/browsers"
|
||||||
|
node "$MBS_UI_TEST_DIR/node_modules/playwright/cli.js" install chromium
|
||||||
|
node web/tests/smoke.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The npm package and downloaded browser remain under the temporary directory named
|
||||||
|
by `MBS_UI_TEST_DIR`; the project does not gain `node_modules`, `package.json`, or
|
||||||
|
an npm lockfile. Remove that temporary directory when finished. If Chromium is
|
||||||
|
already installed, skip its download and set `MBS_CHROMIUM` to the existing
|
||||||
|
executable path. These environment variables also apply to the live-server check
|
||||||
|
below.
|
||||||
|
|
||||||
|
To check an actual running server without changing its data:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MBS_BASE_URL=http://127.0.0.1:5581 \
|
||||||
|
MBS_TEST_USERNAME=admin \
|
||||||
|
MBS_TEST_PASSWORD='test-account-password' \
|
||||||
|
node web/tests/live.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
`MBS_CHROMIUM` optionally selects an existing Chromium executable.
|
||||||
|
`MBS_SCREENSHOT_DIR` saves local screenshots for inspection. The live check verifies
|
||||||
|
views against the actual backend, JavaScript/API/CSP errors, and responsive layout;
|
||||||
|
it does not create backups, upload files, or send notifications. Revision-specific
|
||||||
|
execution results are recorded in [the validation report](validation.md); the
|
||||||
|
presence of a test in the source tree does not imply that it has been rerun for
|
||||||
|
every revision.
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
# Validation report — revision 5 working copy
|
||||||
|
|
||||||
|
Date: **28 October 2026**. This section applies to the revision 5 changes only; the
|
||||||
|
older revision 4 and revision 3 results below remain historical evidence for their
|
||||||
|
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` |
|
||||||
|
| 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 |
|
||||||
|
| Physical SwitchOS device | **NOT TESTED** | Requires the user's device/firmware for final acceptance |
|
||||||
|
|
||||||
|
The revision 5 patch changes SwitchOS authentication/probing, settings layout, and
|
||||||
|
RSC diff payload/rendering. Digest handling now accepts a changed nonce/challenge for
|
||||||
|
`/backup.swb` after homepage authentication and re-signs redirects for their final URI.
|
||||||
|
A physical-device check remains required to confirm the original 502 scenario is
|
||||||
|
resolved on the affected firmware.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Validation report — 0.0.1, revision 4
|
||||||
|
|
||||||
|
Date: **27 October 2026**. This report separates completed revision 4 verification
|
||||||
|
from historical results. Historical checks are explicitly labeled and are not
|
||||||
|
presented as newly executed revision 4 checks.
|
||||||
|
|
||||||
|
The distribution contains **source only**: Rust code, `Cargo.lock`, interface sources,
|
||||||
|
tests, and the patched library under `vendor/`. It does not ship an executable or
|
||||||
|
`bin/`, `target/`, or `storage/` directories. The input archives contained no production
|
||||||
|
database or device credentials.
|
||||||
|
|
||||||
|
## Current revision 4 results
|
||||||
|
|
||||||
|
Revision 4 replaces automatic schema/data/layout conversion with fresh initialization
|
||||||
|
and an explicit Python importer. It also embeds API documentation directly from
|
||||||
|
checked-in input files and translates project documentation into English.
|
||||||
|
|
||||||
|
| Area | Result | Evidence and scope |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Rust dev/test build and suite | **111/111 PASS** | 95 unit tests, 11 API contract tests, 3 import-launcher tests, and 2 OpenAPI tests; no failures or skipped tests |
|
||||||
|
| Fresh installation and incompatible database | **PASS** | Fresh schema creation and restart; old/foreign/empty existing databases rejected without conversion or backup relocation |
|
||||||
|
| Explicit Python importer | **PASS** | Unit regressions plus six actual CLI/HTTP acceptance scenarios; source selection, unchanged originals, preserved records/files, and refusal to overwrite a destination |
|
||||||
|
| Import launcher | **3/3 PASS** | Validation before build, argument/path preservation including spaces and CDPATH, and import failure propagation |
|
||||||
|
| Source launchers and ignore rules | **16/16 PASS** | Isolated Cargo/Docker stubs, build-before-start, errors, arguments, working directories, target-directory overrides, and private/generated file exclusions |
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
| Release executable build | Not performed for revision 4 | The deployment launcher builds release source on the target host |
|
||||||
|
| Docker / systemd execution | Not performed for revision 4 | No container or service-manager execution result claimed |
|
||||||
|
|
||||||
|
The actual import acceptance run used a generated database matching the supplied
|
||||||
|
Python SQLAlchemy models and the original Compose directory layout. Its Passlib
|
||||||
|
PBKDF2-SHA256 password hash was generated independently with Python's standard
|
||||||
|
library. The run verified:
|
||||||
|
|
||||||
|
1. Startup rejects that original database without changing any source file or
|
||||||
|
moving a backup.
|
||||||
|
2. Explicit import discovers the Compose database and creates a separate target;
|
||||||
|
it neither executes nor copies the source `.env`.
|
||||||
|
3. IDs, ownership, password hashes, database secrets, logs, filenames, checksums,
|
||||||
|
and every backup's bytes are preserved in the current schema and layout.
|
||||||
|
4. A second import refuses the existing target without overwriting it.
|
||||||
|
5. The imported account can sign in and download all three fixture backups after
|
||||||
|
the first start and a subsequent restart.
|
||||||
|
6. Ordinary startup in another fresh directory creates an empty installation,
|
||||||
|
without importing the nearby Python data.
|
||||||
|
|
||||||
|
Unit regressions additionally retain committed WAL data with an existing or
|
||||||
|
missing source SHM file, compare source sidecar bytes before/after import, reject
|
||||||
|
ambiguous or overlapping paths, and verify validation precedes secret/lock writes.
|
||||||
|
No production database was used. Filesystem interruption and synchronization
|
||||||
|
failures remain subject to the explicit cleanup limits in [data-import.md](data-import.md).
|
||||||
|
|
||||||
|
The browser run executed public `/health` and an authenticated device-list request
|
||||||
|
through the actual English Swagger UI. All documentation assets stayed on the
|
||||||
|
same origin, with no JavaScript or CSP errors. The build/profile check and tests
|
||||||
|
ran with the old generated documentation files removed; the updated documentation
|
||||||
|
handler embeds checked-in input files directly.
|
||||||
|
|
||||||
|
Evidence is recorded in `revision4-release-check.log`, `revision4-rust-tests.log`,
|
||||||
|
`revision4-clippy.log`, `revision4-format.log`, `revision4-ui-core.log`,
|
||||||
|
`revision4-live-import.json`, `revision4-live-switchos.json`, `revision4-ui-live.log`,
|
||||||
|
`revision4-source-start-checks.json`, and `revision4-vendor-verification.json`.
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Historical revision 3 results
|
||||||
|
|
||||||
|
Revision 3 used actual dev/test builds with Rust 1.99.1, edition 2024. It did not run
|
||||||
|
a revision 3 release build, Docker container, or physical MikroTik acceptance test.
|
||||||
|
|
||||||
|
| Area | Historical revision 3 result | Scope and method |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Dev/test compilation | PASS | Actual application and test compilation with Rust 1.99.1, edition 2024 |
|
||||||
|
| Rust unit tests | **91/91 PASS** | Data, then-current migrations, security, files, diff, cron, adapters, and notifications |
|
||||||
|
| 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 |
|
||||||
|
| 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` |
|
||||||
|
| Chromium + actual Rust, Swagger UI, and SwOS | **8/8 PASS** | Browser, running backend, SQLite, and an independent local HTTP Digest SwOS fixture |
|
||||||
|
| Chromium + actual Rust navigation | **PASS** | Eight routes, old-route redirects, responsiveness, CSP, no API/JS errors, and sign-out |
|
||||||
|
| Vendored integrity recheck | **PASS** | All **91 entries** in `vendor/libssh2-sys/SHA256SUMS` and **5 Swagger UI files** matched; `Cargo.lock` unchanged |
|
||||||
|
| Revision 3 release build | Not performed | The deployment launcher builds release on the target host |
|
||||||
|
| Revision 3 Docker execution | Not performed | No image/container execution result established |
|
||||||
|
|
||||||
|
These results were recorded in `revision3-rust-tests.log`, `revision3-ui-core.log`,
|
||||||
|
`revision3-ui-workflow.log`, `revision3-ui-live.log`, `revision3-live-switchos.json`,
|
||||||
|
`revision3-clippy.log`, `revision3-format.log`, and
|
||||||
|
`revision3-vendor-verification.json` in the acceptance tooling directory. These are
|
||||||
|
verification-environment logs, not files promised as part of the source archive.
|
||||||
|
The Rust and JavaScript test sources are included in the project.
|
||||||
|
|
||||||
|
### Historical migration, file, and concurrency checks
|
||||||
|
|
||||||
|
**The automatic migration behavior in this subsection belonged to revision 3 and
|
||||||
|
has been removed in revision 4.** It is retained here only to identify the scope of
|
||||||
|
the historical 91-unit-test result.
|
||||||
|
|
||||||
|
Those tests preserved IDs, ownership, password hashes, settings, and secrets while
|
||||||
|
upgrading old schemas. Migration 002 kept
|
||||||
|
`use_global_switchos_credentials = null` for existing devices. Migration 003 added
|
||||||
|
a persistent backup-layout journal. Fresh backups, imports, and startup conversions
|
||||||
|
used `DATA_DIR/backups/binary/<router_id>/` and
|
||||||
|
`DATA_DIR/backups/exports/<router_id>/`; renaming a device did not change the directory
|
||||||
|
ID, and the original `file_name` was preserved.
|
||||||
|
|
||||||
|
Checks covered a database snapshot before path changes, file copying before metadata
|
||||||
|
transactions, SHA-256, and idempotent restart. Fault cases interrupted the logical
|
||||||
|
process after plan persistence, after verified copying, and between file publication
|
||||||
|
and journal acknowledgement. Restart did not overwrite unrelated files, even with
|
||||||
|
identical bytes. Missing/modified sources, corrupted copies, tampered journal paths,
|
||||||
|
and snapshot symlinks prevented partial activation of changed records.
|
||||||
|
|
||||||
|
Separate cases covered source-preserving import, target conflicts, storage/database
|
||||||
|
locks, symlinks outside the selected source, and a dangling destination database
|
||||||
|
symlink. **Sixteen threads** concurrently created initial device/type directories;
|
||||||
|
a directory encountered after a race was checked again for symlinks.
|
||||||
|
|
||||||
|
Retention and deletion operated on active files and preserved originals retained by
|
||||||
|
that revision, even if the newer active copy was absent. Tests forced both a SQLite
|
||||||
|
DELETE rejection and a failed compensating rename; the remaining copy had to survive
|
||||||
|
as a recovery file. Code review also examined directory synchronization after imported
|
||||||
|
database publication and backup preservation following a later failure. No final
|
||||||
|
fsync failure, physical power loss, or storage hardware failure was injected.
|
||||||
|
|
||||||
|
Revision 4 uses a new destination and an explicit import instead of this startup
|
||||||
|
conversion mechanism. Historical results do not validate the replacement until its
|
||||||
|
own checks have completed.
|
||||||
|
|
||||||
|
### Historical credentials, SwOS adapter, and HTTP contract checks
|
||||||
|
|
||||||
|
Revision 3 API tests covered all three SwitchOS credential modes: a complete global
|
||||||
|
pair, a complete local pair, and legacy field-by-field fallback. Cases included
|
||||||
|
creation, partial PUT, ordinary saves that preserve legacy semantics, global changes
|
||||||
|
without rewriting device records, and returning from global mode to local mode
|
||||||
|
without losing the stored password. They verified no implicit fallback in explicit
|
||||||
|
modes, a clear missing-username error, empty-password support, and masked responses.
|
||||||
|
|
||||||
|
Adapter tests used **local HTTP servers with controlled responses**. They covered
|
||||||
|
Digest, Basic, and authentication retries when a public HTML page did not return a
|
||||||
|
401 challenge. Checks included the correct system and backup endpoints, the Digest
|
||||||
|
request URI, a global credential pair, explicit URL ports, and actual response-byte
|
||||||
|
transfer and validation. HTML/login pages could neither be saved as backups nor
|
||||||
|
produce a positive availability result. A missing endpoint could not be replaced
|
||||||
|
with a public homepage; redirects did not forward credentials; the timeout covered
|
||||||
|
the whole operation. These were adapter/protocol tests, not hardware acceptance for
|
||||||
|
a particular SwOS model.
|
||||||
|
|
||||||
|
Other repeated cases covered authorization, ownership, JSON/form login, preferences,
|
||||||
|
password changes, and the bounded password-operation pool. Independent fixtures
|
||||||
|
verified Passlib/Werkzeug passwords and HS256 compatibility, invalid `exp`/`nbf`
|
||||||
|
rejection, secret preservation/clearing, download bytes, RSC previews, and ZIP.
|
||||||
|
Unified diff passed **32 Python difflib fixtures**. Local SMTP and Pushover tests did
|
||||||
|
not send to production recipients; the SMTP fixture did not establish STARTTLS
|
||||||
|
compatibility with an external provider.
|
||||||
|
|
||||||
|
The two documentation tests covered all **36 API operations**, actual route coverage,
|
||||||
|
a custom prefix, and documentation assets served without a CDN. A completed Chromium
|
||||||
|
run against an actual Rust process loaded all 36 operations, executed public
|
||||||
|
`/health`, signed in through **Authorize**, and fetched an authenticated device list.
|
||||||
|
There were no external-server requests, JavaScript errors, or CSP violations.
|
||||||
|
|
||||||
|
That same run created a SwitchOS device through the form using global credentials
|
||||||
|
without copying secrets into the device. The connection test and backup used Digest
|
||||||
|
with a `/sys.b` challenge and the correct `/backup.swb` URI. Downloaded bytes, SHA-256,
|
||||||
|
and `backups/binary/<router_id>/` were checked, including valid bytes mislabeled
|
||||||
|
`text/html`. Rotating the global username/password took effect without editing the
|
||||||
|
device. Actual HTML was rejected with HTTP 502, no backup record was added, and the
|
||||||
|
connection test did not report availability. The fixture device, backups, and global
|
||||||
|
test credentials were removed afterward.
|
||||||
|
|
||||||
|
### Historical revision 3 interface checks
|
||||||
|
|
||||||
|
The mocked-API Chromium workflow made **113 requests**. It covered login and
|
||||||
|
registration, device forms, secret preservation, the global → local → global → local
|
||||||
|
sequence, saving legacy `null` mode, downloads, binary upload, and bulk deletion.
|
||||||
|
It checked RSC-only diff actions, hidden SwitchOS export/unsupported actions,
|
||||||
|
toast errors, log filters, preferences, and password changes through the form.
|
||||||
|
|
||||||
|
Checks also covered flat daily/weekly/custom automation controls, old-route redirects,
|
||||||
|
responsive layout, themes, escaping, and sign-out. A separate actual-Rust run visited
|
||||||
|
`/devices`, `/files`, `/diff-configs`, `/logs`, `/settings`, `/schedule`,
|
||||||
|
`/change-password`, and `/`. It confirmed the automation and account redirects,
|
||||||
|
the flat automation layout, a mobile view without horizontal page scrolling, and
|
||||||
|
sign-out. Neither workflow had JavaScript errors; the live-server run also had no
|
||||||
|
API or CSP errors.
|
||||||
|
|
||||||
|
## Historical revision 2 checks
|
||||||
|
|
||||||
|
These separate runs are **not described as rerun in revision 3 or revision 4**.
|
||||||
|
The ordinary test suite reran its included SSH unit tests during revision 3; the five
|
||||||
|
scenarios below were a separate acceptance run with an actual application process.
|
||||||
|
|
||||||
|
| Area | Historical revision 2 result | Scope |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| SSH trust over an actual local protocol | **5/5 PASS** | Default `accept-new`, repeated pin verification, changed-key rejection, invalid private key after host verification, and four concurrent first contacts |
|
||||||
|
| Launchers and project rules | **16/16 PASS** | Bash with isolated Cargo/Docker substitutes; build-before-start, errors, arguments, paths with spaces, `CARGO_TARGET_DIR`, Compose, `.env`; 31 ignored paths and 25 retained source paths |
|
||||||
|
| Chromium + actual Rust | PASS | Eight then-current routes, responsive layout, CSP, sign-out, password change through Settings, and signing in again |
|
||||||
|
| RustSec audit | PASS | **270 dependencies, 0 reported vulnerabilities, 0 warnings**, no ignored advisories |
|
||||||
|
| Vendor integrity | PASS | All **91 SHA256SUMS entries** matched |
|
||||||
|
|
||||||
|
Revision 3's `Cargo.lock` was unchanged from that audit. This does not mean the
|
||||||
|
advisory database was fetched again or a new scan was performed. The historical scan
|
||||||
|
and its metadata remain in [dependency-audit.json](dependency-audit.json) and
|
||||||
|
[dependency-audit-metadata.json](dependency-audit-metadata.json).
|
||||||
|
|
||||||
|
SSH cases used loopback, generated test keys, and real handshake/authentication.
|
||||||
|
Launcher checks used substitute CLIs; **they neither built release nor ran Docker**.
|
||||||
|
|
||||||
|
## Historical initial acceptance — before revision 2
|
||||||
|
|
||||||
|
Initial acceptance included a release build and actual local SSH/SFTP/SCP sessions
|
||||||
|
emulating RouterOS responses. It covered password, own-key and global-key
|
||||||
|
authentication, identity/resource, exports, binary backup, download, upload, SHA-256,
|
||||||
|
and remote temporary-file cleanup. Truncated SCP, negative ACK, and nonzero final
|
||||||
|
exit status were rejected. These runs are not new revision 3 or revision 4 tests.
|
||||||
|
|
||||||
|
A historical scheduler check removed a controlled expired file and its SQLite record.
|
||||||
|
Other checks covered restart, persistence, `healthcheck`, `check-config`, rejecting
|
||||||
|
a second instance, and SIGTERM during an incomplete request; SQLite reported
|
||||||
|
`integrity_check = ok`. A separate libssh2 C test exercised short/oversized AES-GCM
|
||||||
|
blocks and overflowing username lengths against earlier debug and release archives.
|
||||||
|
Patch reproduction confirmed all seven modified C files matched.
|
||||||
|
|
||||||
|
At that time, the launcher could run a bundled executable without Cargo. **That path
|
||||||
|
has been removed.** Current `start.sh` builds the source; the historical release
|
||||||
|
artifact is not included in this source archive. Backport details are in
|
||||||
|
[dependency-security.md](dependency-security.md).
|
||||||
|
|
||||||
|
## Limits and reproduction
|
||||||
|
|
||||||
|
- **Hardware and data:** no physical MikroTik or unavailable production database was
|
||||||
|
tested. Local fixtures do not establish compatibility with every model/firmware.
|
||||||
|
- **Network:** local SMTP/Pushover checks do not verify external provider accounts
|
||||||
|
or STARTTLS. The ICMP test permits an explicit socket-permission failure; PASS
|
||||||
|
alone does not prove an echo reply when the environment denied socket creation.
|
||||||
|
- **Deployment:** no revision 4 release executable was linked or run, and Docker
|
||||||
|
and systemd were not executed. The release-profile check, dev/test compilation,
|
||||||
|
and live debug-server acceptance are separate completed checks.
|
||||||
|
- **Dependency security:** the historical RustSec result applies to a specific
|
||||||
|
advisory set, date, and lockfile, not every possible vulnerability. System
|
||||||
|
OpenSSL, zlib, and glibc updates and new advisories remain relevant.
|
||||||
|
|
||||||
|
To reproduce local verification gates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --package mikrotik-backup -- --check
|
||||||
|
cargo clippy --all-targets --locked -- -D warnings
|
||||||
|
cargo test --locked
|
||||||
|
cargo check --release --locked
|
||||||
|
node web/tests/core.test.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `cargo audit --file Cargo.lock --json` for a new dependency scan. Running
|
||||||
|
`./start.sh` builds release and starts the application on the deployment host.
|
||||||
|
`cargo check --release` checks the release profile but does not produce a tested
|
||||||
|
release executable. These commands are instructions, not additional recorded passes.
|
||||||
|
[ui.md](ui.md) describes browser checks. Fixture servers and acceptance tools are
|
||||||
|
not required for normal application operation.
|
||||||
|
## Revision 6 targeted SwitchOS change
|
||||||
|
|
||||||
|
The source adds a regression fixture for a SwitchOS server that sends a Digest
|
||||||
|
challenge together with `Set-Cookie` and returns an HTML login page if the cookie is
|
||||||
|
missing from the authenticated `/backup.swb` retry. The adapter now preserves that
|
||||||
|
cookie, drains the challenge response before retrying, forces HTTP/1.x, and starts the
|
||||||
|
backup request with fresh authentication state.
|
||||||
|
|
||||||
|
The current execution environment does not contain `cargo`, `rustc`, or `rustfmt`, so
|
||||||
|
the Rust test suite and compiler checks were **not executed for revision 6 here**. The
|
||||||
|
new test is included for execution on the build/deployment host with `cargo test --locked`.
|
||||||
|
|
||||||
|
|
||||||
|
## Revision 7 targeted UI, diff, container, and importer review
|
||||||
|
|
||||||
|
The browser diff now requests a compact JSON response that omits the duplicated unified
|
||||||
|
text and HTML documents. The UI renders at most 200 structured rows per page, filters
|
||||||
|
before rendering, and fetches the full unified `.diff` text only when the user explicitly
|
||||||
|
downloads it. Semantic RSC analysis is included alongside the line diff; it ignores
|
||||||
|
comments/export metadata and command ordering, groups changes by RouterOS section,
|
||||||
|
pairs recognizable commands, and flags selected security/network/service sections.
|
||||||
|
|
||||||
|
Current JavaScript verification in the patch environment:
|
||||||
|
|
||||||
|
- `node --test web/tests/core.test.cjs`: **16/16 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.
|
||||||
|
- Docker build/runtime: **NOT RUN** because the Docker CLI/daemon is unavailable.
|
||||||
|
|
||||||
|
The supplied Python/TypeScript SQLAlchemy models were compared with `src/schema.sql`.
|
||||||
|
All legacy columns in `users`, `routers`, `backups`, `operation_logs`, and
|
||||||
|
`global_settings` are represented in the Rust schema; the Rust `routers` table adds
|
||||||
|
`use_global_switchos_credentials`. The importer copies supported source columns into
|
||||||
|
a new target, validates referenced backup files and recorded checksums, and publishes
|
||||||
|
the database last. No production database was supplied, so this is a source/schema
|
||||||
|
review plus existing importer regression coverage, not a production-data migration run.
|
||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Explicit, copy-only import from a Python/TypeScript installation into new storage.
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage: ./import-python.sh SOURCE_DIRECTORY [--database PATH] [--backup-root DIRECTORY]
|
||||||
|
|
||||||
|
Import accounts, devices, settings, logs and backup files from the original
|
||||||
|
Python/TypeScript application into a new Rust installation.
|
||||||
|
|
||||||
|
Stop the original application before importing. Source files are not changed.
|
||||||
|
The destination is selected by DATA_DIR and DATABASE_PATH, as with ./start.sh.
|
||||||
|
An existing destination database is never overwritten or merged.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
./import-python.sh /srv/mikrotik-python
|
||||||
|
DATA_DIR=./storage-imported ./import-python.sh "/srv/old app"
|
||||||
|
./import-python.sh /srv/mikrotik-python --database docker-data/db/routeros_backup_next.db
|
||||||
|
|
||||||
|
The script builds the current Rust sources, runs the import, and exits.
|
||||||
|
After a successful import, run ./start.sh with the same destination settings.
|
||||||
|
See docs/migration.md for discovery rules and backup-path overrides.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ $# -eq 0 || -z "${1:-}" ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve the source before start.sh changes the working directory. Preserve
|
||||||
|
# every argument boundary, including paths containing spaces or shell metacharacters.
|
||||||
|
source_dir="$(CDPATH= cd -- "$1" && pwd -P)" || {
|
||||||
|
echo "Source directory does not exist or cannot be read: $1" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
shift
|
||||||
|
project_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
exec bash "$project_dir/start.sh" import-python --source "$source_dir" "$@"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
preferred_language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
preferred_font TEXT NOT NULL DEFAULT 'default'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS routers (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
device_type TEXT NOT NULL DEFAULT 'routeros',
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 22,
|
||||||
|
ssh_user TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
ssh_key TEXT,
|
||||||
|
ssh_password TEXT,
|
||||||
|
last_connection_status INTEGER,
|
||||||
|
last_connection_tested_at TEXT,
|
||||||
|
last_connection_error TEXT,
|
||||||
|
last_connection_hostname TEXT,
|
||||||
|
last_connection_model TEXT,
|
||||||
|
last_connection_version TEXT,
|
||||||
|
last_connection_uptime TEXT,
|
||||||
|
last_connection_transport TEXT,
|
||||||
|
last_connection_server TEXT,
|
||||||
|
last_connection_auth_mode TEXT,
|
||||||
|
last_connection_http_status TEXT,
|
||||||
|
last_connection_backup_available INTEGER,
|
||||||
|
disable_all_backups INTEGER NOT NULL DEFAULT 0,
|
||||||
|
disable_export_backups INTEGER NOT NULL DEFAULT 0,
|
||||||
|
disable_binary_backups INTEGER NOT NULL DEFAULT 0,
|
||||||
|
disable_ping INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS backups (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
router_id INTEGER NOT NULL REFERENCES routers(id) ON DELETE CASCADE,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
backup_type TEXT NOT NULL DEFAULT 'export',
|
||||||
|
checksum TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS global_settings (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
backup_retention_days INTEGER DEFAULT 7,
|
||||||
|
log_retention_days INTEGER DEFAULT 7,
|
||||||
|
export_cron TEXT DEFAULT '',
|
||||||
|
binary_cron TEXT DEFAULT '',
|
||||||
|
retention_cron TEXT DEFAULT '',
|
||||||
|
enable_auto_export INTEGER DEFAULT 0,
|
||||||
|
connection_test_interval_minutes INTEGER DEFAULT 0,
|
||||||
|
global_ssh_key TEXT,
|
||||||
|
default_switchos_username TEXT,
|
||||||
|
default_switchos_password TEXT,
|
||||||
|
pushover_token TEXT,
|
||||||
|
pushover_userkey TEXT,
|
||||||
|
notify_failures_only INTEGER DEFAULT 1,
|
||||||
|
smtp_host TEXT,
|
||||||
|
smtp_port INTEGER DEFAULT 587,
|
||||||
|
smtp_login TEXT,
|
||||||
|
smtp_password TEXT,
|
||||||
|
smtp_notifications_enabled INTEGER DEFAULT 0,
|
||||||
|
recipient_email TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_routers_owner_id ON routers(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_backups_router_id ON backups(router_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_backups_created_at ON backups(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_operation_logs_timestamp ON operation_logs(timestamp);
|
||||||
|
CREATE TABLE IF NOT EXISTS rust_schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- NULL retains the original per-field credential fallback for existing rows.
|
||||||
|
-- Explicit 1/0 selects one complete source without erasing stored device data.
|
||||||
|
ALTER TABLE routers ADD COLUMN use_global_switchos_credentials INTEGER
|
||||||
|
CHECK (use_global_switchos_credentials IS NULL OR use_global_switchos_credentials IN (0, 1));
|
||||||
|
INSERT INTO rust_schema_migrations (version) VALUES (2);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- The plan lives in the same private SQLite database as backup metadata. Files
|
||||||
|
-- are copied and verified first; all active paths and applied flags commit
|
||||||
|
-- together. Original files remain available for an explicit rollback.
|
||||||
|
CREATE TABLE backup_layout_journal (
|
||||||
|
backup_id INTEGER PRIMARY KEY REFERENCES backups(id) ON DELETE CASCADE,
|
||||||
|
router_id INTEGER NOT NULL,
|
||||||
|
backup_type TEXT NOT NULL,
|
||||||
|
original_path TEXT NOT NULL,
|
||||||
|
source_path TEXT NOT NULL,
|
||||||
|
destination_path TEXT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
copied INTEGER NOT NULL DEFAULT 0 CHECK (copied IN (0,1)),
|
||||||
|
applied INTEGER NOT NULL DEFAULT 0 CHECK (applied IN (0,1)),
|
||||||
|
snapshot_path TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
INSERT INTO rust_schema_migrations (version) VALUES (3);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "MikroTik Backup System",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"distribution": "source",
|
||||||
|
"prebuilt_binary": false,
|
||||||
|
"rust_toolchain": "1.99.1",
|
||||||
|
"rust_edition": "2024",
|
||||||
|
"build_command": "cargo build --release --locked",
|
||||||
|
"start_command": "./start.sh",
|
||||||
|
"import_command": "./import-python.sh SOURCE_DIRECTORY [--database PATH] [--backup-root DIRECTORY]",
|
||||||
|
"container": {
|
||||||
|
"builder": "rust:1.99.1-alpine3.24",
|
||||||
|
"runtime": "alpine:3.24",
|
||||||
|
"uid_gid": "10001:10001"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"schema": "src/schema.sql",
|
||||||
|
"automatic_migrations": false,
|
||||||
|
"legacy_import": "explicit import-python command into a new destination",
|
||||||
|
"binary_backups": "storage/backups/binary/<device_id>/",
|
||||||
|
"text_exports": "storage/backups/exports/<device_id>/"
|
||||||
|
},
|
||||||
|
"frontend_assets": {
|
||||||
|
"strategy": "content-hashed filenames generated at build time",
|
||||||
|
"cache_control": "public, max-age=31536000, immutable",
|
||||||
|
"favicon": "GET /favicon.ico returns 204 No Content"
|
||||||
|
},
|
||||||
|
"api_documentation": {
|
||||||
|
"url": "/docs",
|
||||||
|
"openapi_url": "/openapi.json",
|
||||||
|
"language": "en",
|
||||||
|
"swagger_ui_version": "5.34.0",
|
||||||
|
"cdn_required": false
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"javascript_syntax": "PASS in patch environment",
|
||||||
|
"javascript_unit_tests": "16/16 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.99.1"
|
||||||
|
profile = "minimal"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
|
|
||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
use super::{ApiError, ApiResult};
|
||||||
|
use crate::{AppState, models::User, security};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
body::Bytes,
|
||||||
|
extract::{FromRequestParts, State},
|
||||||
|
http::{HeaderMap, StatusCode, header, request::Parts},
|
||||||
|
};
|
||||||
|
use rusqlite::params;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
pub(super) struct Auth(pub User);
|
||||||
|
impl FromRequestParts<AppState> for Auth {
|
||||||
|
type Rejection = ApiError;
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let token = parts
|
||||||
|
.headers
|
||||||
|
.get(header::AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.split_once(' '))
|
||||||
|
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer"))
|
||||||
|
.map(|(_, token)| token)
|
||||||
|
.ok_or_else(ApiError::unauthorized)?;
|
||||||
|
let username =
|
||||||
|
security::verify_token(&state.config, token).map_err(|_| ApiError::unauthorized())?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.user_by_name(&username)?
|
||||||
|
.map(Self)
|
||||||
|
.ok_or_else(ApiError::unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn password_work<T: Send + 'static>(
|
||||||
|
state: &AppState,
|
||||||
|
work: impl FnOnce() -> T + Send + 'static,
|
||||||
|
) -> ApiResult<T> {
|
||||||
|
let permit = state
|
||||||
|
.auth_operations
|
||||||
|
.clone()
|
||||||
|
.try_acquire_owned()
|
||||||
|
.map_err(|_| {
|
||||||
|
ApiError(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"Password service is busy; retry shortly".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let _permit = permit;
|
||||||
|
work()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("Password worker failed").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct Credentials {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_credentials(value: &Credentials) -> ApiResult<()> {
|
||||||
|
if !(3..=120).contains(&value.username.chars().count())
|
||||||
|
|| value.username.trim() != value.username
|
||||||
|
|| value.username.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
return Err(ApiError::invalid("Username must contain 3–120 characters"));
|
||||||
|
}
|
||||||
|
if !(4..=128).contains(&value.password.chars().count()) {
|
||||||
|
return Err(ApiError::invalid("Password must contain 4–128 characters"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let credentials: Credentials = if headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("")
|
||||||
|
.contains("application/json")
|
||||||
|
{
|
||||||
|
serde_json::from_slice(&body)
|
||||||
|
.map_err(|_| ApiError::invalid("Username and password are required"))?
|
||||||
|
} else {
|
||||||
|
serde_urlencoded::from_bytes(&body)
|
||||||
|
.map_err(|_| ApiError::invalid("Username and password are required"))?
|
||||||
|
};
|
||||||
|
if credentials.username.is_empty()
|
||||||
|
|| credentials.password.is_empty()
|
||||||
|
|| credentials.username.len() > 480
|
||||||
|
|| credentials.password.len() > 512
|
||||||
|
{
|
||||||
|
return Err(ApiError::invalid("Username and password are required"));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut attempts = state
|
||||||
|
.login_attempts
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Login limiter unavailable"))?;
|
||||||
|
attempts.retain(|_, (_, since)| since.elapsed() < Duration::from_secs(60));
|
||||||
|
if attempts.len() >= 10000 {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"Try again in one minute".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let attempt = attempts
|
||||||
|
.entry(credentials.username.clone())
|
||||||
|
.or_insert((0, Instant::now()));
|
||||||
|
if attempt.0 >= 10 {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"Try again in one minute".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
attempt.0 += 1;
|
||||||
|
}
|
||||||
|
let user = state.db.user_by_name(&credentials.username)?;
|
||||||
|
let hash = user.as_ref().map(|u| u.password_hash.clone());
|
||||||
|
let password = credentials.password;
|
||||||
|
let valid = password_work(&state, move || {
|
||||||
|
static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
|
||||||
|
security::hash_password("dummy-user-never-authenticates")
|
||||||
|
.expect("default Argon2 parameters")
|
||||||
|
});
|
||||||
|
security::verify_password(&password, hash.as_deref().unwrap_or(&DUMMY_HASH))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let Some(user) = user.filter(|_| valid) else {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Invalid credentials".into(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if let Ok(mut attempts) = state.login_attempts.lock() {
|
||||||
|
attempts.remove(&user.username);
|
||||||
|
}
|
||||||
|
let token = security::create_token(&state.config, &user)?;
|
||||||
|
Ok(Json(
|
||||||
|
json!({"access_token": token, "token_type": "bearer", "user": user.public_json()}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn register(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(credentials): Json<Credentials>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
if !state.config.allow_registration {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Registration is disabled".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
validate_credentials(&credentials)?;
|
||||||
|
if state.db.user_by_name(&credentials.username)?.is_some() {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Username already exists".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let password = credentials.password;
|
||||||
|
let hash = password_work(&state, move || security::hash_password(&password)).await??;
|
||||||
|
let result = state.db.insert_user(&credentials.username, &hash);
|
||||||
|
if result.is_err() && state.db.user_by_name(&credentials.username)?.is_some() {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Username already exists".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let user = result?;
|
||||||
|
Ok(Json(user.public_json()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn me(Auth(user): Auth) -> Json<Value> {
|
||||||
|
Json(user.public_json())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct Preferences {
|
||||||
|
#[serde(default = "default_language")]
|
||||||
|
preferred_language: String,
|
||||||
|
#[serde(default = "default_font")]
|
||||||
|
preferred_font: String,
|
||||||
|
}
|
||||||
|
fn default_language() -> String {
|
||||||
|
"en".into()
|
||||||
|
}
|
||||||
|
fn default_font() -> String {
|
||||||
|
"default".into()
|
||||||
|
}
|
||||||
|
pub(super) async fn preferences(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Json(payload): Json<Preferences>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let lang = payload.preferred_language.trim().to_lowercase();
|
||||||
|
let font = payload.preferred_font.trim().to_lowercase();
|
||||||
|
if !["pl", "en", "es", "no"].contains(&lang.as_str()) {
|
||||||
|
return Err(ApiError::invalid("Unsupported language"));
|
||||||
|
}
|
||||||
|
if !["default", "adwaita_mono", "hack"].contains(&font.as_str()) {
|
||||||
|
return Err(ApiError::invalid("Unsupported font"));
|
||||||
|
}
|
||||||
|
state.db.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET preferred_language=?1,preferred_font=?2 WHERE id=?3",
|
||||||
|
params![lang, font, user.id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(Json(
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.user_by_id(user.id)?
|
||||||
|
.ok_or_else(ApiError::unauthorized)?
|
||||||
|
.public_json(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct PasswordChange {
|
||||||
|
current_password: String,
|
||||||
|
new_password: String,
|
||||||
|
}
|
||||||
|
pub(super) async fn change_password(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Json(payload): Json<PasswordChange>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
if !(4..=128).contains(&payload.new_password.chars().count()) {
|
||||||
|
return Err(ApiError::invalid("Password must contain 4–128 characters"));
|
||||||
|
}
|
||||||
|
let id = user.id;
|
||||||
|
let result = password_work(&state, move || {
|
||||||
|
if !security::verify_password(&payload.current_password, &user.password_hash) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
security::hash_password(&payload.new_password).map(Some)
|
||||||
|
})
|
||||||
|
.await??;
|
||||||
|
let hash = result.ok_or_else(|| ApiError::bad("Current password is invalid"))?;
|
||||||
|
state.db.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET password_hash=?1 WHERE id=?2",
|
||||||
|
params![hash, id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(Json(json!({"message":"Password changed successfully"})))
|
||||||
|
}
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
use super::{ApiError, ApiResult, auth::Auth, devices::owned_router};
|
||||||
|
use crate::{AppState, backup, diff, models::BackupRecord};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
body::Body,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::{StatusCode, header},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
io::{Seek, SeekFrom},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn owned_backup(state: &AppState, user_id: i64, id: i64) -> ApiResult<BackupRecord> {
|
||||||
|
let backup = state
|
||||||
|
.db
|
||||||
|
.backup(id)?
|
||||||
|
.ok_or_else(|| ApiError::missing("Backup not found"))?;
|
||||||
|
if !state
|
||||||
|
.db
|
||||||
|
.router(backup.router_id)?
|
||||||
|
.is_some_and(|r| r.owner_id == user_id)
|
||||||
|
{
|
||||||
|
return Err(ApiError::missing("Backup not found"));
|
||||||
|
}
|
||||||
|
Ok(backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn serialize(state: &AppState, item: &BackupRecord) -> ApiResult<Value> {
|
||||||
|
let router = state.db.router(item.router_id)?;
|
||||||
|
let size = backup::resolve_path(state, item)
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| std::fs::metadata(p).ok())
|
||||||
|
.map(|m| m.len());
|
||||||
|
Ok(
|
||||||
|
json!({"id":item.id,"router_id":item.router_id,"router_name":router.as_ref().map(|r|&r.name),
|
||||||
|
"device_type":router.as_ref().map(|r|r.device_type.as_str()).unwrap_or("routeros"),
|
||||||
|
"file_path":item.file_path,"file_name":item.file_name,"backup_type":item.backup_type,
|
||||||
|
"checksum":item.checksum,"file_size":size,"created_at":item.created_at.replace(' ',"T")}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub(super) struct BackupQuery {
|
||||||
|
search: Option<String>,
|
||||||
|
backup_type: Option<String>,
|
||||||
|
router_id: Option<i64>,
|
||||||
|
created_on: Option<String>,
|
||||||
|
sort_by: Option<String>,
|
||||||
|
order: Option<String>,
|
||||||
|
}
|
||||||
|
pub(super) async fn list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Query(query): Query<BackupQuery>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
if query
|
||||||
|
.backup_type
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|v| !["export", "binary"].contains(&v))
|
||||||
|
{
|
||||||
|
return Err(ApiError::invalid("Unsupported backup type"));
|
||||||
|
}
|
||||||
|
if query
|
||||||
|
.order
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|v| !["asc", "desc"].contains(&v))
|
||||||
|
{
|
||||||
|
return Err(ApiError::invalid("Unsupported sort order"));
|
||||||
|
}
|
||||||
|
if let Some(date) = &query.created_on {
|
||||||
|
chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
|
||||||
|
.map_err(|_| ApiError::invalid("Date must use YYYY-MM-DD"))?;
|
||||||
|
}
|
||||||
|
let routers: HashMap<_, _> = state
|
||||||
|
.db
|
||||||
|
.routers(Some(user.id))?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.id, r))
|
||||||
|
.collect();
|
||||||
|
let search = query.search.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
let mut records: Vec<_> = state
|
||||||
|
.db
|
||||||
|
.backups(Some(user.id))?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|b| {
|
||||||
|
query
|
||||||
|
.backup_type
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|v| *v == b.backup_type)
|
||||||
|
&& query.router_id.is_none_or(|id| id == b.router_id)
|
||||||
|
&& query
|
||||||
|
.created_on
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|day| b.created_at.starts_with(day))
|
||||||
|
&& (search.is_empty()
|
||||||
|
|| b.file_name.to_lowercase().contains(&search)
|
||||||
|
|| routers.get(&b.router_id).is_some_and(|r| {
|
||||||
|
r.name.to_lowercase().contains(&search)
|
||||||
|
|| r.host.to_lowercase().contains(&search)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.map(|b| serialize(&state, &b))
|
||||||
|
.collect::<ApiResult<Vec<_>>>()?;
|
||||||
|
let key = match query.sort_by.as_deref() {
|
||||||
|
Some("file_name") => "file_name",
|
||||||
|
Some("backup_type") => "backup_type",
|
||||||
|
Some("router_name") => "router_name",
|
||||||
|
_ => "created_at",
|
||||||
|
};
|
||||||
|
records.sort_by(|a, b| {
|
||||||
|
let order = a[key]
|
||||||
|
.as_str()
|
||||||
|
.cmp(&b[key].as_str())
|
||||||
|
.then(a["id"].as_i64().cmp(&b["id"].as_i64()));
|
||||||
|
if query.order.as_deref() == Some("asc") {
|
||||||
|
order
|
||||||
|
} else {
|
||||||
|
order.reverse()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Json(json!(records)))
|
||||||
|
}
|
||||||
|
pub(super) async fn for_router(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
owned_router(&state, user.id, id)?;
|
||||||
|
let mut records: Vec<_> = state
|
||||||
|
.db
|
||||||
|
.backups(Some(user.id))?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|b| b.router_id == id)
|
||||||
|
.collect();
|
||||||
|
records.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id)));
|
||||||
|
Ok(Json(json!(
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.map(|b| serialize(&state, b))
|
||||||
|
.collect::<ApiResult<Vec<_>>>()?
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
async fn run(state: AppState, user_id: i64, id: i64, kind: &str) -> ApiResult<Json<Value>> {
|
||||||
|
let router = owned_router(&state, user_id, id)?;
|
||||||
|
if router.disable_all_backups
|
||||||
|
|| (kind == "export" && router.disable_export_backups)
|
||||||
|
|| (kind == "binary" && router.disable_binary_backups)
|
||||||
|
{
|
||||||
|
return Err(ApiError::bad(
|
||||||
|
"Backups of this type are disabled for this device",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if kind == "export" && router.device_type != "routeros" {
|
||||||
|
return Err(ApiError::bad(
|
||||||
|
"Text export is available only for RouterOS devices",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let item = backup::run(&state, &router, kind)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::operation(&state, e))?;
|
||||||
|
Ok(Json(serialize(&state, &item)?))
|
||||||
|
}
|
||||||
|
pub(super) async fn export(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
run(state, user.id, id, "export").await
|
||||||
|
}
|
||||||
|
pub(super) async fn binary(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
run(state, user.id, id, "binary").await
|
||||||
|
}
|
||||||
|
pub(super) async fn export_all(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
Ok(Json(
|
||||||
|
backup::run_all(&state, Some(user.id), "export")
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::operation(&state, e))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub(super) async fn binary_all(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
Ok(Json(
|
||||||
|
backup::run_all(&state, Some(user.id), "binary")
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::operation(&state, e))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub(super) async fn upload(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path((id, backup_id)): Path<(i64, i64)>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let router = owned_router(&state, user.id, id)?;
|
||||||
|
let item = owned_backup(&state, user.id, backup_id)?;
|
||||||
|
if router.device_type != "routeros" || item.backup_type != "binary" {
|
||||||
|
return Err(ApiError::bad(
|
||||||
|
"Only binary RouterOS backups can be uploaded to RouterOS devices",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if state
|
||||||
|
.db
|
||||||
|
.router(item.router_id)?
|
||||||
|
.is_some_and(|r| r.device_type != "routeros")
|
||||||
|
{
|
||||||
|
return Err(ApiError::bad(
|
||||||
|
"SwitchOS backups cannot be uploaded over SSH",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
backup::upload(&state, &router, &item)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::operation(&state, e))?;
|
||||||
|
Ok(Json(json!({"message":"Backup uploaded to router"})))
|
||||||
|
}
|
||||||
|
pub(super) async fn remove(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let item = owned_backup(&state, user.id, id)?;
|
||||||
|
backup::delete(&state, &item).map_err(|e| ApiError::operation(&state, e))?;
|
||||||
|
Ok(Json(json!({"message":"Backup deleted"})))
|
||||||
|
}
|
||||||
|
fn content_disposition(file_name: &str) -> String {
|
||||||
|
let safe: String = file_name
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"attachment; filename=\"{}\"",
|
||||||
|
if safe.is_empty() { "backup" } else { &safe }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pub(super) async fn download(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<Response> {
|
||||||
|
let item = owned_backup(&state, user.id, id)?;
|
||||||
|
let path = backup::resolve_path(&state, &item)
|
||||||
|
.map_err(|_| ApiError::missing("Backup file is not available"))?;
|
||||||
|
let file = tokio::fs::File::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::missing("Backup file is not available"))?;
|
||||||
|
let size = file.metadata().await.map_err(anyhow::Error::from)?.len();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||||
|
.header(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
content_disposition(&item.file_name),
|
||||||
|
)
|
||||||
|
.header(header::CONTENT_LENGTH, size)
|
||||||
|
.body(Body::from_stream(tokio_util::io::ReaderStream::new(file)))
|
||||||
|
.expect("valid download response"))
|
||||||
|
}
|
||||||
|
pub(super) async fn view(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let item = owned_backup(&state, user.id, id)?;
|
||||||
|
if item.backup_type != "export" {
|
||||||
|
return Err(ApiError::bad("Only export backups can be viewed"));
|
||||||
|
}
|
||||||
|
let content = backup::read_export(&state, &item).map_err(|e| ApiError::operation(&state, e))?;
|
||||||
|
Ok(Json(
|
||||||
|
json!({"content":content,"backup":serialize(&state,&item)?}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub(super) async fn email(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let item = owned_backup(&state, user.id, id)?;
|
||||||
|
backup::email(&state, &item)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::operation(&state, e))?;
|
||||||
|
Ok(Json(json!({"message":"Backup sent by email"})))
|
||||||
|
}
|
||||||
|
async fn compare(
|
||||||
|
state: AppState,
|
||||||
|
user_id: i64,
|
||||||
|
left_id: i64,
|
||||||
|
right_id: i64,
|
||||||
|
include_html: bool,
|
||||||
|
compact: bool,
|
||||||
|
) -> ApiResult<Value> {
|
||||||
|
let left = owned_backup(&state, user_id, left_id)?;
|
||||||
|
let right = owned_backup(&state, user_id, right_id)?;
|
||||||
|
if left.backup_type != "export" || right.backup_type != "export" {
|
||||||
|
return Err(ApiError::bad("Diff is supported only for export backups"));
|
||||||
|
}
|
||||||
|
let clone = state.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
if include_html {
|
||||||
|
diff::compare(&clone, &left, &right)
|
||||||
|
} else if compact {
|
||||||
|
diff::compare_browser_compact(&clone, &left, &right)
|
||||||
|
} else {
|
||||||
|
diff::compare_browser(&clone, &left, &right)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("Diff task failed"))?
|
||||||
|
.map_err(|e| ApiError::operation(&state, e))
|
||||||
|
}
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub(super) struct DiffQuery {
|
||||||
|
compact: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn diff_json(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path((left, right)): Path<(i64, i64)>,
|
||||||
|
Query(query): Query<DiffQuery>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
Ok(Json(
|
||||||
|
compare(
|
||||||
|
state,
|
||||||
|
user.id,
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
false,
|
||||||
|
query.compact.unwrap_or(false),
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub(super) async fn diff_html(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path((left, right)): Path<(i64, i64)>,
|
||||||
|
) -> ApiResult<Html<String>> {
|
||||||
|
Ok(Html(
|
||||||
|
compare(state, user.id, left, right, true, false).await?["diff_html"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct Bulk {
|
||||||
|
action: String,
|
||||||
|
backup_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_zip_name(file_name: &str, id: i64, names: &mut HashSet<String>) -> String {
|
||||||
|
let name = std::path::Path::new(file_name)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("backup")
|
||||||
|
.to_string();
|
||||||
|
if names.insert(name.clone()) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
// N occupied names cannot cover N + 1 distinct candidates.
|
||||||
|
for suffix in 0..=names.len() {
|
||||||
|
let candidate = if suffix == 0 {
|
||||||
|
format!("{id}_{name}")
|
||||||
|
} else {
|
||||||
|
format!("{id}_{suffix}_{name}")
|
||||||
|
};
|
||||||
|
if names.insert(candidate.clone()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreachable!("a bounded candidate set always contains an unused ZIP name")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn bulk(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Json(payload): Json<Bulk>,
|
||||||
|
) -> ApiResult<Response> {
|
||||||
|
if !["delete", "download"].contains(&payload.action.as_str()) {
|
||||||
|
return Err(ApiError::invalid("Unsupported bulk action"));
|
||||||
|
}
|
||||||
|
if payload.backup_ids.is_empty() || payload.backup_ids.len() > 1000 {
|
||||||
|
return Err(ApiError::invalid("Select between 1 and 1000 backups"));
|
||||||
|
}
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
// Authorize the complete set before any mutation.
|
||||||
|
let items: Vec<_> = payload
|
||||||
|
.backup_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| seen.insert(*id))
|
||||||
|
.map(|id| owned_backup(&state, user.id, id))
|
||||||
|
.collect::<ApiResult<_>>()?;
|
||||||
|
if payload.action == "delete" {
|
||||||
|
let count = items.len();
|
||||||
|
for item in &items {
|
||||||
|
backup::delete(&state, item).map_err(|e| ApiError::operation(&state, e))?;
|
||||||
|
}
|
||||||
|
return Ok(Json(json!({"message":format!("Deleted {count} backups")})).into_response());
|
||||||
|
}
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
let mut total = 0_u64;
|
||||||
|
for item in items {
|
||||||
|
let path = backup::resolve_path(&state, &item)
|
||||||
|
.map_err(|_| ApiError::missing("Backup file is not available"))?;
|
||||||
|
total = total.saturating_add(std::fs::metadata(&path).map_err(anyhow::Error::from)?.len());
|
||||||
|
if total > 2 * 1024 * 1024 * 1024 {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"Bulk download exceeds 2 GiB; select fewer files".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
paths.push((item, path));
|
||||||
|
}
|
||||||
|
let archive = tokio::task::spawn_blocking(move || -> anyhow::Result<std::fs::File> {
|
||||||
|
let file = tempfile::tempfile()?;
|
||||||
|
let mut writer = zip::ZipWriter::new(file);
|
||||||
|
let mut names = HashSet::new();
|
||||||
|
let options = zip::write::SimpleFileOptions::default()
|
||||||
|
.compression_method(zip::CompressionMethod::Deflated)
|
||||||
|
.unix_permissions(0o600);
|
||||||
|
for (item, path) in paths {
|
||||||
|
let name = unique_zip_name(&item.file_name, item.id, &mut names);
|
||||||
|
writer.start_file(name, options)?;
|
||||||
|
std::io::copy(&mut std::fs::File::open(path)?, &mut writer)?;
|
||||||
|
}
|
||||||
|
let mut file = writer.finish()?;
|
||||||
|
file.seek(SeekFrom::Start(0))?;
|
||||||
|
Ok(file)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("ZIP task failed"))??;
|
||||||
|
let size = archive.metadata().map_err(anyhow::Error::from)?.len();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, "application/zip")
|
||||||
|
.header(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
"attachment; filename=\"backups.zip\"",
|
||||||
|
)
|
||||||
|
.header(header::CONTENT_LENGTH, size)
|
||||||
|
.body(Body::from_stream(tokio_util::io::ReaderStream::new(
|
||||||
|
tokio::fs::File::from_std(archive),
|
||||||
|
)))
|
||||||
|
.expect("valid ZIP response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::unique_zip_name;
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
io::{Cursor, Read, Write},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zip_round_trip_preserves_files_with_colliding_generated_names() {
|
||||||
|
for entries in [
|
||||||
|
[
|
||||||
|
(1, "a.rsc", "first"),
|
||||||
|
(2, "a.rsc", "second"),
|
||||||
|
(3, "2_a.rsc", "third"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
(1, "a.rsc", "first"),
|
||||||
|
(3, "2_a.rsc", "third"),
|
||||||
|
(2, "a.rsc", "second"),
|
||||||
|
],
|
||||||
|
] {
|
||||||
|
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
let mut names = HashSet::new();
|
||||||
|
for (id, name, content) in entries {
|
||||||
|
writer
|
||||||
|
.start_file(
|
||||||
|
unique_zip_name(name, id, &mut names),
|
||||||
|
zip::write::SimpleFileOptions::default()
|
||||||
|
.compression_method(zip::CompressionMethod::Stored),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
writer.write_all(content.as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
let mut archive = zip::ZipArchive::new(writer.finish().unwrap()).unwrap();
|
||||||
|
let archived_names: HashSet<_> = archive
|
||||||
|
.file_names()
|
||||||
|
.map(|name| name.unwrap().into_owned())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(archive.len(), 3);
|
||||||
|
assert_eq!(archived_names.len(), 3);
|
||||||
|
assert_eq!(names, archived_names);
|
||||||
|
let mut contents = Vec::new();
|
||||||
|
for name in archived_names {
|
||||||
|
let mut content = String::new();
|
||||||
|
archive
|
||||||
|
.by_name(&name)
|
||||||
|
.unwrap()
|
||||||
|
.read_to_string(&mut content)
|
||||||
|
.unwrap();
|
||||||
|
contents.push(content);
|
||||||
|
}
|
||||||
|
contents.sort();
|
||||||
|
assert_eq!(contents, ["first", "second", "third"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zip_name_skips_preexisting_suffixes_and_reserves_the_result() {
|
||||||
|
let mut names = HashSet::from(["a.rsc".into(), "7_a.rsc".into()]);
|
||||||
|
names.extend((1..998).map(|suffix| format!("7_{suffix}_a.rsc")));
|
||||||
|
let name = unique_zip_name("a.rsc", 7, &mut names);
|
||||||
|
assert_eq!(name, "7_998_a.rsc");
|
||||||
|
assert!(names.contains(&name));
|
||||||
|
assert_eq!(names.len(), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
use super::{ApiError, ApiResult, auth::Auth};
|
||||||
|
use crate::{AppState, backup, models::RouterRecord, routeros};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use rusqlite::params;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
pub(super) fn owned_router(state: &AppState, user_id: i64, id: i64) -> ApiResult<RouterRecord> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.router(id)?
|
||||||
|
.filter(|r| r.owner_id == user_id)
|
||||||
|
.ok_or_else(|| ApiError::missing("Device not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub(super) struct RouterInput {
|
||||||
|
name: Option<String>,
|
||||||
|
device_type: Option<String>,
|
||||||
|
host: Option<String>,
|
||||||
|
port: Option<i64>,
|
||||||
|
ssh_user: Option<String>,
|
||||||
|
use_global_switchos_credentials: Option<bool>,
|
||||||
|
ssh_key: Option<String>,
|
||||||
|
ssh_password: Option<String>,
|
||||||
|
disable_all_backups: Option<bool>,
|
||||||
|
disable_export_backups: Option<bool>,
|
||||||
|
disable_binary_backups: Option<bool>,
|
||||||
|
disable_ping: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
clear_ssh_key: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
clear_ssh_password: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(router: &RouterRecord) -> ApiResult<()> {
|
||||||
|
if router.name.is_empty()
|
||||||
|
|| router.name.len() > 120
|
||||||
|
|| !router
|
||||||
|
.name
|
||||||
|
.bytes()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-')
|
||||||
|
{
|
||||||
|
return Err(ApiError::invalid(
|
||||||
|
"Device name allows only letters, digits, dashes and underscores (1–120 characters)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !["routeros", "switchos"].contains(&router.device_type.as_str()) {
|
||||||
|
return Err(ApiError::invalid("Unsupported device type"));
|
||||||
|
}
|
||||||
|
if router.host.is_empty()
|
||||||
|
|| router.host.len() > 255
|
||||||
|
|| router
|
||||||
|
.host
|
||||||
|
.chars()
|
||||||
|
.any(|c| c.is_whitespace() || c.is_control())
|
||||||
|
{
|
||||||
|
return Err(ApiError::invalid("Invalid device host"));
|
||||||
|
}
|
||||||
|
if router.device_type == "switchos" && router.host.contains("://") {
|
||||||
|
let url = reqwest::Url::parse(&router.host)
|
||||||
|
.map_err(|_| ApiError::invalid("Invalid SwitchOS URL"))?;
|
||||||
|
if !matches!(url.scheme(), "http" | "https")
|
||||||
|
|| url.host_str().is_none()
|
||||||
|
|| !url.username().is_empty()
|
||||||
|
|| url.password().is_some()
|
||||||
|
|| url.query().is_some()
|
||||||
|
|| url.fragment().is_some()
|
||||||
|
|| url.path() != "/"
|
||||||
|
|| router.host.contains('\\')
|
||||||
|
{
|
||||||
|
return Err(ApiError::invalid(
|
||||||
|
"SwitchOS URL must be an http(s) origin without credentials, a path, query or fragment",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if router
|
||||||
|
.host
|
||||||
|
.chars()
|
||||||
|
.any(|c| matches!(c, '/' | '\\' | '@' | '?' | '#'))
|
||||||
|
{
|
||||||
|
return Err(ApiError::invalid(
|
||||||
|
"Host must be a hostname or IP address; only SwitchOS also accepts an http(s) URL",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !(1..=65535).contains(&router.port) {
|
||||||
|
return Err(ApiError::invalid("Port must be between 1 and 65535"));
|
||||||
|
}
|
||||||
|
if router.ssh_user.chars().count() > 120 || router.ssh_user.chars().any(char::is_control) {
|
||||||
|
return Err(ApiError::invalid("Invalid device username"));
|
||||||
|
}
|
||||||
|
if router.ssh_key.as_ref().is_some_and(|s| s.len() > 65536)
|
||||||
|
|| router.ssh_password.as_ref().is_some_and(|s| s.len() > 1024)
|
||||||
|
{
|
||||||
|
return Err(ApiError::invalid("Device credential is too long"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(router: &mut RouterRecord, payload: RouterInput) -> ApiResult<()> {
|
||||||
|
let old_type = router.device_type.clone();
|
||||||
|
if let Some(value) = payload.name {
|
||||||
|
router.name = value.trim().into();
|
||||||
|
}
|
||||||
|
if let Some(value) = payload.device_type {
|
||||||
|
router.device_type = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = payload.host {
|
||||||
|
router.host = value.trim().into();
|
||||||
|
}
|
||||||
|
if let Some(value) = payload.port {
|
||||||
|
router.port = value;
|
||||||
|
} else if old_type != router.device_type {
|
||||||
|
router.port = if router.device_type == "switchos" {
|
||||||
|
80
|
||||||
|
} else {
|
||||||
|
22
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(value) = payload.ssh_user {
|
||||||
|
router.ssh_user = value.trim().into();
|
||||||
|
} else if old_type != router.device_type {
|
||||||
|
router.ssh_user = if router.device_type == "routeros" {
|
||||||
|
"admin".into()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(value) = payload.ssh_key.filter(|s| !s.trim().is_empty()) {
|
||||||
|
router.ssh_key = Some(value.trim().into());
|
||||||
|
}
|
||||||
|
if let Some(value) = payload.ssh_password.filter(|s| !s.is_empty()) {
|
||||||
|
router.ssh_password = Some(value);
|
||||||
|
}
|
||||||
|
if let Some(value) = payload.use_global_switchos_credentials {
|
||||||
|
router.use_global_switchos_credentials = Some(value);
|
||||||
|
}
|
||||||
|
if payload.clear_ssh_key || router.device_type == "switchos" {
|
||||||
|
router.ssh_key = None;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if let Some(value) = payload.disable_export_backups {
|
||||||
|
router.disable_export_backups = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = payload.disable_binary_backups {
|
||||||
|
router.disable_binary_backups = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = payload.disable_ping {
|
||||||
|
router.disable_ping = value;
|
||||||
|
}
|
||||||
|
validate(router)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let settings = state.db.get_settings()?;
|
||||||
|
let mut routers = state.db.routers(Some(user.id))?;
|
||||||
|
routers.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id)));
|
||||||
|
Ok(Json(json!(
|
||||||
|
routers
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.public_json(&settings))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
pub(super) async fn get_one(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
Ok(Json(
|
||||||
|
owned_router(&state, user.id, id)?.public_json(&state.db.get_settings()?),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub(super) async fn create(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Json(payload): Json<RouterInput>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let mut router = RouterRecord {
|
||||||
|
id: 0,
|
||||||
|
owner_id: user.id,
|
||||||
|
name: String::new(),
|
||||||
|
host: String::new(),
|
||||||
|
device_type: "routeros".into(),
|
||||||
|
port: 22,
|
||||||
|
ssh_user: "admin".into(),
|
||||||
|
use_global_switchos_credentials: None,
|
||||||
|
ssh_key: None,
|
||||||
|
ssh_password: None,
|
||||||
|
last_connection_status: None,
|
||||||
|
last_connection_tested_at: None,
|
||||||
|
last_connection_error: None,
|
||||||
|
last_connection_hostname: None,
|
||||||
|
last_connection_model: None,
|
||||||
|
last_connection_version: None,
|
||||||
|
last_connection_uptime: None,
|
||||||
|
last_connection_transport: None,
|
||||||
|
last_connection_server: None,
|
||||||
|
last_connection_auth_mode: None,
|
||||||
|
last_connection_http_status: None,
|
||||||
|
last_connection_backup_available: None,
|
||||||
|
disable_all_backups: false,
|
||||||
|
disable_export_backups: false,
|
||||||
|
disable_binary_backups: false,
|
||||||
|
disable_ping: false,
|
||||||
|
created_at: String::new(),
|
||||||
|
};
|
||||||
|
apply(&mut router, payload)?;
|
||||||
|
let id = state.db.with_conn(|conn| {
|
||||||
|
conn.execute("INSERT INTO routers (owner_id,name,device_type,host,port,ssh_user,ssh_key,ssh_password,disable_all_backups,disable_export_backups,disable_binary_backups,disable_ping,use_global_switchos_credentials,created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,CURRENT_TIMESTAMP)", params![router.owner_id,router.name,router.device_type,router.host,router.port,router.ssh_user,router.ssh_key,router.ssh_password,router.disable_all_backups,router.disable_export_backups,router.disable_binary_backups,router.disable_ping,router.use_global_switchos_credentials])?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
})?;
|
||||||
|
state.db.log(&format!("Device {} created", router.name))?;
|
||||||
|
Ok(Json(
|
||||||
|
owned_router(&state, user.id, id)?.public_json(&state.db.get_settings()?),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub(super) async fn update(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(payload): Json<RouterInput>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let mut router = owned_router(&state, user.id, id)?;
|
||||||
|
apply(&mut router, payload)?;
|
||||||
|
{
|
||||||
|
let locks = state
|
||||||
|
.device_locks
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Device locks unavailable"))?;
|
||||||
|
if locks.contains(&id) {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"A backup operation is running for this device".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state.db.with_conn(|conn| {
|
||||||
|
conn.execute("UPDATE routers SET name=?1,device_type=?2,host=?3,port=?4,ssh_user=?5,ssh_key=?6,ssh_password=?7,disable_all_backups=?8,disable_export_backups=?9,disable_binary_backups=?10,disable_ping=?11,use_global_switchos_credentials=?12 WHERE id=?13 AND owner_id=?14",params![router.name,router.device_type,router.host,router.port,router.ssh_user,router.ssh_key,router.ssh_password,router.disable_all_backups,router.disable_export_backups,router.disable_binary_backups,router.disable_ping,router.use_global_switchos_credentials,id,user.id])?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
state.db.log(&format!("Device {} updated", router.name))?;
|
||||||
|
Ok(Json(
|
||||||
|
owned_router(&state, user.id, id)?.public_json(&state.db.get_settings()?),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub(super) async fn remove(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let router = owned_router(&state, user.id, id)?;
|
||||||
|
let locks = state
|
||||||
|
.device_locks
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Device locks unavailable"))?;
|
||||||
|
if locks.contains(&id) {
|
||||||
|
return Err(ApiError(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"A backup operation is running for this device".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for item in state
|
||||||
|
.db
|
||||||
|
.backups(Some(user.id))?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|b| b.router_id == id)
|
||||||
|
{
|
||||||
|
backup::delete(&state, &item).map_err(|e| ApiError::operation(&state, e))?;
|
||||||
|
}
|
||||||
|
state.db.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM routers WHERE id=?1 AND owner_id=?2",
|
||||||
|
params![id, user.id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
drop(locks);
|
||||||
|
state.db.log(&format!("Device {} deleted", router.name))?;
|
||||||
|
Ok(Json(json!({"message":"Device deleted"})))
|
||||||
|
}
|
||||||
|
pub(super) async fn ping(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let items = routeros::ping_many(&state, state.db.routers(Some(user.id))?)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::operation(&state, e))?;
|
||||||
|
Ok(Json(json!({"items":items})))
|
||||||
|
}
|
||||||
|
pub(super) async fn test_connection(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let router = owned_router(&state, user.id, id)?;
|
||||||
|
Ok(Json(
|
||||||
|
routeros::test_connection(&state, &router)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::operation(&state, e))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! Embedded OpenAPI and Swagger UI, served from this process without a CDN.
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::State,
|
||||||
|
http::header,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
pub(super) async fn specification(State(state): State<AppState>) -> Json<Value> {
|
||||||
|
let mut document: Value = serde_json::from_str(include_str!("../../docs/openapi.json"))
|
||||||
|
.expect("checked-in OpenAPI document is valid JSON");
|
||||||
|
document["info"]["title"] = json!(state.config.app_name);
|
||||||
|
document["info"]["version"] = json!(env!("CARGO_PKG_VERSION"));
|
||||||
|
document["servers"][0]["url"] = json!(state.config.api_prefix);
|
||||||
|
document["components"]["securitySchemes"]["OAuth2PasswordBearer"]["flows"]["password"]["tokenUrl"] =
|
||||||
|
json!(format!("{}/auth/login", state.config.api_prefix));
|
||||||
|
Json(document)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn page() -> Response {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||||
|
include_str!(concat!(env!("OUT_DIR"), "/docs.html")),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn legacy_redoc() -> Redirect {
|
||||||
|
Redirect::permanent("/docs")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn javascript() -> Response {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
|
||||||
|
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
|
||||||
|
],
|
||||||
|
include_str!(concat!(env!("OUT_DIR"), "/", env!("DOCS_JS_FILE"))),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn stylesheet() -> Response {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
|
||||||
|
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
|
||||||
|
],
|
||||||
|
include_str!(concat!(env!("OUT_DIR"), "/", env!("DOCS_CSS_FILE"))),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
+353
@@ -0,0 +1,353 @@
|
|||||||
|
//! HTTP contract compatible with the original /api endpoints.
|
||||||
|
mod auth;
|
||||||
|
mod backups;
|
||||||
|
mod devices;
|
||||||
|
mod documentation;
|
||||||
|
mod system;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
body::Body,
|
||||||
|
extract::{DefaultBodyLimit, State},
|
||||||
|
http::{HeaderValue, StatusCode, header},
|
||||||
|
middleware::{self, Next},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{delete, get, post, put},
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::{error::Error, time::Duration};
|
||||||
|
use tower_http::{
|
||||||
|
compression::CompressionLayer,
|
||||||
|
cors::{AllowOrigin, CorsLayer},
|
||||||
|
trace::TraceLayer,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) type ApiResult<T> = Result<T, ApiError>;
|
||||||
|
|
||||||
|
pub(crate) struct ApiError(pub StatusCode, pub String);
|
||||||
|
impl ApiError {
|
||||||
|
pub fn bad(message: impl Into<String>) -> Self {
|
||||||
|
Self(StatusCode::BAD_REQUEST, message.into())
|
||||||
|
}
|
||||||
|
pub fn invalid(message: impl Into<String>) -> Self {
|
||||||
|
Self(StatusCode::UNPROCESSABLE_ENTITY, message.into())
|
||||||
|
}
|
||||||
|
pub fn missing(message: &str) -> Self {
|
||||||
|
Self(StatusCode::NOT_FOUND, message.into())
|
||||||
|
}
|
||||||
|
pub fn unauthorized() -> Self {
|
||||||
|
Self(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Could not validate credentials".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pub fn operation(state: &AppState, error: anyhow::Error) -> Self {
|
||||||
|
let mut message = error.to_string();
|
||||||
|
let mut secrets: Vec<String> = Vec::new();
|
||||||
|
// Adapter errors may contain upstream messages. Never return configured credentials.
|
||||||
|
if let Ok(settings) = state.db.get_settings() {
|
||||||
|
for secret in [
|
||||||
|
&settings.global_ssh_key,
|
||||||
|
&settings.default_switchos_password,
|
||||||
|
&settings.smtp_password,
|
||||||
|
&settings.pushover_token,
|
||||||
|
&settings.pushover_userkey,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
if !secret.is_empty() {
|
||||||
|
secrets.push(secret.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(routers) = state.db.routers(None) {
|
||||||
|
for router in routers {
|
||||||
|
for secret in [router.ssh_key, router.ssh_password].into_iter().flatten() {
|
||||||
|
if !secret.is_empty() {
|
||||||
|
secrets.push(secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secrets.sort_by_key(|secret| std::cmp::Reverse(secret.len()));
|
||||||
|
for secret in secrets {
|
||||||
|
message = message.replace(&secret, "[REDACTED]");
|
||||||
|
}
|
||||||
|
if message.contains("PRIVATE KEY") || message.len() > 512 {
|
||||||
|
message = "Device operation failed".into();
|
||||||
|
}
|
||||||
|
Self(StatusCode::BAD_GATEWAY, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<anyhow::Error> for ApiError {
|
||||||
|
fn from(_: anyhow::Error) -> Self {
|
||||||
|
tracing::error!("An internal operation failed; credentials and file contents omitted");
|
||||||
|
Self(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal operation failed".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<rusqlite::Error> for ApiError {
|
||||||
|
fn from(value: rusqlite::Error) -> Self {
|
||||||
|
anyhow::Error::from(value).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let mut response = (self.0, axum::Json(json!({"detail": self.1}))).into_response();
|
||||||
|
if self.0 == StatusCode::UNAUTHORIZED {
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer"));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
let api = Router::new()
|
||||||
|
.route("/health", get(system::health))
|
||||||
|
.route("/auth/login", post(auth::login))
|
||||||
|
.route("/auth/register", post(auth::register))
|
||||||
|
.route("/auth/me", get(auth::me))
|
||||||
|
.route("/auth/preferences", put(auth::preferences))
|
||||||
|
.route("/auth/change-password", post(auth::change_password))
|
||||||
|
.route("/dashboard", get(system::dashboard))
|
||||||
|
.route("/routers", get(devices::list).post(devices::create))
|
||||||
|
.route("/routers/ping-statuses", get(devices::ping))
|
||||||
|
.route(
|
||||||
|
"/routers/{id}",
|
||||||
|
get(devices::get_one)
|
||||||
|
.put(devices::update)
|
||||||
|
.delete(devices::remove),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/routers/{id}/test-connection",
|
||||||
|
get(devices::test_connection),
|
||||||
|
)
|
||||||
|
.route("/backups", get(backups::list))
|
||||||
|
.route("/backups/router/{id}", get(backups::for_router))
|
||||||
|
.route("/backups/routers/export-all", post(backups::export_all))
|
||||||
|
.route("/backups/routers/binary-all", post(backups::binary_all))
|
||||||
|
.route("/backups/router/{id}/export", post(backups::export))
|
||||||
|
.route("/backups/router/{id}/binary", post(backups::binary))
|
||||||
|
.route(
|
||||||
|
"/backups/router/{id}/upload/{backup_id}",
|
||||||
|
post(backups::upload),
|
||||||
|
)
|
||||||
|
.route("/backups/bulk", post(backups::bulk))
|
||||||
|
.route("/backups/{id}", delete(backups::remove))
|
||||||
|
.route("/backups/{id}/download", get(backups::download))
|
||||||
|
.route("/backups/{id}/view", get(backups::view))
|
||||||
|
.route("/backups/{id}/email", post(backups::email))
|
||||||
|
.route("/backups/{left}/diff/{right}", get(backups::diff_json))
|
||||||
|
.route("/backups/{left}/diff/{right}/html", get(backups::diff_html))
|
||||||
|
.route(
|
||||||
|
"/settings",
|
||||||
|
get(system::settings).put(system::update_settings),
|
||||||
|
)
|
||||||
|
.route("/settings/scheduler-status", get(system::scheduler_status))
|
||||||
|
.route("/settings/reveal-ssh-key", post(system::reveal_ssh_key))
|
||||||
|
.route("/settings/test-email", post(system::test_email))
|
||||||
|
.route("/settings/test-pushover", post(system::test_pushover))
|
||||||
|
.route("/logs", get(system::logs))
|
||||||
|
.route("/logs/older-than/{days}", delete(system::delete_logs))
|
||||||
|
.fallback(|| async { ApiError::missing("API endpoint not found") });
|
||||||
|
let origins: Vec<HeaderValue> = state
|
||||||
|
.config
|
||||||
|
.cors_origins
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| s.parse().ok())
|
||||||
|
.collect();
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(AllowOrigin::list(origins))
|
||||||
|
.allow_methods([
|
||||||
|
axum::http::Method::GET,
|
||||||
|
axum::http::Method::POST,
|
||||||
|
axum::http::Method::PUT,
|
||||||
|
axum::http::Method::DELETE,
|
||||||
|
axum::http::Method::OPTIONS,
|
||||||
|
])
|
||||||
|
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
|
||||||
|
.max_age(Duration::from_secs(3600));
|
||||||
|
Router::new()
|
||||||
|
.nest(&state.config.api_prefix, api)
|
||||||
|
.route("/health", get(system::health))
|
||||||
|
.route("/docs", get(documentation::page))
|
||||||
|
.route("/docs/", get(documentation::page))
|
||||||
|
.route("/redoc", get(documentation::legacy_redoc))
|
||||||
|
.route("/openapi.json", get(documentation::specification))
|
||||||
|
.route(env!("DOCS_JS_ROUTE"), get(documentation::javascript))
|
||||||
|
.route(env!("DOCS_CSS_ROUTE"), get(documentation::stylesheet))
|
||||||
|
.route(env!("APP_JS_ROUTE"), get(javascript))
|
||||||
|
.route(env!("APP_CSS_ROUTE"), get(stylesheet))
|
||||||
|
.route("/favicon.ico", get(favicon))
|
||||||
|
.fallback(index)
|
||||||
|
.layer(DefaultBodyLimit::max(1024 * 1024))
|
||||||
|
.layer(middleware::from_fn(bounded_request_body))
|
||||||
|
.layer(middleware::from_fn(security_headers))
|
||||||
|
.layer(CompressionLayer::new())
|
||||||
|
.layer(cors)
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
match tokio::time::timeout(deadline, axum::body::to_bytes(body, 1024 * 1024)).await {
|
||||||
|
Ok(Ok(bytes)) => Ok(bytes),
|
||||||
|
Ok(Err(error))
|
||||||
|
if error
|
||||||
|
.source()
|
||||||
|
.is_some_and(|source| source.is::<http_body_util::LengthLimitError>()) =>
|
||||||
|
{
|
||||||
|
Err(ApiError(
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"Request body exceeds 1 MiB".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Ok(Err(_)) => Err(ApiError::bad("Cannot read request body")),
|
||||||
|
Err(_) => Err(ApiError(
|
||||||
|
StatusCode::REQUEST_TIMEOUT,
|
||||||
|
"Request body deadline exceeded".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bounded_request_body(request: axum::extract::Request, next: Next) -> Response {
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
match collect_request_body(body, Duration::from_secs(15)).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
next.run(axum::extract::Request::from_parts(parts, Body::from(bytes)))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(error) => error.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn security_headers(request: axum::extract::Request, next: Next) -> Response {
|
||||||
|
let mut response = next.run(request).await;
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
headers.insert(
|
||||||
|
header::X_CONTENT_TYPE_OPTIONS,
|
||||||
|
HeaderValue::from_static("nosniff"),
|
||||||
|
);
|
||||||
|
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||||
|
headers.insert(
|
||||||
|
header::REFERRER_POLICY,
|
||||||
|
HeaderValue::from_static("no-referrer"),
|
||||||
|
);
|
||||||
|
headers.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"));
|
||||||
|
headers
|
||||||
|
.entry(header::CACHE_CONTROL)
|
||||||
|
.or_insert(HeaderValue::from_static("no-store"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn javascript() -> Response {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
|
||||||
|
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
|
||||||
|
],
|
||||||
|
include_str!(concat!(env!("OUT_DIR"), "/", env!("APP_JS_FILE"))),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
async fn stylesheet() -> Response {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
|
||||||
|
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
|
||||||
|
],
|
||||||
|
include_str!(concat!(env!("OUT_DIR"), "/", env!("APP_CSS_FILE"))),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
async fn favicon() -> StatusCode {
|
||||||
|
StatusCode::NO_CONTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
uri: axum::http::Uri,
|
||||||
|
method: axum::http::Method,
|
||||||
|
) -> Response {
|
||||||
|
let path = uri.path();
|
||||||
|
if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
|
||||||
|
return StatusCode::METHOD_NOT_ALLOWED.into_response();
|
||||||
|
}
|
||||||
|
let valid = matches!(
|
||||||
|
path,
|
||||||
|
"/" | "/login"
|
||||||
|
| "/register"
|
||||||
|
| "/devices"
|
||||||
|
| "/routers"
|
||||||
|
| "/files"
|
||||||
|
| "/backups"
|
||||||
|
| "/diff-configs"
|
||||||
|
| "/logs"
|
||||||
|
| "/settings"
|
||||||
|
| "/change-password"
|
||||||
|
| "/schedule"
|
||||||
|
| "/history"
|
||||||
|
) || ["/devices/", "/routers/"].iter().any(|prefix| {
|
||||||
|
path.strip_prefix(prefix)
|
||||||
|
.is_some_and(|s| s.parse::<i64>().is_ok())
|
||||||
|
});
|
||||||
|
if !valid {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
let html = include_str!(concat!(env!("OUT_DIR"), "/index.html"))
|
||||||
|
.replace(
|
||||||
|
"name=\"api-prefix\" content=\"/api\"",
|
||||||
|
&format!(
|
||||||
|
"name=\"api-prefix\" content=\"{}\"",
|
||||||
|
state.config.api_prefix
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
"name=\"allow-registration\" content=\"false\"",
|
||||||
|
&format!(
|
||||||
|
"name=\"allow-registration\" content=\"{}\"",
|
||||||
|
state.config.allow_registration
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Response::builder()
|
||||||
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
||||||
|
.body(Body::from(html))
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn incomplete_http_body_has_a_deadline() {
|
||||||
|
let (mut client, server) = tokio::io::duplex(32);
|
||||||
|
client.write_all(b"{").await.unwrap();
|
||||||
|
let body = Body::from_stream(tokio_util::io::ReaderStream::new(server));
|
||||||
|
let error = collect_request_body(body, Duration::from_millis(20))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.0, StatusCode::REQUEST_TIMEOUT);
|
||||||
|
drop(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oversized_body_is_rejected_before_authentication_work() {
|
||||||
|
let body = Body::from(vec![b'x'; 1024 * 1024 + 1]);
|
||||||
|
let error = collect_request_body(body, Duration::from_secs(1))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.0, StatusCode::PAYLOAD_TOO_LARGE);
|
||||||
|
let valid = collect_request_body(Body::from("{}"), Duration::from_secs(1)).await;
|
||||||
|
assert!(valid.is_ok_and(|body| body == "{}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
use super::{ApiError, ApiResult, auth::Auth};
|
||||||
|
use crate::{AppState, backup, models::OperationLog, notifications, scheduler, security};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
};
|
||||||
|
use rusqlite::params;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
pub(super) async fn health(State(state): State<AppState>) -> ApiResult<Json<Value>> {
|
||||||
|
state.db.with_conn(|conn| {
|
||||||
|
conn.query_row("SELECT 1", [], |row| row.get::<_, i64>(0))?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(Json(
|
||||||
|
json!({"status":"ok","version":env!("CARGO_PKG_VERSION"),"timestamp":chrono::Utc::now().to_rfc3339()}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
fn read_logs(state: &AppState, limit: i64) -> anyhow::Result<Vec<OperationLog>> {
|
||||||
|
state.db.with_conn(|conn| {
|
||||||
|
let mut statement = conn.prepare("SELECT id,message,timestamp FROM operation_logs ORDER BY timestamp DESC,id DESC LIMIT ?1")?;
|
||||||
|
Ok(statement.query_map([limit],|row| Ok(OperationLog {id:row.get(0)?,message:row.get(1)?,timestamp:crate::db::normalize_timestamp(row.get(2)?)}))?.collect::<Result<Vec<_>,_>>()?)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(super) async fn dashboard(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let records = state.db.backups(Some(user.id))?;
|
||||||
|
let exports = records.iter().filter(|b| b.backup_type == "export").count();
|
||||||
|
let binaries = records.iter().filter(|b| b.backup_type == "binary").count();
|
||||||
|
Ok(Json(
|
||||||
|
json!({"routers_count":state.db.routers(Some(user.id))?.len(),"export_count":exports,"binary_count":binaries,
|
||||||
|
"total_backups":exports+binaries,"storage":backup::storage_stats(&state)?,"recent_logs":read_logs(&state,10)?}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub(super) async fn settings(State(state): State<AppState>, _: Auth) -> ApiResult<Json<Value>> {
|
||||||
|
Ok(Json(state.db.get_settings()?.public_json()))
|
||||||
|
}
|
||||||
|
pub(super) async fn update_settings(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_: Auth,
|
||||||
|
Json(payload): Json<Value>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let mut settings = state.db.get_settings()?;
|
||||||
|
settings
|
||||||
|
.apply_patch(&payload)
|
||||||
|
.map_err(|e| ApiError::invalid(e.to_string()))?;
|
||||||
|
for cron in [
|
||||||
|
&settings.export_cron,
|
||||||
|
&settings.binary_cron,
|
||||||
|
&settings.retention_cron,
|
||||||
|
] {
|
||||||
|
if !cron.is_empty() {
|
||||||
|
scheduler::validate_cron(cron, &state.config.timezone)
|
||||||
|
.map_err(|e| ApiError::invalid(e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.db.save_settings(&settings)?;
|
||||||
|
state.db.log("System settings updated")?;
|
||||||
|
Ok(Json(settings.public_json()))
|
||||||
|
}
|
||||||
|
pub(super) async fn scheduler_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_: Auth,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
Ok(Json(scheduler::status(&state)?))
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct RevealKey {
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
pub(super) async fn reveal_ssh_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Auth(user): Auth,
|
||||||
|
Json(payload): Json<RevealKey>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let valid = super::auth::password_work(&state, move || {
|
||||||
|
security::verify_password(&payload.password, &user.password_hash)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if !valid {
|
||||||
|
return Err(ApiError::bad("Current password is invalid"));
|
||||||
|
}
|
||||||
|
Ok(Json(
|
||||||
|
json!({"global_ssh_key":state.db.get_settings()?.global_ssh_key}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub(super) async fn test_email(State(state): State<AppState>, _: Auth) -> ApiResult<Json<Value>> {
|
||||||
|
notifications::test_email(&state)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::operation(&state, e))?;
|
||||||
|
Ok(Json(json!({"message":"Test email sent"})))
|
||||||
|
}
|
||||||
|
pub(super) async fn test_pushover(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_: Auth,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
notifications::test_pushover(&state)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::operation(&state, e))?;
|
||||||
|
Ok(Json(json!({"message":"Test pushover sent"})))
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct LogQuery {
|
||||||
|
limit: Option<i64>,
|
||||||
|
}
|
||||||
|
pub(super) async fn logs(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_: Auth,
|
||||||
|
Query(query): Query<LogQuery>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
let limit = query.limit.unwrap_or(100);
|
||||||
|
if !(1..=500).contains(&limit) {
|
||||||
|
return Err(ApiError::invalid("Limit must be between 1 and 500"));
|
||||||
|
}
|
||||||
|
Ok(Json(json!(read_logs(&state, limit)?)))
|
||||||
|
}
|
||||||
|
pub(super) async fn delete_logs(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_: Auth,
|
||||||
|
Path(days): Path<i64>,
|
||||||
|
) -> ApiResult<Json<Value>> {
|
||||||
|
if !(1..=36500).contains(&days) {
|
||||||
|
return Err(ApiError::bad("Days must be between 1 and 36500"));
|
||||||
|
}
|
||||||
|
let cutoff = (chrono::Utc::now() - chrono::Duration::days(days))
|
||||||
|
.format("%Y-%m-%d %H:%M:%S")
|
||||||
|
.to_string();
|
||||||
|
let deleted = state.db.with_conn(|conn| {
|
||||||
|
Ok(conn.execute(
|
||||||
|
"DELETE FROM operation_logs WHERE datetime(timestamp)<datetime(?1)",
|
||||||
|
params![cutoff],
|
||||||
|
)?)
|
||||||
|
})?;
|
||||||
|
Ok(Json(
|
||||||
|
json!({"message":format!("Deleted {deleted} logs"),"deleted":deleted}),
|
||||||
|
))
|
||||||
|
}
|
||||||
+843
@@ -0,0 +1,843 @@
|
|||||||
|
//! Backup lifecycle with device-level exclusion, bounded transfers and atomic files.
|
||||||
|
use crate::{
|
||||||
|
AppState,
|
||||||
|
config::{create_private_dir, protect_file},
|
||||||
|
models::{BackupRecord, RouterRecord},
|
||||||
|
notifications, routeros,
|
||||||
|
};
|
||||||
|
use anyhow::{Context, Result, bail, ensure};
|
||||||
|
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
fs::{self, File},
|
||||||
|
io::{Read, Seek, SeekFrom, Write},
|
||||||
|
path::{Component, Path, PathBuf},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DeviceGuard {
|
||||||
|
locks: Arc<Mutex<HashSet<i64>>>,
|
||||||
|
id: i64,
|
||||||
|
}
|
||||||
|
impl DeviceGuard {
|
||||||
|
fn acquire(state: &AppState, id: i64) -> Result<Self> {
|
||||||
|
let mut locks = state
|
||||||
|
.device_locks
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Device lock is unavailable"))?;
|
||||||
|
ensure!(
|
||||||
|
locks.insert(id),
|
||||||
|
"Another backup or upload is already running for this device"
|
||||||
|
);
|
||||||
|
Ok(Self {
|
||||||
|
locks: state.device_locks.clone(),
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for DeviceGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(mut locks) = self.locks.lock() {
|
||||||
|
locks.remove(&self.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checksum(path: &Path) -> Result<String> {
|
||||||
|
checksum_file(&mut File::open(path)?)
|
||||||
|
}
|
||||||
|
fn checksum_file(file: &mut File) -> Result<String> {
|
||||||
|
let mut digest = Sha256::new();
|
||||||
|
let mut buffer = [0u8; 65536];
|
||||||
|
loop {
|
||||||
|
let n = file.read(&mut buffer)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
digest.update(&buffer[..n]);
|
||||||
|
}
|
||||||
|
Ok(format!("{:x}", digest.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_name(name: &str) -> Result<()> {
|
||||||
|
ensure!(
|
||||||
|
!name.is_empty()
|
||||||
|
&& Path::new(name)
|
||||||
|
.components()
|
||||||
|
.all(|c| matches!(c, Component::Normal(_)))
|
||||||
|
&& !name.contains(['/', '\\'])
|
||||||
|
&& !name.chars().any(char::is_control)
|
||||||
|
&& name != "."
|
||||||
|
&& name != "..",
|
||||||
|
"Invalid backup filename"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_path(state: &AppState, backup: &BackupRecord) -> Result<PathBuf> {
|
||||||
|
resolve_path_in(&state.config.data_dir, backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path_in(data_dir: &Path, backup: &BackupRecord) -> Result<PathBuf> {
|
||||||
|
validate_name(&backup.file_name)?;
|
||||||
|
let root = data_dir
|
||||||
|
.canonicalize()
|
||||||
|
.context("Backup directory is unavailable")?;
|
||||||
|
let raw = Path::new(&backup.file_path);
|
||||||
|
ensure!(
|
||||||
|
!raw.components()
|
||||||
|
.any(|part| matches!(part, Component::ParentDir)),
|
||||||
|
"Unsafe backup path"
|
||||||
|
);
|
||||||
|
let candidate = if raw.is_absolute() {
|
||||||
|
raw.to_path_buf()
|
||||||
|
} else {
|
||||||
|
root.join(raw)
|
||||||
|
};
|
||||||
|
let canonical = candidate.canonicalize().context("Backup file is missing")?;
|
||||||
|
ensure!(
|
||||||
|
canonical.starts_with(&root) && canonical != root,
|
||||||
|
"Backup path is outside DATA_DIR"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
fs::metadata(&canonical)?.is_file(),
|
||||||
|
"Backup path is not a regular file"
|
||||||
|
);
|
||||||
|
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 {
|
||||||
|
"export" => "exports",
|
||||||
|
"binary" => "binary",
|
||||||
|
_ => bail!("Unsupported backup type"),
|
||||||
|
};
|
||||||
|
Ok(PathBuf::from("backups")
|
||||||
|
.join(kind)
|
||||||
|
.join(router_id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn layout_directory(data_dir: &Path, kind: &str, router_id: i64) -> Result<PathBuf> {
|
||||||
|
create_private_dir(data_dir)?;
|
||||||
|
let mut directory = data_dir.canonicalize()?;
|
||||||
|
for part in layout_folder(kind, router_id)?.components() {
|
||||||
|
let Component::Normal(part) = part else {
|
||||||
|
bail!("Invalid backup directory component");
|
||||||
|
};
|
||||||
|
directory.push(part);
|
||||||
|
match fs::symlink_metadata(&directory) {
|
||||||
|
Ok(metadata) => ensure!(
|
||||||
|
metadata.is_dir() && !metadata.file_type().is_symlink(),
|
||||||
|
"Backup layout directory must be a real directory, not a symbolic link"
|
||||||
|
),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
let mut builder = fs::DirBuilder::new();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::DirBuilderExt;
|
||||||
|
builder.mode(0o700);
|
||||||
|
}
|
||||||
|
match builder.create(&directory) {
|
||||||
|
Ok(()) => sync_directory(directory.parent().expect("nested directory"))?,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||||
|
let metadata = fs::symlink_metadata(&directory)?;
|
||||||
|
ensure!(
|
||||||
|
metadata.is_dir() && !metadata.file_type().is_symlink(),
|
||||||
|
"Concurrent backup directory creation produced an unsafe path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sync_directory(path: &Path) -> Result<()> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
File::open(path)?.sync_all()?;
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = path;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_regular_file(path: &Path) -> Result<File> {
|
||||||
|
let metadata = fs::symlink_metadata(path)?;
|
||||||
|
ensure!(
|
||||||
|
metadata.is_file() && !metadata.file_type().is_symlink(),
|
||||||
|
"Backup must be a regular file, not a symbolic link"
|
||||||
|
);
|
||||||
|
let mut options = fs::OpenOptions::new();
|
||||||
|
options.read(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
options.custom_flags(libc::O_NOFOLLOW);
|
||||||
|
}
|
||||||
|
let file = options.open(path)?;
|
||||||
|
ensure!(file.metadata()?.is_file(), "Backup must be a regular file");
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout_destination(data_dir: &Path, backup: &BackupRecord, relative: &Path) -> Result<PathBuf> {
|
||||||
|
ensure!(
|
||||||
|
relative.is_relative()
|
||||||
|
&& relative.parent()
|
||||||
|
== Some(layout_folder(&backup.backup_type, backup.router_id)?.as_path()),
|
||||||
|
"Unsafe backup layout destination"
|
||||||
|
);
|
||||||
|
let name = relative
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.context("Invalid backup layout filename")?;
|
||||||
|
validate_name(name)?;
|
||||||
|
let directory = layout_directory(data_dir, &backup.backup_type, backup.router_id)?;
|
||||||
|
Ok(directory.join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unused_layout_path(data_dir: &Path, backup: &BackupRecord) -> Result<PathBuf> {
|
||||||
|
validate_name(&backup.file_name)?;
|
||||||
|
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 {
|
||||||
|
let extension = Path::new(&backup.file_name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.filter(|s| s.len() <= 12 && s.bytes().all(|b| b.is_ascii_alphanumeric()));
|
||||||
|
let hash = format!("{:x}", Sha256::digest(backup.file_name.as_bytes()));
|
||||||
|
extension.map_or(hash.clone(), |ext| format!("{hash}.{ext}"))
|
||||||
|
};
|
||||||
|
for attempt in 0..10000 {
|
||||||
|
let name = if attempt == 0 {
|
||||||
|
format!("{}-{name}", backup.id)
|
||||||
|
} else {
|
||||||
|
format!("{}-{attempt}-{name}", backup.id)
|
||||||
|
};
|
||||||
|
match fs::symlink_metadata(directory.join(&name)) {
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(folder.join(name));
|
||||||
|
}
|
||||||
|
Ok(_) => continue,
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("Too many backup destination filename collisions")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn copy_into_layout(
|
||||||
|
data_dir: &Path,
|
||||||
|
backup: &BackupRecord,
|
||||||
|
source: &Path,
|
||||||
|
relative: &Path,
|
||||||
|
expected: &str,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let destination = layout_destination(data_dir, backup, relative)?;
|
||||||
|
let directory = destination.parent().expect("layout file has a parent");
|
||||||
|
let mut target = tempfile::NamedTempFile::new_in(directory)?;
|
||||||
|
std::io::copy(&mut open_regular_file(source)?, &mut target)?;
|
||||||
|
target.seek(SeekFrom::Start(0))?;
|
||||||
|
ensure!(
|
||||||
|
checksum_file(target.as_file_mut())?.eq_ignore_ascii_case(expected),
|
||||||
|
"Backup {} changed while copying",
|
||||||
|
backup.id
|
||||||
|
);
|
||||||
|
target.as_file().sync_all()?;
|
||||||
|
protect_file(target.path())?;
|
||||||
|
ensure!(
|
||||||
|
layout_destination(data_dir, backup, relative)? == destination,
|
||||||
|
"Backup layout directory changed during copy"
|
||||||
|
);
|
||||||
|
target
|
||||||
|
.persist_noclobber(&destination)
|
||||||
|
.map_err(|error| error.error)
|
||||||
|
.context("Cannot publish backup without replacing an existing file")?;
|
||||||
|
sync_directory(directory)?;
|
||||||
|
Ok(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_export(state: &AppState, backup: &BackupRecord) -> Result<String> {
|
||||||
|
ensure!(
|
||||||
|
backup.backup_type == "export",
|
||||||
|
"Preview is supported only for export backups"
|
||||||
|
);
|
||||||
|
let path = resolve_path(state, backup)?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
File::open(path)?
|
||||||
|
.take(routeros::MAX_EXPORT_BYTES + 1)
|
||||||
|
.read_to_end(&mut bytes)?;
|
||||||
|
ensure!(
|
||||||
|
bytes.len() as u64 <= routeros::MAX_EXPORT_BYTES,
|
||||||
|
"Export exceeds preview size limit"
|
||||||
|
);
|
||||||
|
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(state: &AppState, router: &RouterRecord, kind: &str) -> Result<BackupRecord> {
|
||||||
|
ensure!(matches!(kind, "export" | "binary"), "Invalid backup type");
|
||||||
|
ensure!(
|
||||||
|
!router.disable_all_backups,
|
||||||
|
"Backups are disabled for this device"
|
||||||
|
);
|
||||||
|
if kind == "export" {
|
||||||
|
ensure!(
|
||||||
|
router.device_type == "routeros",
|
||||||
|
"Text export is available only for RouterOS devices"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
!router.disable_export_backups,
|
||||||
|
"Exports are disabled for this device"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ensure!(
|
||||||
|
!router.disable_binary_backups,
|
||||||
|
"Binary backups are disabled for this device"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let guard = DeviceGuard::acquire(state, router.id)?;
|
||||||
|
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 {
|
||||||
|
let _guard = guard;
|
||||||
|
let _permit = state
|
||||||
|
.operations
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.context("Backup service is shutting down")?;
|
||||||
|
let settings = state.db.get_settings()?;
|
||||||
|
let result = run_inner(&state, &router, &kind, &settings).await;
|
||||||
|
let label = if kind == "export" {
|
||||||
|
"Export"
|
||||||
|
} else {
|
||||||
|
"Binary backup"
|
||||||
|
};
|
||||||
|
match &result {
|
||||||
|
Ok(_) => {
|
||||||
|
let _ = state.db.log(&format!(
|
||||||
|
"{label} OK for {} device {}",
|
||||||
|
if router.device_type == "switchos" {
|
||||||
|
"SwitchOS"
|
||||||
|
} else {
|
||||||
|
"RouterOS"
|
||||||
|
},
|
||||||
|
router.name
|
||||||
|
));
|
||||||
|
notifications::notify(
|
||||||
|
&state,
|
||||||
|
&settings,
|
||||||
|
&format!("{label} {} OK", router.name),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let message = notifications::redact_error(
|
||||||
|
&format!("{label} FAILED for device {}: {error:#}", router.name),
|
||||||
|
Some(&router),
|
||||||
|
&settings,
|
||||||
|
);
|
||||||
|
let _ = state.db.log(&message);
|
||||||
|
notifications::notify(&state, &settings, &message, false).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("Backup worker failed")?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_inner(
|
||||||
|
state: &AppState,
|
||||||
|
router: &RouterRecord,
|
||||||
|
kind: &str,
|
||||||
|
settings: &crate::models::Settings,
|
||||||
|
) -> Result<BackupRecord> {
|
||||||
|
let directory = layout_directory(&state.config.data_dir, kind, router.id)?;
|
||||||
|
ensure!(
|
||||||
|
fs2::available_space(&state.config.data_dir)? > 4 * 1024 * 1024,
|
||||||
|
"Insufficient free space for backup"
|
||||||
|
);
|
||||||
|
let stamp = Utc::now().format("%Y%m%d_%H%M%S_%f");
|
||||||
|
let base = format!(
|
||||||
|
"{}_{}_{stamp}_{:08x}",
|
||||||
|
router.name,
|
||||||
|
router.id,
|
||||||
|
rand::random::<u32>()
|
||||||
|
);
|
||||||
|
let extension = if kind == "export" {
|
||||||
|
"rsc"
|
||||||
|
} else if router.device_type == "switchos" {
|
||||||
|
"swb"
|
||||||
|
} else {
|
||||||
|
"backup"
|
||||||
|
};
|
||||||
|
let name = format!("{base}.{extension}");
|
||||||
|
validate_name(&name)?;
|
||||||
|
let mut temp = tempfile::NamedTempFile::new_in(&directory)?;
|
||||||
|
if kind == "export" {
|
||||||
|
let addresses = routeros::resolve_addresses(&state.config, router).await?;
|
||||||
|
let config = state.config.clone();
|
||||||
|
let router = router.clone();
|
||||||
|
let settings = settings.clone();
|
||||||
|
let content = tokio::task::spawn_blocking(move || {
|
||||||
|
routeros::export_sync(&config, &router, &settings, addresses)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("SSH export worker failed")??;
|
||||||
|
temp.write_all(&content)?;
|
||||||
|
temp.as_file().sync_all()?;
|
||||||
|
} else if router.device_type == "switchos" {
|
||||||
|
routeros::swos_binary(&state.config, router, settings, temp.path()).await?;
|
||||||
|
} else {
|
||||||
|
let addresses = routeros::resolve_addresses(&state.config, router).await?;
|
||||||
|
let config = state.config.clone();
|
||||||
|
let router = router.clone();
|
||||||
|
let settings = settings.clone();
|
||||||
|
let path = temp.path().to_path_buf();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
routeros::binary_sync(&config, &router, &settings, &base, &path, addresses)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("SSH backup worker failed")??;
|
||||||
|
}
|
||||||
|
temp.as_file().sync_all()?;
|
||||||
|
let checksum = checksum(temp.path())?;
|
||||||
|
let relative = layout_folder(kind, router.id)?.join(&name);
|
||||||
|
let path = directory.join(&name);
|
||||||
|
ensure!(
|
||||||
|
layout_directory(&state.config.data_dir, kind, router.id)? == directory,
|
||||||
|
"Backup directory changed during transfer"
|
||||||
|
);
|
||||||
|
temp.persist_noclobber(&path)
|
||||||
|
.map_err(|e| e.error)
|
||||||
|
.context("Cannot atomically store backup")?;
|
||||||
|
sync_directory(&directory)?;
|
||||||
|
let timestamp = Utc::now().to_rfc3339();
|
||||||
|
let id=state.db.with_conn(|conn|{
|
||||||
|
conn.execute("INSERT INTO backups(router_id,file_path,file_name,backup_type,checksum,created_at) VALUES(?1,?2,?3,?4,?5,?6)",rusqlite::params![router.id,relative.to_string_lossy(),name,kind,checksum,timestamp])?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
});
|
||||||
|
let id = match id {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
return Err(error).context("Cannot save backup metadata");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.backup(id)?
|
||||||
|
.context("Saved backup record is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_all(state: &AppState, owner: Option<i64>, kind: &str) -> Result<Value> {
|
||||||
|
ensure!(matches!(kind, "export" | "binary"), "Invalid backup type");
|
||||||
|
let routers = state.db.routers(owner)?;
|
||||||
|
let settings = state.db.get_settings()?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
let mut tasks = tokio::task::JoinSet::new();
|
||||||
|
for (index, router) in routers.into_iter().enumerate() {
|
||||||
|
let skip = if router.disable_all_backups {
|
||||||
|
"Backups are disabled for this device"
|
||||||
|
} else if kind == "export" && router.device_type != "routeros" {
|
||||||
|
"Text export is available only for RouterOS devices"
|
||||||
|
} else if kind == "export" && router.disable_export_backups {
|
||||||
|
"Exports are disabled for this device"
|
||||||
|
} else if kind == "binary" && router.disable_binary_backups {
|
||||||
|
"Binary backups are disabled for this device"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
if !skip.is_empty() {
|
||||||
|
results.push((
|
||||||
|
index,
|
||||||
|
json!({"router":router.name,"status":"skipped","message":skip}),
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let state = state.clone();
|
||||||
|
let kind = kind.to_owned();
|
||||||
|
let settings = settings.clone();
|
||||||
|
tasks.spawn(async move{let result=match run(&state,&router,&kind).await{Ok(backup)=>json!({"router":router.name,"status":"ok","backup_id":backup.id}),Err(error)=>json!({"router":router.name,"status":"error","message":notifications::redact_error(&format!("{error:#}"),Some(&router),&settings)})};(index,result)});
|
||||||
|
}
|
||||||
|
while let Some(result) = tasks.join_next().await {
|
||||||
|
results.push(result?);
|
||||||
|
}
|
||||||
|
results.sort_by_key(|(index, _)| *index);
|
||||||
|
Ok(Value::Array(
|
||||||
|
results.into_iter().map(|(_, value)| value).collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload(state: &AppState, router: &RouterRecord, backup: &BackupRecord) -> Result<()> {
|
||||||
|
ensure!(
|
||||||
|
router.device_type == "routeros",
|
||||||
|
"Upload is supported only for RouterOS"
|
||||||
|
);
|
||||||
|
ensure!(
|
||||||
|
backup.backup_type == "binary",
|
||||||
|
"Only binary backups can be uploaded"
|
||||||
|
);
|
||||||
|
let source_router = state
|
||||||
|
.db
|
||||||
|
.router(backup.router_id)?
|
||||||
|
.context("Backup device is missing")?;
|
||||||
|
ensure!(
|
||||||
|
source_router.device_type == "routeros",
|
||||||
|
"SwitchOS backups cannot be uploaded over SSH"
|
||||||
|
);
|
||||||
|
let path = resolve_path(state, backup)?;
|
||||||
|
let mut source = File::open(&path)?;
|
||||||
|
if let Some(expected) = &backup.checksum {
|
||||||
|
ensure!(
|
||||||
|
checksum_file(&mut source)? == *expected,
|
||||||
|
"Checksum mismatch"
|
||||||
|
);
|
||||||
|
source.seek(SeekFrom::Start(0))?;
|
||||||
|
}
|
||||||
|
let guard = DeviceGuard::acquire(state, router.id)?;
|
||||||
|
let state = state.clone();
|
||||||
|
let router = router.clone();
|
||||||
|
let name = backup.file_name.clone();
|
||||||
|
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!(
|
||||||
|
"Upload backup OK for device {} (file transfer only; no restore performed)",
|
||||||
|
router.name
|
||||||
|
))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut tombstone = if let Some(path) = &path {
|
||||||
|
let tomb = tempfile::NamedTempFile::new_in(&state.config.data_dir)?;
|
||||||
|
let tomb_path = tomb.into_temp_path();
|
||||||
|
fs::rename(path, &tomb_path)?;
|
||||||
|
Some(tomb_path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let result = state.db.with_conn(|conn| {
|
||||||
|
conn.execute("DELETE FROM backups WHERE id=?1", [backup.id])?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
if let Err(error) = result {
|
||||||
|
if let (Some(old), Some(tomb)) = (&path, &mut tombstone)
|
||||||
|
&& let Err(recovery_error) = restore_deleted_file(tomb, old)
|
||||||
|
{
|
||||||
|
return Err(error).context(format!("{recovery_error:#}"));
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
drop(tombstone);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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}",
|
||||||
|
tombstone.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_time(value: &str) -> Result<DateTime<Utc>> {
|
||||||
|
if let Ok(time) = DateTime::parse_from_rfc3339(value) {
|
||||||
|
return Ok(time.with_timezone(&Utc));
|
||||||
|
}
|
||||||
|
for format in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f"] {
|
||||||
|
if let Ok(time) = NaiveDateTime::parse_from_str(value, format) {
|
||||||
|
return Ok(time.and_utc());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("Invalid backup timestamp")
|
||||||
|
}
|
||||||
|
pub async fn cleanup(state: &AppState) -> Result<usize> {
|
||||||
|
let settings = state.db.get_settings()?;
|
||||||
|
let cutoff = Utc::now() - Duration::days(settings.backup_retention_days.max(0));
|
||||||
|
let mut deleted = 0;
|
||||||
|
for backup in state.db.backups(None)? {
|
||||||
|
if parse_time(&backup.created_at)? < cutoff {
|
||||||
|
delete(state, &backup)?;
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.db.log(&format!(
|
||||||
|
"Retention cleanup removed {deleted} backups older than {} days",
|
||||||
|
settings.backup_retention_days
|
||||||
|
))?;
|
||||||
|
Ok(deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn email(state: &AppState, backup: &BackupRecord) -> Result<()> {
|
||||||
|
let settings = state.db.get_settings()?;
|
||||||
|
let path = resolve_path(state, backup)?;
|
||||||
|
let router = state
|
||||||
|
.db
|
||||||
|
.router(backup.router_id)?
|
||||||
|
.context("Backup device is missing")?;
|
||||||
|
let platform = if router.device_type == "switchos" {
|
||||||
|
"SwitchOS"
|
||||||
|
} else {
|
||||||
|
"RouterOS"
|
||||||
|
};
|
||||||
|
let noun = if backup.backup_type == "export" {
|
||||||
|
"Export"
|
||||||
|
} else {
|
||||||
|
"Backup"
|
||||||
|
};
|
||||||
|
notifications::send_email(
|
||||||
|
state,
|
||||||
|
&settings,
|
||||||
|
&format!("{platform} {noun}: {}", backup.file_name),
|
||||||
|
&format!("Sending {} from device {}.", backup.file_name, router.name),
|
||||||
|
Some(&path),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.log(&format!("Email sent for backup {}", backup.file_name))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn storage_stats(state: &AppState) -> Result<Value> {
|
||||||
|
let root = &state.config.data_dir;
|
||||||
|
let total = fs2::total_space(root)?;
|
||||||
|
let free = fs2::available_space(root)?;
|
||||||
|
let mut stack = vec![root.clone()];
|
||||||
|
let mut folder_used = 0u64;
|
||||||
|
while let Some(path) = stack.pop() {
|
||||||
|
if let Ok(entries) = fs::read_dir(path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let Ok(metadata) = entry.path().symlink_metadata() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if metadata.is_file() {
|
||||||
|
folder_used = folder_used.saturating_add(metadata.len());
|
||||||
|
} else if metadata.is_dir() {
|
||||||
|
stack.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
json!({"total":total,"used":total.saturating_sub(free),"free":free,"folder_used":folder_used,"usage_percent":if total==0{0.0}else{folder_used as f64/total as f64*100.0}}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn checksum_matches_sha256() {
|
||||||
|
let mut file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
file.write_all(b"abc").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
checksum(file.path()).unwrap(),
|
||||||
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn supports_python_and_rust_timestamps() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_time("2026-04-13 11:45:00.000000").unwrap(),
|
||||||
|
parse_time("2026-04-13T11:45:00+00:00").unwrap()
|
||||||
|
);
|
||||||
|
assert!(parse_time("invalid").is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn file_names_cannot_escape() {
|
||||||
|
for name in ["../file", "a/b", "a\\b", "", ".."] {
|
||||||
|
assert!(validate_name(name).is_err());
|
||||||
|
}
|
||||||
|
assert!(validate_name("core_1.backup").is_ok());
|
||||||
|
}
|
||||||
|
fn state_fixture(path: &Path) -> AppState {
|
||||||
|
let config = crate::config::Config::from_values(&std::collections::HashMap::from([
|
||||||
|
("DATA_DIR".into(), path.display().to_string()),
|
||||||
|
("DEFAULT_ADMIN_PASSWORD".into(), "test-password".into()),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
let db = crate::db::Db::open(&config).unwrap();
|
||||||
|
db.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO routers(id,owner_id,name,host) VALUES(1,1,'fixture','127.0.0.1')",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
AppState::new(config, db)
|
||||||
|
}
|
||||||
|
fn insert_backup(state: &AppState, name: &str, time: &str) -> BackupRecord {
|
||||||
|
fs::write(state.config.data_dir.join(name), b"fixture").unwrap();
|
||||||
|
let id=state.db.with_conn(|conn|{conn.execute("INSERT INTO backups(router_id,file_path,file_name,backup_type,created_at) VALUES(1,?1,?1,'export',?2)",rusqlite::params![name,time])?;Ok(conn.last_insert_rowid())}).unwrap();
|
||||||
|
state.db.backup(id).unwrap().unwrap()
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn retention_uses_age_and_zero_is_not_disabled() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = state_fixture(dir.path());
|
||||||
|
let old = insert_backup(
|
||||||
|
&state,
|
||||||
|
"old.rsc",
|
||||||
|
&(Utc::now() - Duration::days(8)).to_rfc3339(),
|
||||||
|
);
|
||||||
|
let new = insert_backup(&state, "new.rsc", &Utc::now().to_rfc3339());
|
||||||
|
assert_eq!(cleanup(&state).await.unwrap(), 1);
|
||||||
|
assert!(state.db.backup(old.id).unwrap().is_none());
|
||||||
|
assert!(!dir.path().join("old.rsc").exists());
|
||||||
|
assert!(state.db.backup(new.id).unwrap().is_some());
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute("UPDATE global_settings SET backup_retention_days=0", [])?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cleanup(&state).await.unwrap(), 1);
|
||||||
|
assert!(state.db.backup(new.id).unwrap().is_none());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn deletion_allows_missing_file_but_rejects_escape() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = state_fixture(dir.path());
|
||||||
|
let mut backup = insert_backup(&state, "file.rsc", &Utc::now().to_rfc3339());
|
||||||
|
fs::remove_file(dir.path().join("file.rsc")).unwrap();
|
||||||
|
delete(&state, &backup).unwrap();
|
||||||
|
assert!(state.db.backup(backup.id).unwrap().is_none());
|
||||||
|
backup.file_path = "../secret".into();
|
||||||
|
assert!(resolve_path(&state, &backup).is_err());
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn symlink_cannot_escape_data_directory() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = state_fixture(dir.path());
|
||||||
|
let outside = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
let mut backup = insert_backup(&state, "safe.rsc", &Utc::now().to_rfc3339());
|
||||||
|
std::os::unix::fs::symlink(outside.path(), dir.path().join("escape.rsc")).unwrap();
|
||||||
|
backup.file_name = "escape.rsc".into();
|
||||||
|
backup.file_path = "escape.rsc".into();
|
||||||
|
assert!(resolve_path(&state, &backup).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn device_lock_releases_on_drop() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = state_fixture(dir.path());
|
||||||
|
let guard = DeviceGuard::acquire(&state, 1).unwrap();
|
||||||
|
assert!(DeviceGuard::acquire(&state, 1).is_err());
|
||||||
|
drop(guard);
|
||||||
|
assert!(DeviceGuard::acquire(&state, 1).is_ok());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn concurrent_first_backups_can_create_shared_layout_directories() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = dir.path().join("new-data");
|
||||||
|
let barrier = std::sync::Barrier::new(16);
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let mut workers = Vec::new();
|
||||||
|
for id in 1..=16 {
|
||||||
|
let root = &root;
|
||||||
|
let barrier = &barrier;
|
||||||
|
workers.push(scope.spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
let kind = if id % 2 == 0 { "binary" } else { "export" };
|
||||||
|
let directory = layout_directory(root, kind, id).unwrap();
|
||||||
|
assert_eq!(directory, root.join(layout_folder(kind, id).unwrap()));
|
||||||
|
assert!(directory.is_dir());
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for worker in workers {
|
||||||
|
worker.join().unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_dir(root.join("backups/binary")).unwrap().count(),
|
||||||
|
8
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_dir(root.join("backups/exports")).unwrap().count(),
|
||||||
|
8
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejected_metadata_deletion_restores_the_active_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let state = state_fixture(dir.path());
|
||||||
|
let backup = insert_backup(&state, "reject.rsc", "2020-01-01T00:00:00Z");
|
||||||
|
state.db.with_conn(|conn| {
|
||||||
|
conn.execute_batch("CREATE TRIGGER reject_delete BEFORE DELETE ON backups BEGIN SELECT RAISE(ABORT,'fixture deletion denied'); END")?;
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
assert!(delete(&state, &backup).is_err());
|
||||||
|
assert_eq!(fs::read(dir.path().join("reject.rsc")).unwrap(), b"fixture");
|
||||||
|
assert!(state.db.backup(backup.id).unwrap().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_delete_rollback_retains_recovery_file_after_temp_path_drop() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let original = dir.path().join("unavailable.rsc");
|
||||||
|
fs::create_dir(&original).unwrap();
|
||||||
|
let mut file = tempfile::NamedTempFile::new_in(dir.path()).unwrap();
|
||||||
|
file.write_all(b"only surviving copy").unwrap();
|
||||||
|
let mut tombstone = file.into_temp_path();
|
||||||
|
let recovery = tombstone.to_path_buf();
|
||||||
|
let error = restore_deleted_file(&mut tombstone, &original).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains(recovery.to_string_lossy().as_ref())
|
||||||
|
);
|
||||||
|
drop(tombstone);
|
||||||
|
assert_eq!(fs::read(recovery).unwrap(), b"only surviving copy");
|
||||||
|
}
|
||||||
|
}
|
||||||
+490
@@ -0,0 +1,490 @@
|
|||||||
|
//! Side-effect-free configuration parsing and explicit storage initialization.
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use rand::{RngCore, rngs::OsRng};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fs::{self, OpenOptions},
|
||||||
|
io::Write,
|
||||||
|
net::SocketAddr,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub app_name: String,
|
||||||
|
pub app_env: String,
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub database_path: PathBuf,
|
||||||
|
pub secret_key: String,
|
||||||
|
pub api_prefix: String,
|
||||||
|
pub timezone: String,
|
||||||
|
pub listen: String,
|
||||||
|
pub allow_registration: bool,
|
||||||
|
pub default_admin_username: String,
|
||||||
|
pub default_admin_password: String,
|
||||||
|
pub access_token_expire_minutes: u64,
|
||||||
|
pub smtp_starttls: bool,
|
||||||
|
pub smtp_timeout_seconds: u64,
|
||||||
|
pub ssh_timeout_seconds: u64,
|
||||||
|
pub ssh_host_key_policy: String,
|
||||||
|
pub max_parallel_backups: usize,
|
||||||
|
pub cors_origins: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Result<Self> {
|
||||||
|
let mut config = Self::from_env_uninitialized()?;
|
||||||
|
config.initialize_storage()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read environment and .env values without changing files or process variables.
|
||||||
|
pub fn from_env_uninitialized() -> Result<Self> {
|
||||||
|
let mut values: HashMap<String, String> = std::env::vars().collect();
|
||||||
|
match dotenvy::dotenv_iter() {
|
||||||
|
Ok(entries) => {
|
||||||
|
for entry in entries {
|
||||||
|
let (name, value) = entry.context("Cannot parse .env")?;
|
||||||
|
values.entry(name).or_insert(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(dotenvy::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(error).context("Cannot parse .env"),
|
||||||
|
}
|
||||||
|
Self::from_values_uninitialized(&values)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration injection without mutating process environment (also useful to embedders/tests).
|
||||||
|
pub fn from_values(values: &HashMap<String, String>) -> Result<Self> {
|
||||||
|
let mut config = Self::from_values_uninitialized(values)?;
|
||||||
|
config.initialize_storage()?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate all values before storage is touched. Relative paths are resolved
|
||||||
|
/// against the working directory; existing symlinks and future directories
|
||||||
|
/// are left for the caller's read-only path preflight and initialization.
|
||||||
|
pub fn from_values_uninitialized(values: &HashMap<String, String>) -> Result<Self> {
|
||||||
|
let get =
|
||||||
|
|name: &str, default: &str| values.get(name).cloned().unwrap_or_else(|| default.into());
|
||||||
|
let current_dir = std::env::current_dir().context("Cannot resolve current directory")?;
|
||||||
|
let data_dir = PathBuf::from(get("DATA_DIR", "./storage"));
|
||||||
|
anyhow::ensure!(
|
||||||
|
!data_dir.as_os_str().is_empty(),
|
||||||
|
"DATA_DIR must not be empty"
|
||||||
|
);
|
||||||
|
let data_dir = if data_dir.is_absolute() {
|
||||||
|
data_dir
|
||||||
|
} else {
|
||||||
|
current_dir.join(data_dir)
|
||||||
|
};
|
||||||
|
let database_path = if let Some(path) = values.get("DATABASE_PATH") {
|
||||||
|
PathBuf::from(path)
|
||||||
|
} else if let Some(url) = values.get("DATABASE_URL") {
|
||||||
|
let path = url.strip_prefix("sqlite:///").ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Only SQLite DATABASE_URL is supported (sqlite:///path)")
|
||||||
|
})?;
|
||||||
|
if path.is_empty() || path == ":memory:" {
|
||||||
|
bail!("DATABASE_URL must point to a persistent SQLite file");
|
||||||
|
}
|
||||||
|
PathBuf::from(path)
|
||||||
|
} else {
|
||||||
|
data_dir.join("routeros_backup_next.db")
|
||||||
|
};
|
||||||
|
let database_path = if database_path.is_absolute() {
|
||||||
|
database_path
|
||||||
|
} else {
|
||||||
|
anyhow::ensure!(
|
||||||
|
!database_path.as_os_str().is_empty(),
|
||||||
|
"DATABASE_PATH must point to a file"
|
||||||
|
);
|
||||||
|
current_dir.join(database_path)
|
||||||
|
};
|
||||||
|
anyhow::ensure!(
|
||||||
|
database_path.file_name().is_some(),
|
||||||
|
"DATABASE_PATH must point to a file"
|
||||||
|
);
|
||||||
|
let secret_key = match values.get("SECRET_KEY").filter(|s| !s.is_empty()) {
|
||||||
|
Some(key) => {
|
||||||
|
if key.len() < 32 || key.starts_with("change-me") {
|
||||||
|
bail!(
|
||||||
|
"SECRET_KEY must contain at least 32 characters and must not be a placeholder"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
key.clone()
|
||||||
|
}
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
if get("JWT_ALGORITHM", "HS256") != "HS256" {
|
||||||
|
bail!("JWT_ALGORITHM must be HS256 for compatibility with the Python API");
|
||||||
|
}
|
||||||
|
let api_prefix = get("API_PREFIX", "/api").trim_end_matches('/').to_string();
|
||||||
|
if !api_prefix.starts_with('/')
|
||||||
|
|| api_prefix.len() < 2
|
||||||
|
|| api_prefix.contains("//")
|
||||||
|
|| !api_prefix
|
||||||
|
.bytes()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'/' | b'_' | b'-'))
|
||||||
|
|| api_prefix == "/assets"
|
||||||
|
|| api_prefix.starts_with("/assets/")
|
||||||
|
{
|
||||||
|
bail!(
|
||||||
|
"API_PREFIX must be an absolute path using letters, digits, '/', '_' and '-', outside /assets"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let timezone = get("TIMEZONE", "Europe/Warsaw");
|
||||||
|
timezone
|
||||||
|
.parse::<chrono_tz::Tz>()
|
||||||
|
.context("Invalid TIMEZONE")?;
|
||||||
|
let app_port = number(values, "APP_PORT", 5581, 1, 65535)?;
|
||||||
|
let listen = values
|
||||||
|
.get("LISTEN_ADDR")
|
||||||
|
.or_else(|| values.get("LISTEN"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| format!("127.0.0.1:{app_port}"));
|
||||||
|
listen
|
||||||
|
.parse::<SocketAddr>()
|
||||||
|
.context("LISTEN_ADDR must be an IP address and port")?;
|
||||||
|
let ssh_host_key_policy = get("SSH_HOST_KEY_POLICY", "accept-new");
|
||||||
|
if !["strict", "accept-new"].contains(&ssh_host_key_policy.as_str()) {
|
||||||
|
bail!("SSH_HOST_KEY_POLICY must be strict or accept-new");
|
||||||
|
}
|
||||||
|
let default_admin_username = get("DEFAULT_ADMIN_USERNAME", "admin");
|
||||||
|
if !(3..=120).contains(&default_admin_username.chars().count())
|
||||||
|
|| default_admin_username.trim() != default_admin_username
|
||||||
|
|| default_admin_username.chars().any(char::is_control)
|
||||||
|
{
|
||||||
|
bail!("DEFAULT_ADMIN_USERNAME must contain 3 to 120 characters");
|
||||||
|
}
|
||||||
|
let default_admin_password = get("DEFAULT_ADMIN_PASSWORD", "");
|
||||||
|
if !default_admin_password.is_empty()
|
||||||
|
&& !(4..=128).contains(&default_admin_password.chars().count())
|
||||||
|
{
|
||||||
|
bail!("DEFAULT_ADMIN_PASSWORD must contain 4 to 128 characters");
|
||||||
|
}
|
||||||
|
let cors = get("CORS_ORIGINS", "[]");
|
||||||
|
let cors_origins: Vec<String> = if cors.trim_start().starts_with('[') {
|
||||||
|
serde_json::from_str(&cors).context("CORS_ORIGINS must be a JSON array of origins")?
|
||||||
|
} else {
|
||||||
|
cors.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if cors_origins.iter().any(|origin| {
|
||||||
|
!(origin.starts_with("http://") || origin.starts_with("https://"))
|
||||||
|
|| origin.contains(['\r', '\n'])
|
||||||
|
}) {
|
||||||
|
bail!("CORS_ORIGINS must contain explicit http(s) origins");
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
app_name: get("APP_NAME", "MikroTik Backup System"),
|
||||||
|
app_env: get("APP_ENV", "production"),
|
||||||
|
data_dir,
|
||||||
|
database_path,
|
||||||
|
secret_key,
|
||||||
|
api_prefix,
|
||||||
|
timezone,
|
||||||
|
listen,
|
||||||
|
allow_registration: boolean(values, "ALLOW_REGISTRATION", false)?,
|
||||||
|
default_admin_username,
|
||||||
|
default_admin_password,
|
||||||
|
access_token_expire_minutes: number(
|
||||||
|
values,
|
||||||
|
"ACCESS_TOKEN_EXPIRE_MINUTES",
|
||||||
|
1440,
|
||||||
|
1,
|
||||||
|
52_560_000,
|
||||||
|
)?,
|
||||||
|
smtp_starttls: boolean(values, "SMTP_STARTTLS", true)?,
|
||||||
|
smtp_timeout_seconds: number(values, "SMTP_TIMEOUT_SECONDS", 20, 1, 300)?,
|
||||||
|
ssh_timeout_seconds: number(values, "SSH_TIMEOUT_SECONDS", 30, 1, 3600)?,
|
||||||
|
ssh_host_key_policy,
|
||||||
|
max_parallel_backups: number(values, "MAX_PARALLEL_BACKUPS", 4, 1, 64)? as usize,
|
||||||
|
cors_origins,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create runtime directories and load the persistent signing key only after
|
||||||
|
/// the caller has validated the destination and acquired its storage lock.
|
||||||
|
pub fn initialize_storage(&mut self) -> Result<()> {
|
||||||
|
create_private_dir(&self.data_dir)?;
|
||||||
|
let data_dir = self
|
||||||
|
.data_dir
|
||||||
|
.canonicalize()
|
||||||
|
.context("Cannot resolve DATA_DIR")?;
|
||||||
|
let database_parent = self
|
||||||
|
.database_path
|
||||||
|
.parent()
|
||||||
|
.context("Database has no parent directory")?;
|
||||||
|
let database_name = self
|
||||||
|
.database_path
|
||||||
|
.file_name()
|
||||||
|
.context("DATABASE_PATH must point to a file")?;
|
||||||
|
create_private_dir(database_parent)?;
|
||||||
|
let database_path = database_parent
|
||||||
|
.canonicalize()
|
||||||
|
.context("Cannot resolve database parent directory")?
|
||||||
|
.join(database_name);
|
||||||
|
// Keep the final database path component intact so Db::open can reject
|
||||||
|
// a symbolic link instead of silently following it during initialization.
|
||||||
|
let secret_key = if self.secret_key.is_empty() {
|
||||||
|
read_or_create_secret(&data_dir.join("secrets/jwt.key"), 32)?
|
||||||
|
} else {
|
||||||
|
self.secret_key.clone()
|
||||||
|
};
|
||||||
|
self.data_dir = data_dir;
|
||||||
|
self.database_path = database_path;
|
||||||
|
self.secret_key = secret_key;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number(
|
||||||
|
values: &HashMap<String, String>,
|
||||||
|
key: &str,
|
||||||
|
default: u64,
|
||||||
|
min: u64,
|
||||||
|
max: u64,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let value = values
|
||||||
|
.get(key)
|
||||||
|
.map(|s| s.parse::<u64>())
|
||||||
|
.transpose()
|
||||||
|
.with_context(|| format!("{key} must be an integer"))?
|
||||||
|
.unwrap_or(default);
|
||||||
|
if !(min..=max).contains(&value) {
|
||||||
|
bail!("{key} must be between {min} and {max}");
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn boolean(values: &HashMap<String, String>, key: &str, default: bool) -> Result<bool> {
|
||||||
|
match values
|
||||||
|
.get(key)
|
||||||
|
.map(|s| s.trim().to_ascii_lowercase())
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
None => Ok(default),
|
||||||
|
Some("true" | "1" | "yes" | "on") => Ok(true),
|
||||||
|
Some("false" | "0" | "no" | "off") => Ok(false),
|
||||||
|
_ => bail!("{key} must be true or false"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn create_private_dir(path: &Path) -> Result<()> {
|
||||||
|
if !path.exists() {
|
||||||
|
fs::create_dir_all(path).with_context(|| format!("Cannot create {}", path.display()))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn protect_file(path: &Path) -> Result<()> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_or_create_secret(path: &Path, bytes: usize) -> Result<String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
create_private_dir(parent)?;
|
||||||
|
}
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.write(true).create_new(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
match options.open(path) {
|
||||||
|
Ok(mut file) => {
|
||||||
|
let mut random = vec![0_u8; bytes];
|
||||||
|
OsRng.fill_bytes(&mut random);
|
||||||
|
let value: String = random.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
file.write_all(value.as_bytes())?;
|
||||||
|
file.sync_all()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||||
|
if fs::symlink_metadata(path)?.file_type().is_symlink() {
|
||||||
|
bail!("Secret file must not be a symbolic link");
|
||||||
|
}
|
||||||
|
let value = fs::read_to_string(path).context("Cannot read existing secret file")?;
|
||||||
|
let value = value.trim().to_owned();
|
||||||
|
if value.len() < bytes * 2 {
|
||||||
|
bail!(
|
||||||
|
"Secret file is incomplete; restore it from backup instead of rotating silently"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
protect_file(path)?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
Err(error) => Err(error).context("Cannot create secret file"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn values(dir: &Path) -> HashMap<String, String> {
|
||||||
|
HashMap::from([("DATA_DIR".into(), dir.display().to_string())])
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn generated_secret_survives_restart() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let a = Config::from_values(&values(dir.path())).unwrap();
|
||||||
|
let b = Config::from_values(&values(dir.path())).unwrap();
|
||||||
|
assert_eq!(a.secret_key, b.secret_key);
|
||||||
|
assert_eq!(a.secret_key.len(), 64);
|
||||||
|
assert!(!a.allow_registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pure_parse_defers_storage_and_secret_creation_until_initialization() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let data = dir.path().join("new data/runtime");
|
||||||
|
let database = dir.path().join("separate database/main.db");
|
||||||
|
let mut input = values(&data);
|
||||||
|
input.insert("DATABASE_PATH".into(), database.display().to_string());
|
||||||
|
|
||||||
|
let mut parsed = Config::from_values_uninitialized(&input).unwrap();
|
||||||
|
assert_eq!(parsed.data_dir, data);
|
||||||
|
assert_eq!(parsed.database_path, database);
|
||||||
|
assert!(parsed.secret_key.is_empty());
|
||||||
|
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||||
|
|
||||||
|
parsed.initialize_storage().unwrap();
|
||||||
|
assert_eq!(parsed.data_dir, data.canonicalize().unwrap());
|
||||||
|
assert_eq!(parsed.secret_key.len(), 64);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(data.join("secrets/jwt.key")).unwrap(),
|
||||||
|
parsed.secret_key
|
||||||
|
);
|
||||||
|
assert!(database.parent().unwrap().is_dir());
|
||||||
|
assert!(!database.exists());
|
||||||
|
assert!(!data.join("application.lock").exists());
|
||||||
|
|
||||||
|
let mut restarted = Config::from_values_uninitialized(&input).unwrap();
|
||||||
|
assert!(restarted.secret_key.is_empty());
|
||||||
|
restarted.initialize_storage().unwrap();
|
||||||
|
assert_eq!(restarted.secret_key, parsed.secret_key);
|
||||||
|
assert_eq!(restarted.database_path, parsed.database_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pure_parse_resolves_relative_paths_without_creating_them() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let name = format!(
|
||||||
|
"configuration-preflight-{}",
|
||||||
|
dir.path().file_name().unwrap().to_string_lossy()
|
||||||
|
);
|
||||||
|
let relative = PathBuf::from(name);
|
||||||
|
let data = relative.join("storage with spaces");
|
||||||
|
let database = relative.join("database with spaces/current.db");
|
||||||
|
let mut input = values(&data);
|
||||||
|
input.insert("DATABASE_PATH".into(), database.display().to_string());
|
||||||
|
let current_dir = std::env::current_dir().unwrap();
|
||||||
|
let parsed = Config::from_values_uninitialized(&input).unwrap();
|
||||||
|
assert_eq!(parsed.data_dir, current_dir.join(data));
|
||||||
|
assert_eq!(parsed.database_path, current_dir.join(database));
|
||||||
|
assert!(!current_dir.join(relative).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pure_parse_does_not_read_or_change_an_existing_secret() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let secret = dir.path().join("secrets/jwt.key");
|
||||||
|
fs::create_dir(secret.parent().unwrap()).unwrap();
|
||||||
|
fs::write(&secret, b"incomplete-existing-secret").unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&secret, fs::Permissions::from_mode(0o644)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parsed = Config::from_values_uninitialized(&values(dir.path())).unwrap();
|
||||||
|
assert!(parsed.secret_key.is_empty());
|
||||||
|
assert_eq!(fs::read(&secret).unwrap(), b"incomplete-existing-secret");
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(&secret).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o644
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
|
||||||
|
assert!(parsed.initialize_storage().is_err());
|
||||||
|
assert_eq!(fs::read(&secret).unwrap(), b"incomplete-existing-secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_signing_key_needs_no_secret_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let data = dir.path().join("new storage");
|
||||||
|
let mut input = values(&data);
|
||||||
|
let key = "a-test-only-explicit-key-with-at-least-32-characters";
|
||||||
|
input.insert("SECRET_KEY".into(), key.into());
|
||||||
|
let mut parsed = Config::from_values_uninitialized(&input).unwrap();
|
||||||
|
assert_eq!(parsed.secret_key, key);
|
||||||
|
assert!(!data.exists());
|
||||||
|
parsed.initialize_storage().unwrap();
|
||||||
|
assert_eq!(parsed.secret_key, key);
|
||||||
|
assert!(!data.join("secrets").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_legacy_sqlite_url_and_custom_prefix() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut input = values(dir.path());
|
||||||
|
input.insert(
|
||||||
|
"DATABASE_URL".into(),
|
||||||
|
format!("sqlite:///{}/db/legacy.db", dir.path().display()),
|
||||||
|
);
|
||||||
|
input.insert("API_PREFIX".into(), "/management/api/".into());
|
||||||
|
let config = Config::from_values(&input).unwrap();
|
||||||
|
assert_eq!(config.database_path, dir.path().join("db/legacy.db"));
|
||||||
|
assert_eq!(config.api_prefix, "/management/api");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rejects_unsafe_configuration() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let data = dir.path().join("must not be created/storage");
|
||||||
|
let database = dir.path().join("must not be created/database/main.db");
|
||||||
|
for (key, value) in [
|
||||||
|
("SECRET_KEY", "change-me"),
|
||||||
|
("JWT_ALGORITHM", "none"),
|
||||||
|
("ALLOW_REGISTRATION", "maybe"),
|
||||||
|
("SSH_HOST_KEY_POLICY", "insecure"),
|
||||||
|
("API_PREFIX", "/"),
|
||||||
|
("API_PREFIX", "/api\"onload=evil"),
|
||||||
|
("API_PREFIX", "/api space"),
|
||||||
|
("API_PREFIX", "/api//nested"),
|
||||||
|
("API_PREFIX", "/assets"),
|
||||||
|
("TIMEZONE", "not-a-timezone"),
|
||||||
|
("APP_PORT", "99999"),
|
||||||
|
("MAX_PARALLEL_BACKUPS", "0"),
|
||||||
|
("DATA_DIR", ""),
|
||||||
|
("DATABASE_PATH", ""),
|
||||||
|
] {
|
||||||
|
let mut input = values(&data);
|
||||||
|
input.insert("DATABASE_PATH".into(), database.display().to_string());
|
||||||
|
input.insert(key.into(), value.into());
|
||||||
|
assert!(
|
||||||
|
Config::from_values_uninitialized(&input).is_err(),
|
||||||
|
"pure parser must reject {key}"
|
||||||
|
);
|
||||||
|
assert!(Config::from_values(&input).is_err(), "must reject {key}");
|
||||||
|
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+880
@@ -0,0 +1,880 @@
|
|||||||
|
//! 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};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
pub fn escape(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
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,
|
||||||
|
right: &BackupRecord,
|
||||||
|
) -> Result<Value> {
|
||||||
|
compare_files(state, left, right, false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_files(
|
||||||
|
state: &AppState,
|
||||||
|
left: &BackupRecord,
|
||||||
|
right: &BackupRecord,
|
||||||
|
include_text: bool,
|
||||||
|
include_html: bool,
|
||||||
|
) -> Result<Value> {
|
||||||
|
ensure!(
|
||||||
|
left.backup_type == "export" && right.backup_type == "export",
|
||||||
|
"Diff is supported only for export backups"
|
||||||
|
);
|
||||||
|
let left_text = backup::read_export(state, left)?;
|
||||||
|
let right_text = backup::read_export(state, right)?;
|
||||||
|
let mut result = compare_text_options(
|
||||||
|
&left_text,
|
||||||
|
&right_text,
|
||||||
|
&left.file_name,
|
||||||
|
&right.file_name,
|
||||||
|
include_text,
|
||||||
|
include_html,
|
||||||
|
)?;
|
||||||
|
result["left_backup_id"] = json!(left.id);
|
||||||
|
result["right_backup_id"] = json!(right.id);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compare_text(left: &str, right: &str, left_name: &str, right_name: &str) -> Result<Value> {
|
||||||
|
compare_text_impl(left, right, left_name, right_name, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_text_impl(
|
||||||
|
left: &str,
|
||||||
|
right: &str,
|
||||||
|
left_name: &str,
|
||||||
|
right_name: &str,
|
||||||
|
include_html: bool,
|
||||||
|
) -> Result<Value> {
|
||||||
|
compare_text_options(left, right, left_name, right_name, true, include_html)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_text_options(
|
||||||
|
left: &str,
|
||||||
|
right: &str,
|
||||||
|
left_name: &str,
|
||||||
|
right_name: &str,
|
||||||
|
include_text: bool,
|
||||||
|
include_html: bool,
|
||||||
|
) -> Result<Value> {
|
||||||
|
let left_lines: Vec<_> = left.lines().collect();
|
||||||
|
let right_lines: Vec<_> = right.lines().collect();
|
||||||
|
ensure!(
|
||||||
|
left_lines.len() + right_lines.len() <= 100_000,
|
||||||
|
"Diff exceeds the 100,000-line limit"
|
||||||
|
);
|
||||||
|
let operations = sequence_operations(&left_lines, &right_lines)?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut added = 0;
|
||||||
|
let mut removed = 0;
|
||||||
|
let mut modified = 0;
|
||||||
|
let mut context = 0;
|
||||||
|
for operation in &operations {
|
||||||
|
let (left_range, right_range) = (operation.a..operation.b, operation.c..operation.d);
|
||||||
|
match operation.kind {
|
||||||
|
Kind::Equal => {
|
||||||
|
for (li, ri) in left_range.zip(right_range) {
|
||||||
|
rows.push(json!({"type":"context","left_number":li+1,"right_number":ri+1,"left_text":left_lines[li],"right_text":right_lines[ri]}));
|
||||||
|
context += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Delete => {
|
||||||
|
for li in left_range {
|
||||||
|
rows.push(json!({"type":"removed","left_number":li+1,"right_number":null,"left_text":left_lines[li],"right_text":""}));
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Insert => {
|
||||||
|
for ri in right_range {
|
||||||
|
rows.push(json!({"type":"added","left_number":null,"right_number":ri+1,"left_text":"","right_text":right_lines[ri]}));
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Replace => {
|
||||||
|
for offset in 0..left_range.len().max(right_range.len()) {
|
||||||
|
let li = (offset < left_range.len()).then_some(left_range.start + offset);
|
||||||
|
let ri = (offset < right_range.len()).then_some(right_range.start + offset);
|
||||||
|
let kind = match (li, ri) {
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
modified += 1;
|
||||||
|
"modified"
|
||||||
|
}
|
||||||
|
(Some(_), None) => {
|
||||||
|
removed += 1;
|
||||||
|
"removed"
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
added += 1;
|
||||||
|
"added"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rows.push(json!({"type":kind,"left_number":li.map(|i|i+1),"right_number":ri.map(|i|i+1),"left_text":li.map(|i|left_lines[i]).unwrap_or(""),"right_text":ri.map(|i|right_lines[i]).unwrap_or("")}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut result = json!({
|
||||||
|
"left_file_name": left_name,
|
||||||
|
"right_file_name": right_name,
|
||||||
|
"stats": {"added":added,"removed":removed,"modified":modified,"context":context},
|
||||||
|
"lines": rows,
|
||||||
|
"smart": smart_analysis(left, right)
|
||||||
|
});
|
||||||
|
if include_text {
|
||||||
|
result["diff_text"] = json!(unified_diff(
|
||||||
|
&operations,
|
||||||
|
&left_lines,
|
||||||
|
&right_lines,
|
||||||
|
left_name,
|
||||||
|
right_name,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct SmartCommand {
|
||||||
|
section: String,
|
||||||
|
verb: String,
|
||||||
|
normalized: String,
|
||||||
|
identity: String,
|
||||||
|
fields: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn smart_analysis(left: &str, right: &str) -> Value {
|
||||||
|
let (left_commands, left_ignored) = parse_rsc_commands(left);
|
||||||
|
let (right_commands, right_ignored) = parse_rsc_commands(right);
|
||||||
|
let mut names = BTreeSet::new();
|
||||||
|
names.extend(left_commands.iter().map(|item| item.section.clone()));
|
||||||
|
names.extend(right_commands.iter().map(|item| item.section.clone()));
|
||||||
|
|
||||||
|
let mut sections = Vec::new();
|
||||||
|
let (mut total_added, mut total_removed, mut total_modified) = (0usize, 0usize, 0usize);
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
let mut right_used = vec![false; right_items.len()];
|
||||||
|
let mut left_unmatched = Vec::new();
|
||||||
|
for item in &left_items {
|
||||||
|
let matched = right_exact
|
||||||
|
.get_mut(&item.normalized)
|
||||||
|
.and_then(|indices| indices.pop());
|
||||||
|
if let Some(index) = matched {
|
||||||
|
right_used[index] = true;
|
||||||
|
} else {
|
||||||
|
left_unmatched.push(*item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let right_unmatched: Vec<_> = right_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, item)| (!right_used[index]).then_some(*item))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
for item in &right_unmatched {
|
||||||
|
right_by_key.entry(item.identity.clone()).or_default().push(*item);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paired_left = BTreeSet::new();
|
||||||
|
let mut paired_right = BTreeSet::new();
|
||||||
|
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 };
|
||||||
|
if old_items.len() != 1 || new_items.len() != 1 || key.starts_with("raw:") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let old = old_items[0];
|
||||||
|
let new = new_items[0];
|
||||||
|
modified += 1;
|
||||||
|
paired_left.insert(old.normalized.clone());
|
||||||
|
paired_right.insert(new.normalized.clone());
|
||||||
|
if details.len() < 12 {
|
||||||
|
details.push(json!({
|
||||||
|
"kind": "modified",
|
||||||
|
"verb": old.verb.as_str(),
|
||||||
|
"identity": display_identity(&old.identity),
|
||||||
|
"fields": changed_fields(old, new),
|
||||||
|
"before": truncate_command(&old.normalized),
|
||||||
|
"after": truncate_command(&new.normalized)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed_items: Vec<_> = left_unmatched
|
||||||
|
.iter()
|
||||||
|
.filter(|item| !paired_left.contains(&item.normalized))
|
||||||
|
.collect();
|
||||||
|
let added_items: Vec<_> = right_unmatched
|
||||||
|
.iter()
|
||||||
|
.filter(|item| !paired_right.contains(&item.normalized))
|
||||||
|
.collect();
|
||||||
|
let removed = removed_items.len();
|
||||||
|
let added = added_items.len();
|
||||||
|
if added == 0 && removed == 0 && modified == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for item in removed_items.iter().take(12usize.saturating_sub(details.len())) {
|
||||||
|
details.push(json!({
|
||||||
|
"kind": "removed",
|
||||||
|
"verb": item.verb.as_str(),
|
||||||
|
"identity": display_identity(&item.identity),
|
||||||
|
"before": truncate_command(&item.normalized)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for item in added_items.iter().take(12usize.saturating_sub(details.len())) {
|
||||||
|
details.push(json!({
|
||||||
|
"kind": "added",
|
||||||
|
"verb": item.verb.as_str(),
|
||||||
|
"identity": display_identity(&item.identity),
|
||||||
|
"after": truncate_command(&item.normalized)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let (attention, category) = classify_section(§ion);
|
||||||
|
if attention != "low" {
|
||||||
|
attention_sections += 1;
|
||||||
|
}
|
||||||
|
total_added += added;
|
||||||
|
total_removed += removed;
|
||||||
|
total_modified += modified;
|
||||||
|
let details_truncated = added + removed + modified > details.len();
|
||||||
|
sections.push(json!({
|
||||||
|
"name": section,
|
||||||
|
"category": category,
|
||||||
|
"attention": attention,
|
||||||
|
"added": added,
|
||||||
|
"removed": removed,
|
||||||
|
"modified": modified,
|
||||||
|
"details": details,
|
||||||
|
"details_truncated": details_truncated
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"sections_changed": sections.len(),
|
||||||
|
"attention_sections": attention_sections,
|
||||||
|
"semantic_changes": total_added + total_removed + total_modified,
|
||||||
|
"added": total_added,
|
||||||
|
"removed": total_removed,
|
||||||
|
"modified": total_modified,
|
||||||
|
"ignored_metadata_lines": left_ignored + right_ignored,
|
||||||
|
"sections": sections
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_rsc_commands(text: &str) -> (Vec<SmartCommand>, usize) {
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
let mut section = String::from("/");
|
||||||
|
let mut pending = String::new();
|
||||||
|
let mut ignored = 0usize;
|
||||||
|
for raw in text.lines() {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||||
|
ignored += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if pending.is_empty() && trimmed.starts_with('/') {
|
||||||
|
section = normalize_spaces(trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let continuation = trimmed.ends_with('\\');
|
||||||
|
let part = trimmed.strip_suffix('\\').unwrap_or(trimmed).trim_end();
|
||||||
|
if !pending.is_empty() {
|
||||||
|
pending.push(' ');
|
||||||
|
}
|
||||||
|
pending.push_str(part);
|
||||||
|
if continuation {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let normalized = normalize_spaces(&pending);
|
||||||
|
pending.clear();
|
||||||
|
if normalized.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let tokens = shellish_tokens(&normalized);
|
||||||
|
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: section.clone(),
|
||||||
|
verb,
|
||||||
|
normalized,
|
||||||
|
identity,
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !pending.trim().is_empty() {
|
||||||
|
let normalized = normalize_spaces(&pending);
|
||||||
|
let tokens = shellish_tokens(&normalized);
|
||||||
|
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, ignored)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_spaces(value: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(value.len());
|
||||||
|
let mut quoted = false;
|
||||||
|
let mut escaped = false;
|
||||||
|
let mut pending_space = false;
|
||||||
|
for ch in value.chars() {
|
||||||
|
if escaped {
|
||||||
|
output.push(ch);
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch == '\\' {
|
||||||
|
output.push(ch);
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch == '"' {
|
||||||
|
if pending_space && !output.is_empty() {
|
||||||
|
output.push(' ');
|
||||||
|
}
|
||||||
|
pending_space = false;
|
||||||
|
quoted = !quoted;
|
||||||
|
output.push(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch.is_whitespace() && !quoted {
|
||||||
|
pending_space = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if pending_space && !output.is_empty() {
|
||||||
|
output.push(' ');
|
||||||
|
}
|
||||||
|
pending_space = false;
|
||||||
|
output.push(ch);
|
||||||
|
}
|
||||||
|
output.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shellish_tokens(value: &str) -> Vec<String> {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut quoted = false;
|
||||||
|
let mut escaped = false;
|
||||||
|
for ch in value.chars() {
|
||||||
|
if escaped {
|
||||||
|
current.push(ch);
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch == '\\' {
|
||||||
|
current.push(ch);
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch == '"' {
|
||||||
|
quoted = !quoted;
|
||||||
|
current.push(ch);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch.is_whitespace() && !quoted {
|
||||||
|
if !current.is_empty() {
|
||||||
|
output.push(std::mem::take(&mut current));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
output.push(current);
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
if !key.is_empty() {
|
||||||
|
fields.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
fn semantic_identity(
|
||||||
|
verb: &str,
|
||||||
|
tokens: &[String],
|
||||||
|
fields: &BTreeMap<String, String>,
|
||||||
|
normalized: &str,
|
||||||
|
) -> String {
|
||||||
|
if verb == "set" || verb == "remove" || verb == "enable" || verb == "disable" {
|
||||||
|
let target: Vec<_> = tokens
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.take_while(|token| !token.contains('='))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
if !target.is_empty() {
|
||||||
|
return format!("{verb}:{}", target.join(" "));
|
||||||
|
}
|
||||||
|
if verb == "set" {
|
||||||
|
return "set:singleton".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if verb == "add" {
|
||||||
|
for keys in [
|
||||||
|
&["name"][..],
|
||||||
|
&["address", "interface"][..],
|
||||||
|
&["chain", "comment"][..],
|
||||||
|
&["interface", "vlan-ids"][..],
|
||||||
|
&["list", "address"][..],
|
||||||
|
&["mac-address"][..],
|
||||||
|
] {
|
||||||
|
if keys.iter().all(|key| fields.contains_key(*key)) {
|
||||||
|
let id = keys
|
||||||
|
.iter()
|
||||||
|
.map(|key| format!("{key}={}", fields.get(*key).unwrap()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";");
|
||||||
|
return format!("add:{id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("raw:{normalized}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn changed_fields(old: &SmartCommand, new: &SmartCommand) -> Vec<Value> {
|
||||||
|
let mut keys = BTreeSet::new();
|
||||||
|
keys.extend(old.fields.keys().cloned());
|
||||||
|
keys.extend(new.fields.keys().cloned());
|
||||||
|
keys.into_iter()
|
||||||
|
.filter_map(|key| {
|
||||||
|
let before = old.fields.get(&key);
|
||||||
|
let after = new.fields.get(&key);
|
||||||
|
(before != after).then(|| {
|
||||||
|
json!({
|
||||||
|
"name": key,
|
||||||
|
"before": redact_value(&key, before.map(String::as_str)),
|
||||||
|
"after": redact_value(&key, after.map(String::as_str))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.take(8)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redact_value(key: &str, value: Option<&str>) -> Value {
|
||||||
|
let lower = key.to_ascii_lowercase();
|
||||||
|
let sensitive = lower.contains("password")
|
||||||
|
|| lower.contains("secret")
|
||||||
|
|| lower.contains("private-key")
|
||||||
|
|| lower == "key";
|
||||||
|
match value {
|
||||||
|
None => Value::Null,
|
||||||
|
Some(_) if sensitive => json!("••••••"),
|
||||||
|
Some(value) => json!(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_identity(identity: &str) -> String {
|
||||||
|
identity
|
||||||
|
.strip_prefix("raw:")
|
||||||
|
.or_else(|| identity.strip_prefix("add:"))
|
||||||
|
.or_else(|| identity.strip_prefix("set:"))
|
||||||
|
.or_else(|| identity.strip_prefix("remove:"))
|
||||||
|
.or_else(|| identity.strip_prefix("enable:"))
|
||||||
|
.or_else(|| identity.strip_prefix("disable:"))
|
||||||
|
.unwrap_or(identity)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_command(value: &str) -> String {
|
||||||
|
const LIMIT: usize = 220;
|
||||||
|
if value.chars().count() <= LIMIT {
|
||||||
|
return value.to_string();
|
||||||
|
}
|
||||||
|
let mut output: String = value.chars().take(LIMIT).collect();
|
||||||
|
output.push('…');
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_section(section: &str) -> (&'static str, &'static str) {
|
||||||
|
let name = section.to_ascii_lowercase();
|
||||||
|
if name.starts_with("/user")
|
||||||
|
|| name.starts_with("/ip service")
|
||||||
|
|| name.starts_with("/ip firewall")
|
||||||
|
|| name.starts_with("/ipv6 firewall")
|
||||||
|
|| name.starts_with("/system script")
|
||||||
|
|| name.starts_with("/system scheduler")
|
||||||
|
|| name.starts_with("/tool")
|
||||||
|
|| name.starts_with("/certificate")
|
||||||
|
|| name.starts_with("/ppp secret")
|
||||||
|
{
|
||||||
|
return ("high", "security");
|
||||||
|
}
|
||||||
|
if name.starts_with("/interface")
|
||||||
|
|| name.starts_with("/ip address")
|
||||||
|
|| name.starts_with("/ip route")
|
||||||
|
|| name.starts_with("/routing")
|
||||||
|
|| name.starts_with("/bridge")
|
||||||
|
|| name.starts_with("/ip dhcp")
|
||||||
|
{
|
||||||
|
return ("medium", "network");
|
||||||
|
}
|
||||||
|
if name.starts_with("/ip dns") || name.starts_with("/snmp") || name.starts_with("/system logging") {
|
||||||
|
return ("medium", "services");
|
||||||
|
}
|
||||||
|
("low", "other")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum Kind {
|
||||||
|
Equal,
|
||||||
|
Delete,
|
||||||
|
Insert,
|
||||||
|
Replace,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Operation {
|
||||||
|
kind: Kind,
|
||||||
|
a: usize,
|
||||||
|
b: usize,
|
||||||
|
c: usize,
|
||||||
|
d: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Python difflib.SequenceMatcher's longest-contiguous-match and autojunk semantics.
|
||||||
|
/// Keeping this algorithm also keeps repeated RouterOS sections aligned like the source API.
|
||||||
|
fn sequence_operations(left: &[&str], right: &[&str]) -> Result<Vec<Operation>> {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
let mut positions: HashMap<&str, Vec<usize>> = HashMap::new();
|
||||||
|
for (index, line) in right.iter().enumerate() {
|
||||||
|
positions.entry(line).or_default().push(index);
|
||||||
|
}
|
||||||
|
if right.len() >= 200 {
|
||||||
|
let popular = right.len() / 100 + 1;
|
||||||
|
positions.retain(|_, items| items.len() <= popular);
|
||||||
|
}
|
||||||
|
let mut queue = vec![(0, left.len(), 0, right.len())];
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
while let Some((alo, ahi, blo, bhi)) = queue.pop() {
|
||||||
|
ensure!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"Diff calculation exceeded the five-second limit"
|
||||||
|
);
|
||||||
|
let (mut best_a, mut best_b, mut size) = (alo, blo, 0);
|
||||||
|
let mut previous: HashMap<usize, usize> = HashMap::new();
|
||||||
|
for (i, line) in left.iter().enumerate().take(ahi).skip(alo) {
|
||||||
|
if i % 128 == 0 {
|
||||||
|
ensure!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"Diff calculation exceeded the five-second limit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut current = HashMap::new();
|
||||||
|
if let Some(indices) = positions.get(line) {
|
||||||
|
for &j in indices {
|
||||||
|
if j < blo {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if j >= bhi {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let length = j
|
||||||
|
.checked_sub(1)
|
||||||
|
.and_then(|v| previous.get(&v))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0)
|
||||||
|
+ 1;
|
||||||
|
current.insert(j, length);
|
||||||
|
if length > size {
|
||||||
|
best_a = i + 1 - length;
|
||||||
|
best_b = j + 1 - length;
|
||||||
|
size = length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
|
while best_a > alo && best_b > blo && left[best_a - 1] == right[best_b - 1] {
|
||||||
|
best_a -= 1;
|
||||||
|
best_b -= 1;
|
||||||
|
size += 1;
|
||||||
|
}
|
||||||
|
while best_a + size < ahi
|
||||||
|
&& best_b + size < bhi
|
||||||
|
&& left[best_a + size] == right[best_b + size]
|
||||||
|
{
|
||||||
|
size += 1;
|
||||||
|
}
|
||||||
|
if size > 0 {
|
||||||
|
blocks.push((best_a, best_b, size));
|
||||||
|
if alo < best_a && blo < best_b {
|
||||||
|
queue.push((alo, best_a, blo, best_b));
|
||||||
|
}
|
||||||
|
if best_a + size < ahi && best_b + size < bhi {
|
||||||
|
queue.push((best_a + size, ahi, best_b + size, bhi));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blocks.sort_unstable();
|
||||||
|
let mut merged: Vec<(usize, usize, usize)> = Vec::new();
|
||||||
|
for (a, b, size) in blocks {
|
||||||
|
if let Some(last) = merged.last_mut()
|
||||||
|
&& last.0 + last.2 == a
|
||||||
|
&& last.1 + last.2 == b
|
||||||
|
{
|
||||||
|
last.2 += size;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
merged.push((a, b, size));
|
||||||
|
}
|
||||||
|
merged.push((left.len(), right.len(), 0));
|
||||||
|
let (mut a, mut b) = (0, 0);
|
||||||
|
let mut operations = Vec::new();
|
||||||
|
for (next_a, next_b, size) in merged {
|
||||||
|
let kind = if a < next_a && b < next_b {
|
||||||
|
Some(Kind::Replace)
|
||||||
|
} else if a < next_a {
|
||||||
|
Some(Kind::Delete)
|
||||||
|
} else if b < next_b {
|
||||||
|
Some(Kind::Insert)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(kind) = kind {
|
||||||
|
operations.push(Operation {
|
||||||
|
kind,
|
||||||
|
a,
|
||||||
|
b: next_a,
|
||||||
|
c: b,
|
||||||
|
d: next_b,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if size > 0 {
|
||||||
|
operations.push(Operation {
|
||||||
|
kind: Kind::Equal,
|
||||||
|
a: next_a,
|
||||||
|
b: next_a + size,
|
||||||
|
c: next_b,
|
||||||
|
d: next_b + size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
a = next_a + size;
|
||||||
|
b = next_b + size;
|
||||||
|
}
|
||||||
|
Ok(operations)
|
||||||
|
}
|
||||||
|
fn line_range(start: usize, end: usize) -> String {
|
||||||
|
let count = end - start;
|
||||||
|
if count == 1 {
|
||||||
|
(start + 1).to_string()
|
||||||
|
} else {
|
||||||
|
format!("{},{}", if count == 0 { start } else { start + 1 }, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn unified_diff(
|
||||||
|
operations: &[Operation],
|
||||||
|
left: &[&str],
|
||||||
|
right: &[&str],
|
||||||
|
left_name: &str,
|
||||||
|
right_name: &str,
|
||||||
|
) -> String {
|
||||||
|
if operations
|
||||||
|
.iter()
|
||||||
|
.all(|operation| operation.kind == Kind::Equal)
|
||||||
|
{
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let mut operations = operations.to_vec();
|
||||||
|
if let Some(first) = operations.first_mut()
|
||||||
|
&& first.kind == Kind::Equal
|
||||||
|
{
|
||||||
|
first.a = first.a.max(first.b.saturating_sub(3));
|
||||||
|
first.c = first.c.max(first.d.saturating_sub(3));
|
||||||
|
}
|
||||||
|
if let Some(last) = operations.last_mut()
|
||||||
|
&& last.kind == Kind::Equal
|
||||||
|
{
|
||||||
|
last.b = last.b.min(last.a + 3);
|
||||||
|
last.d = last.d.min(last.c + 3);
|
||||||
|
}
|
||||||
|
let mut groups = Vec::new();
|
||||||
|
let mut group = Vec::new();
|
||||||
|
for mut operation in operations {
|
||||||
|
if operation.kind == Kind::Equal && operation.b - operation.a > 6 {
|
||||||
|
group.push(Operation {
|
||||||
|
b: operation.a + 3,
|
||||||
|
d: operation.c + 3,
|
||||||
|
..operation
|
||||||
|
});
|
||||||
|
groups.push(group);
|
||||||
|
group = Vec::new();
|
||||||
|
operation.a = operation.b - 3;
|
||||||
|
operation.c = operation.d - 3;
|
||||||
|
}
|
||||||
|
group.push(operation);
|
||||||
|
}
|
||||||
|
if !group.is_empty() && !(group.len() == 1 && group[0].kind == Kind::Equal) {
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
let mut lines = vec![format!("--- {left_name}"), format!("+++ {right_name}")];
|
||||||
|
for group in groups {
|
||||||
|
let first = group.first().unwrap();
|
||||||
|
let last = group.last().unwrap();
|
||||||
|
lines.push(format!(
|
||||||
|
"@@ -{} +{} @@",
|
||||||
|
line_range(first.a, last.b),
|
||||||
|
line_range(first.c, last.d)
|
||||||
|
));
|
||||||
|
for operation in group {
|
||||||
|
if operation.kind == Kind::Equal {
|
||||||
|
for line in &left[operation.a..operation.b] {
|
||||||
|
lines.push(format!(" {line}"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if matches!(operation.kind, Kind::Delete | Kind::Replace) {
|
||||||
|
for line in &left[operation.a..operation.b] {
|
||||||
|
lines.push(format!("-{line}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matches!(operation.kind, Kind::Insert | Kind::Replace) {
|
||||||
|
for line in &right[operation.c..operation.d] {
|
||||||
|
lines.push(format!("+{line}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn reports_all_line_types() {
|
||||||
|
let result = compare_text("a\nb\nc\nd\n", "a\nx\nc\nd\ne\n", "left", "right").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result["stats"],
|
||||||
|
json!({"added":1,"removed":0,"modified":1,"context":3})
|
||||||
|
);
|
||||||
|
assert_eq!(result["lines"][1]["left_number"], 2);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn browser_payload_skips_duplicate_html_document() {
|
||||||
|
let result = compare_text_impl("a\nb", "a\nc", "left", "right", false).unwrap();
|
||||||
|
assert!(result.get("diff_html").is_none());
|
||||||
|
assert!(result.get("lines").is_some());
|
||||||
|
assert!(result.get("diff_text").is_some());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn compact_browser_payload_omits_full_unified_document() {
|
||||||
|
let result = compare_text_options("a\nb", "a\nc", "left", "right", false, false).unwrap();
|
||||||
|
assert!(result.get("diff_html").is_none());
|
||||||
|
assert!(result.get("diff_text").is_none());
|
||||||
|
assert!(result.get("lines").is_some());
|
||||||
|
assert!(result.get("smart").is_some());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn smart_diff_ignores_order_and_reports_field_change() {
|
||||||
|
let left = "# export header\n/ip service\nset ssh disabled=no port=22\n/interface bridge\nadd name=lan\n";
|
||||||
|
let right = "# another header\n/interface bridge\nadd name=lan\n/ip service\nset ssh disabled=yes port=22\n";
|
||||||
|
let result = compare_text(left, right, "left", "right").unwrap();
|
||||||
|
assert_eq!(result["smart"]["sections_changed"], 1);
|
||||||
|
assert_eq!(result["smart"]["modified"], 1);
|
||||||
|
assert_eq!(result["smart"]["sections"][0]["name"], "/ip service");
|
||||||
|
assert_eq!(result["smart"]["sections"][0]["attention"], "high");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn escapes_configuration_and_file_names() {
|
||||||
|
let result = compare_text(
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
"<img src=x>",
|
||||||
|
"<script>",
|
||||||
|
"right",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let html = result["diff_html"].as_str().unwrap();
|
||||||
|
assert!(!html.contains("<script>"));
|
||||||
|
assert!(html.contains("<script>"));
|
||||||
|
assert!(!html.contains("<img src=x>"));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn final_newline_and_crlf_are_normalized() {
|
||||||
|
let result = compare_text("a\r\nb\r\n", "a\nb", "left", "right").unwrap();
|
||||||
|
assert_eq!(result["stats"]["context"], 2);
|
||||||
|
assert_eq!(result["diff_text"], "");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn tracks_blank_lines_in_replacements() {
|
||||||
|
let result = compare_text("a\n\nc", "a\nx\nc", "left", "right").unwrap();
|
||||||
|
assert_eq!(result["lines"][1]["left_number"], 2);
|
||||||
|
assert_eq!(result["lines"][1]["right_number"], 2);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn unified_output_matches_python_difflib_golden_fixtures() {
|
||||||
|
let cases: Vec<Value> =
|
||||||
|
serde_json::from_str(include_str!("../tests/fixtures/python-diff.json")).unwrap();
|
||||||
|
for (index, case) in cases.iter().enumerate() {
|
||||||
|
let result = compare_text(
|
||||||
|
case["left"].as_str().unwrap(),
|
||||||
|
case["right"].as_str().unwrap(),
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result["diff_text"], case["unified"],
|
||||||
|
"Python difflib fixture {index}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
pub mod api;
|
||||||
|
pub mod backup;
|
||||||
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod diff;
|
||||||
|
pub mod models;
|
||||||
|
pub mod notifications;
|
||||||
|
pub mod routeros;
|
||||||
|
pub mod scheduler;
|
||||||
|
pub mod security;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub config: Arc<config::Config>,
|
||||||
|
pub db: db::Db,
|
||||||
|
pub operations: Arc<tokio::sync::Semaphore>,
|
||||||
|
pub device_locks: Arc<Mutex<HashSet<i64>>>,
|
||||||
|
pub login_attempts: Arc<Mutex<HashMap<String, (u32, Instant)>>>,
|
||||||
|
pub workers: tokio_util::task::TaskTracker,
|
||||||
|
pub auth_operations: Arc<tokio::sync::Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(config: config::Config, db: db::Db) -> Self {
|
||||||
|
Self {
|
||||||
|
operations: Arc::new(tokio::sync::Semaphore::new(config.max_parallel_backups)),
|
||||||
|
config: Arc::new(config),
|
||||||
|
db,
|
||||||
|
device_locks: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
workers: tokio_util::task::TaskTracker::new(),
|
||||||
|
auth_operations: Arc::new(tokio::sync::Semaphore::new(4)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use mikrotik_backup::{AppState, api, config::Config, db, scheduler};
|
||||||
|
use std::{net::SocketAddr, path::PathBuf};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "mikrotik-backup", version, about = "MikroTik Backup System")]
|
||||||
|
struct Arguments {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Command>,
|
||||||
|
}
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let args = Arguments::parse();
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "mikrotik_backup=info,tower_http=warn".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
let mut config = Config::from_env_uninitialized()?;
|
||||||
|
match args.command.unwrap_or(Command::Serve) {
|
||||||
|
Command::Healthcheck => {
|
||||||
|
let mut address: SocketAddr = config.listen.parse()?;
|
||||||
|
if address.ip().is_unspecified() {
|
||||||
|
address.set_ip(if address.is_ipv4() {
|
||||||
|
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
|
||||||
|
} else {
|
||||||
|
std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let response = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(4))
|
||||||
|
.no_proxy()
|
||||||
|
.build()?
|
||||||
|
.get(format!("http://{address}{}/health", config.api_prefix))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
let body: serde_json::Value = response.json().await?;
|
||||||
|
anyhow::ensure!(body["status"] == "ok", "Healthcheck failed");
|
||||||
|
println!("ok");
|
||||||
|
}
|
||||||
|
Command::CheckConfig => {
|
||||||
|
println!(
|
||||||
|
"Configuration valid; listen={}, API={}, timezone={}",
|
||||||
|
config.listen, config.api_prefix, config.timezone
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Command::ImportPython {
|
||||||
|
source,
|
||||||
|
database,
|
||||||
|
backup_root,
|
||||||
|
} => {
|
||||||
|
let report = db::import_python(
|
||||||
|
&config,
|
||||||
|
&source,
|
||||||
|
database.as_deref(),
|
||||||
|
backup_root.as_deref(),
|
||||||
|
)?;
|
||||||
|
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||||
|
}
|
||||||
|
Command::Serve => {
|
||||||
|
db::normalize_storage_paths(&mut config)?;
|
||||||
|
db::preflight_existing(&config)?;
|
||||||
|
let lock = db::lock_storage(&config)?;
|
||||||
|
config.initialize_storage()?;
|
||||||
|
let database = db::Db::open(&config)?;
|
||||||
|
let address: SocketAddr = config.listen.parse()?;
|
||||||
|
let listener = tokio::net::TcpListener::bind(address)
|
||||||
|
.await
|
||||||
|
.context("Cannot bind the HTTP listener")?;
|
||||||
|
let state = AppState::new(config, database);
|
||||||
|
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||||
|
let worker = scheduler::start(state.clone(), shutdown_rx);
|
||||||
|
tracing::info!(version=env!("CARGO_PKG_VERSION"),%address,"MikroTik Backup System started");
|
||||||
|
let password_file = state.config.data_dir.join("secrets/initial-admin-password");
|
||||||
|
if password_file.exists() {
|
||||||
|
tracing::info!(path=%password_file.display(),"Initial administrator password file (not printed in logs)");
|
||||||
|
}
|
||||||
|
let shutting_down = state.clone();
|
||||||
|
axum::serve(listener, api::router(state.clone()))
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
shutdown_signal().await;
|
||||||
|
shutting_down.operations.close();
|
||||||
|
let _ = shutdown_tx.send(true);
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
worker.await.context("Scheduler shutdown failed")?;
|
||||||
|
state.workers.close();
|
||||||
|
state.workers.wait().await;
|
||||||
|
state.db.with_conn(|conn| {
|
||||||
|
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
tracing::info!("Shutdown complete");
|
||||||
|
drop(lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let mut terminate =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("SIGTERM handler");
|
||||||
|
tokio::select! { _=tokio::signal::ctrl_c()=>{}, _=terminate.recv()=>{} }
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
+398
@@ -0,0 +1,398 @@
|
|||||||
|
//! 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};
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: i64,
|
||||||
|
pub username: String,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub password_hash: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub preferred_language: String,
|
||||||
|
pub preferred_font: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
pub fn public_json(&self) -> Value {
|
||||||
|
json!({"id": self.id, "username": self.username,
|
||||||
|
"preferred_language": self.preferred_language, "preferred_font": self.preferred_font})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
pub struct RouterRecord {
|
||||||
|
pub id: i64,
|
||||||
|
pub owner_id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub device_type: String,
|
||||||
|
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)]
|
||||||
|
pub ssh_key: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub ssh_password: Option<String>,
|
||||||
|
pub last_connection_status: Option<bool>,
|
||||||
|
pub last_connection_tested_at: Option<String>,
|
||||||
|
pub last_connection_error: Option<String>,
|
||||||
|
pub last_connection_hostname: Option<String>,
|
||||||
|
pub last_connection_model: Option<String>,
|
||||||
|
pub last_connection_version: Option<String>,
|
||||||
|
pub last_connection_uptime: Option<String>,
|
||||||
|
pub last_connection_transport: Option<String>,
|
||||||
|
pub last_connection_server: Option<String>,
|
||||||
|
pub last_connection_auth_mode: Option<String>,
|
||||||
|
pub last_connection_http_status: Option<String>,
|
||||||
|
pub last_connection_backup_available: Option<bool>,
|
||||||
|
pub disable_all_backups: bool,
|
||||||
|
pub disable_export_backups: bool,
|
||||||
|
pub disable_binary_backups: bool,
|
||||||
|
pub disable_ping: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RouterRecord {
|
||||||
|
fn switchos_credential_fields<'a>(
|
||||||
|
&'a self,
|
||||||
|
settings: &'a Settings,
|
||||||
|
) -> (Option<&'a str>, &'a str, bool) {
|
||||||
|
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(|s| !s.is_empty());
|
||||||
|
let global_user = settings
|
||||||
|
.default_switchos_username
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let global_password = settings
|
||||||
|
.default_switchos_password
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
match self.use_global_switchos_credentials {
|
||||||
|
Some(true) => (
|
||||||
|
global_user.filter(|s| !s.trim().is_empty()),
|
||||||
|
global_password.unwrap_or(""),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
Some(false) => (own_user, own_password.unwrap_or(""), false),
|
||||||
|
None => (
|
||||||
|
own_user.or(global_user),
|
||||||
|
own_password.or(global_password).unwrap_or(""),
|
||||||
|
(own_user.is_none() && global_user.is_some())
|
||||||
|
|| (own_password.is_none() && global_password.is_some()),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
) -> Result<(&'a str, &'a str)> {
|
||||||
|
let (username, password, _) = self.switchos_credential_fields(settings);
|
||||||
|
let username = username.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(match self.use_global_switchos_credentials {
|
||||||
|
Some(true) => "Global SwitchOS username is not configured in Settings",
|
||||||
|
Some(false) => "SwitchOS username is not configured on this device",
|
||||||
|
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 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 (switchos_user, switchos_password, uses_global_switchos) =
|
||||||
|
self.switchos_credential_fields(settings);
|
||||||
|
let effective_username = if is_routeros { own_user } else { switchos_user };
|
||||||
|
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);
|
||||||
|
object.insert("ssh_password".into(), Value::Null);
|
||||||
|
object.insert(
|
||||||
|
"has_own_password".into(),
|
||||||
|
json!(self.ssh_password.as_deref().is_some_and(|s| !s.is_empty())),
|
||||||
|
);
|
||||||
|
object.insert("effective_username".into(), json!(effective_username));
|
||||||
|
object.insert(
|
||||||
|
"uses_global_ssh_key".into(),
|
||||||
|
json!(is_routeros && !own_key && global_key),
|
||||||
|
);
|
||||||
|
object.insert(
|
||||||
|
"has_effective_ssh_key".into(),
|
||||||
|
json!(is_routeros && (own_key || global_key)),
|
||||||
|
);
|
||||||
|
object.insert(
|
||||||
|
"uses_global_switchos_credentials".into(),
|
||||||
|
json!(!is_routeros && uses_global_switchos),
|
||||||
|
);
|
||||||
|
object.insert(
|
||||||
|
"has_effective_password".into(),
|
||||||
|
json!(if is_routeros {
|
||||||
|
present(&self.ssh_password)
|
||||||
|
} else {
|
||||||
|
!switchos_password.is_empty()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
object.insert("supports_export".into(), json!(is_routeros));
|
||||||
|
object.insert("supports_restore_upload".into(), json!(is_routeros));
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct BackupRecord {
|
||||||
|
pub id: i64,
|
||||||
|
pub router_id: i64,
|
||||||
|
pub file_path: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub backup_type: String,
|
||||||
|
pub checksum: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct OperationLog {
|
||||||
|
pub id: i64,
|
||||||
|
pub message: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Settings {
|
||||||
|
pub id: i64,
|
||||||
|
pub backup_retention_days: i64,
|
||||||
|
pub log_retention_days: i64,
|
||||||
|
pub export_cron: String,
|
||||||
|
pub binary_cron: String,
|
||||||
|
pub retention_cron: String,
|
||||||
|
pub enable_auto_export: bool,
|
||||||
|
pub connection_test_interval_minutes: i64,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub global_ssh_key: Option<String>,
|
||||||
|
pub default_switchos_username: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub default_switchos_password: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub pushover_token: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub pushover_userkey: Option<String>,
|
||||||
|
pub notify_failures_only: bool,
|
||||||
|
pub smtp_host: Option<String>,
|
||||||
|
pub smtp_port: i64,
|
||||||
|
pub smtp_login: Option<String>,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub smtp_password: Option<String>,
|
||||||
|
pub smtp_notifications_enabled: bool,
|
||||||
|
pub recipient_email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: 1,
|
||||||
|
backup_retention_days: 7,
|
||||||
|
log_retention_days: 7,
|
||||||
|
export_cron: String::new(),
|
||||||
|
binary_cron: String::new(),
|
||||||
|
retention_cron: String::new(),
|
||||||
|
enable_auto_export: false,
|
||||||
|
connection_test_interval_minutes: 0,
|
||||||
|
global_ssh_key: None,
|
||||||
|
default_switchos_username: None,
|
||||||
|
default_switchos_password: None,
|
||||||
|
pushover_token: None,
|
||||||
|
pushover_userkey: None,
|
||||||
|
notify_failures_only: true,
|
||||||
|
smtp_host: None,
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_login: None,
|
||||||
|
smtp_password: None,
|
||||||
|
smtp_notifications_enabled: false,
|
||||||
|
recipient_email: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
pub fn public_json(&self) -> Value {
|
||||||
|
let mut value = serde_json::to_value(self).expect("record is serializable");
|
||||||
|
let object = value.as_object_mut().expect("record is an object");
|
||||||
|
for (name, field) in [
|
||||||
|
("global_ssh_key", &self.global_ssh_key),
|
||||||
|
("default_switchos_password", &self.default_switchos_password),
|
||||||
|
("pushover_token", &self.pushover_token),
|
||||||
|
("pushover_userkey", &self.pushover_userkey),
|
||||||
|
("smtp_password", &self.smtp_password),
|
||||||
|
] {
|
||||||
|
object.insert(name.into(), Value::Null);
|
||||||
|
object.insert(format!("has_{name}"), json!(present(field)));
|
||||||
|
}
|
||||||
|
object.insert(
|
||||||
|
"has_default_switchos_credentials".into(),
|
||||||
|
json!(
|
||||||
|
present(&self.default_switchos_username)
|
||||||
|
|| present(&self.default_switchos_password)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Settings must be an object"))?;
|
||||||
|
let mut value = serde_json::to_value(&*self)?;
|
||||||
|
let merged = value.as_object_mut().expect("record is an object");
|
||||||
|
for (name, field) in [
|
||||||
|
("global_ssh_key", &self.global_ssh_key),
|
||||||
|
("default_switchos_password", &self.default_switchos_password),
|
||||||
|
("pushover_token", &self.pushover_token),
|
||||||
|
("pushover_userkey", &self.pushover_userkey),
|
||||||
|
("smtp_password", &self.smtp_password),
|
||||||
|
] {
|
||||||
|
merged.insert(name.into(), json!(field));
|
||||||
|
}
|
||||||
|
let secrets = [
|
||||||
|
"global_ssh_key",
|
||||||
|
"default_switchos_password",
|
||||||
|
"pushover_token",
|
||||||
|
"pushover_userkey",
|
||||||
|
"smtp_password",
|
||||||
|
];
|
||||||
|
for (key, value) in object {
|
||||||
|
if key == "id" || !merged.contains_key(key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if secrets.contains(&key.as_str())
|
||||||
|
&& (value.is_null() || value.as_str().is_some_and(|s| s.trim().is_empty()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
merged.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
for key in secrets {
|
||||||
|
if object.get(&format!("clear_{key}")).and_then(Value::as_bool) == Some(true) {
|
||||||
|
merged.insert(key.into(), Value::Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut candidate: Self = serde_json::from_value(value)?;
|
||||||
|
candidate.export_cron = candidate.export_cron.trim().into();
|
||||||
|
candidate.binary_cron = candidate.binary_cron.trim().into();
|
||||||
|
candidate.retention_cron = candidate.retention_cron.trim().into();
|
||||||
|
for item in [
|
||||||
|
&mut candidate.global_ssh_key,
|
||||||
|
&mut candidate.default_switchos_username,
|
||||||
|
&mut candidate.default_switchos_password,
|
||||||
|
&mut candidate.pushover_token,
|
||||||
|
&mut candidate.pushover_userkey,
|
||||||
|
&mut candidate.smtp_host,
|
||||||
|
&mut candidate.smtp_login,
|
||||||
|
&mut candidate.recipient_email,
|
||||||
|
] {
|
||||||
|
*item = item
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_owned);
|
||||||
|
}
|
||||||
|
if !(0..=36500).contains(&candidate.backup_retention_days)
|
||||||
|
|| !(0..=36500).contains(&candidate.log_retention_days)
|
||||||
|
{
|
||||||
|
bail!("Retention days must be between 0 and 36500");
|
||||||
|
}
|
||||||
|
if !(0..=1440).contains(&candidate.connection_test_interval_minutes) {
|
||||||
|
bail!("Connection interval must be between 0 and 1440 minutes");
|
||||||
|
}
|
||||||
|
if !(1..=65535).contains(&candidate.smtp_port) {
|
||||||
|
bail!("SMTP port is invalid");
|
||||||
|
}
|
||||||
|
if let Some(address) = &candidate.recipient_email
|
||||||
|
&& !valid_email(address)
|
||||||
|
{
|
||||||
|
bail!("Recipient email is invalid");
|
||||||
|
}
|
||||||
|
*self = candidate;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_email(address: &str) -> bool {
|
||||||
|
if address.len() > 254
|
||||||
|
|| address.chars().any(char::is_whitespace)
|
||||||
|
|| address.contains(['\r', '\n'])
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some((local, domain)) = address.split_once('@') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
!local.is_empty()
|
||||||
|
&& local.len() <= 64
|
||||||
|
&& !domain.is_empty()
|
||||||
|
&& !domain.contains('@')
|
||||||
|
&& domain
|
||||||
|
.split('.')
|
||||||
|
.all(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn present(value: &Option<String>) -> bool {
|
||||||
|
value.as_ref().is_some_and(|s| !s.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn settings_keep_secrets_and_clear_explicitly() {
|
||||||
|
let mut value = Settings {
|
||||||
|
smtp_password: Some("private".into()),
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
value
|
||||||
|
.apply_patch(&json!({"smtp_password": null, "smtp_port": 465}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(value.smtp_password.as_deref(), Some("private"));
|
||||||
|
assert_eq!(value.public_json()["smtp_password"], Value::Null);
|
||||||
|
assert_eq!(value.public_json()["has_smtp_password"], true);
|
||||||
|
value.apply_patch(&json!({"smtp_password": ""})).unwrap();
|
||||||
|
assert_eq!(value.smtp_password.as_deref(), Some("private"));
|
||||||
|
value
|
||||||
|
.apply_patch(&json!({"clear_smtp_password": true}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(value.smtp_password, None);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn invalid_patch_is_atomic() {
|
||||||
|
let mut value = Settings::default();
|
||||||
|
assert!(
|
||||||
|
value
|
||||||
|
.apply_patch(&json!({"smtp_port": 0, "export_cron": "changed"}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(value.smtp_port, 587);
|
||||||
|
assert!(value.export_cron.is_empty());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn generic_serialization_does_not_leak_secrets() {
|
||||||
|
let value = Settings {
|
||||||
|
global_ssh_key: Some("private".into()),
|
||||||
|
smtp_password: Some("password".into()),
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
let public = serde_json::to_string(&value).unwrap();
|
||||||
|
assert!(!public.contains("private"));
|
||||||
|
assert!(!public.contains("password"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
//! SMTP and Pushover providers. Notification failures never turn a completed backup into a failed one.
|
||||||
|
use crate::{
|
||||||
|
AppState,
|
||||||
|
models::{RouterRecord, Settings},
|
||||||
|
};
|
||||||
|
use anyhow::{Context, Result, ensure};
|
||||||
|
use lettre::{
|
||||||
|
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||||
|
message::{Attachment, Mailbox, MultiPart, SinglePart, header::ContentType},
|
||||||
|
transport::smtp::authentication::Credentials,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{path::Path, time::Duration};
|
||||||
|
|
||||||
|
pub async fn send_email(
|
||||||
|
state: &AppState,
|
||||||
|
settings: &Settings,
|
||||||
|
subject: &str,
|
||||||
|
body: &str,
|
||||||
|
attachment: Option<&Path>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let host = settings
|
||||||
|
.smtp_host
|
||||||
|
.as_deref()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.context("SMTP host is not configured")?;
|
||||||
|
let login = settings
|
||||||
|
.smtp_login
|
||||||
|
.as_deref()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.context("SMTP login is not configured")?;
|
||||||
|
let password = settings
|
||||||
|
.smtp_password
|
||||||
|
.as_deref()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.context("SMTP password is not configured")?;
|
||||||
|
let recipient = settings
|
||||||
|
.recipient_email
|
||||||
|
.as_deref()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.unwrap_or(login);
|
||||||
|
let from: Mailbox = login
|
||||||
|
.parse()
|
||||||
|
.context("SMTP login must be an email address for the sender")?;
|
||||||
|
let to: Mailbox = recipient.parse().context("Recipient email is invalid")?;
|
||||||
|
let mut content = MultiPart::mixed().singlepart(SinglePart::plain(body.to_owned()));
|
||||||
|
if let Some(path) = attachment {
|
||||||
|
ensure!(
|
||||||
|
tokio::fs::metadata(path).await?.len() <= 25 * 1024 * 1024,
|
||||||
|
"Email attachment exceeds the 25 MiB limit; use Download instead"
|
||||||
|
);
|
||||||
|
let bytes = tokio::fs::read(path).await?;
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|v| v.to_str())
|
||||||
|
.context("Invalid attachment filename")?;
|
||||||
|
content = content.singlepart(
|
||||||
|
Attachment::new(name.to_owned())
|
||||||
|
.body(bytes, ContentType::parse("application/octet-stream")?),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let message = Message::builder()
|
||||||
|
.from(from)
|
||||||
|
.to(to)
|
||||||
|
.subject(subject)
|
||||||
|
.multipart(content)?;
|
||||||
|
let builder = if state.config.smtp_starttls {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host)
|
||||||
|
.context("Cannot configure SMTP TLS")?
|
||||||
|
} else {
|
||||||
|
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host)
|
||||||
|
};
|
||||||
|
let transport = builder
|
||||||
|
.port(settings.smtp_port as u16)
|
||||||
|
.timeout(Some(Duration::from_secs(state.config.smtp_timeout_seconds)))
|
||||||
|
.credentials(Credentials::new(login.to_owned(), password.to_owned()))
|
||||||
|
.build();
|
||||||
|
transport.send(message).await.map_err(|_| {
|
||||||
|
anyhow::anyhow!("SMTP delivery failed; verify host, port, TLS and credentials")
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_pushover(settings: &Settings, message: &str) -> Result<()> {
|
||||||
|
send_pushover_to(
|
||||||
|
settings,
|
||||||
|
message,
|
||||||
|
"https://api.pushover.net/1/messages.json",
|
||||||
|
Duration::from_secs(1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
async fn send_pushover_to(
|
||||||
|
settings: &Settings,
|
||||||
|
message: &str,
|
||||||
|
endpoint: &str,
|
||||||
|
backoff: Duration,
|
||||||
|
) -> Result<()> {
|
||||||
|
let token = settings
|
||||||
|
.pushover_token
|
||||||
|
.as_deref()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.context("Pushover token is not configured")?;
|
||||||
|
let user = settings
|
||||||
|
.pushover_userkey
|
||||||
|
.as_deref()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.context("Pushover user key is not configured")?;
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(15))
|
||||||
|
.build()?;
|
||||||
|
for attempt in 0..3 {
|
||||||
|
let response = client
|
||||||
|
.post(endpoint)
|
||||||
|
.form(&[
|
||||||
|
("token", token),
|
||||||
|
("user", user),
|
||||||
|
("message", message),
|
||||||
|
("title", "RouterOS Backup"),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
match response {
|
||||||
|
Ok(response) => {
|
||||||
|
let status = response.status();
|
||||||
|
if status.is_success() {
|
||||||
|
let body: Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("Pushover returned invalid JSON")?;
|
||||||
|
ensure!(
|
||||||
|
body["status"].as_i64() == Some(1),
|
||||||
|
"Pushover rejected the notification"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !status.is_server_error() && status.as_u16() != 429 {
|
||||||
|
anyhow::bail!("Pushover rejected notification (HTTP {})", status.as_u16());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) if attempt == 2 => anyhow::bail!("Cannot connect to Pushover"),
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
if attempt < 2 {
|
||||||
|
tokio::time::sleep(backoff.saturating_mul(1 << attempt)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anyhow::bail!("Pushover delivery failed after three attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn notify(state: &AppState, settings: &Settings, message: &str, success: bool) {
|
||||||
|
if success && settings.notify_failures_only {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if settings.smtp_notifications_enabled
|
||||||
|
&& let Err(error) = send_email(
|
||||||
|
state,
|
||||||
|
settings,
|
||||||
|
"Mikrotik Backup System notification",
|
||||||
|
message,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = state.db.log(&format!("Email notification failed: {error}"));
|
||||||
|
}
|
||||||
|
if settings.pushover_token.is_some()
|
||||||
|
&& settings.pushover_userkey.is_some()
|
||||||
|
&& let Err(error) = send_pushover(settings, message).await
|
||||||
|
{
|
||||||
|
let _ = state
|
||||||
|
.db
|
||||||
|
.log(&format!("Pushover notification failed: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn test_email(state: &AppState) -> Result<()> {
|
||||||
|
let settings = state.db.get_settings()?;
|
||||||
|
send_email(
|
||||||
|
state,
|
||||||
|
&settings,
|
||||||
|
"Mikrotik Backup System test",
|
||||||
|
"This is a test email from Mikrotik Backup System",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
pub async fn test_pushover(state: &AppState) -> Result<()> {
|
||||||
|
send_pushover(
|
||||||
|
&state.db.get_settings()?,
|
||||||
|
"Test pushover from Mikrotik Backup System",
|
||||||
|
)
|
||||||
|
.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_switchos_password.as_deref(),
|
||||||
|
settings.smtp_password.as_deref(),
|
||||||
|
settings.pushover_token.as_deref(),
|
||||||
|
settings.pushover_userkey.as_deref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.collect();
|
||||||
|
if let Some(router) = router {
|
||||||
|
secrets.extend(
|
||||||
|
[router.ssh_key.as_deref(), router.ssh_password.as_deref()]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|v| !v.is_empty()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
|
||||||
|
secrets
|
||||||
|
.into_iter()
|
||||||
|
.fold(message.to_owned(), |message, secret| {
|
||||||
|
message.replace(secret, "[redacted]")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_pushover_settings_fail_before_network() {
|
||||||
|
assert!(
|
||||||
|
send_pushover(&Settings::default(), "test")
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("not configured")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn error_redaction_covers_global_secret_fields() {
|
||||||
|
let s = Settings {
|
||||||
|
global_ssh_key: Some("private-key".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]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn smtp_delivery_with_attachment_against_local_fixture() {
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let (sent, received) = tokio::sync::oneshot::channel();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let (read, mut write) = stream.into_split();
|
||||||
|
let mut read = BufReader::new(read);
|
||||||
|
write.write_all(b"220 fixture ESMTP\r\n").await.unwrap();
|
||||||
|
let mut sent = Some(sent);
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
if read.read_line(&mut line).await.unwrap() == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if line.starts_with("EHLO") {
|
||||||
|
write
|
||||||
|
.write_all(b"250-fixture\r\n250-AUTH PLAIN LOGIN\r\n250 SIZE 26214400\r\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
} else if line.starts_with("AUTH") {
|
||||||
|
write
|
||||||
|
.write_all(b"235 2.7.0 authenticated\r\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
} else if line.starts_with("MAIL FROM") || line.starts_with("RCPT TO") {
|
||||||
|
write.write_all(b"250 2.1.0 OK\r\n").await.unwrap();
|
||||||
|
} else if line.starts_with("DATA") {
|
||||||
|
write.write_all(b"354 End with dot\r\n").await.unwrap();
|
||||||
|
let mut content = String::new();
|
||||||
|
loop {
|
||||||
|
let mut part = String::new();
|
||||||
|
read.read_line(&mut part).await.unwrap();
|
||||||
|
if part == ".\r\n" {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
content.push_str(&part);
|
||||||
|
}
|
||||||
|
if let Some(sent) = sent.take() {
|
||||||
|
let _ = sent.send(content);
|
||||||
|
}
|
||||||
|
write.write_all(b"250 2.0.0 queued\r\n").await.unwrap();
|
||||||
|
} else if line.starts_with("QUIT") {
|
||||||
|
write.write_all(b"221 bye\r\n").await.unwrap();
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
write.write_all(b"250 OK\r\n").await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = crate::config::Config::from_values(&std::collections::HashMap::from([
|
||||||
|
("DATA_DIR".into(), dir.path().display().to_string()),
|
||||||
|
("DEFAULT_ADMIN_PASSWORD".into(), "test-password".into()),
|
||||||
|
("SMTP_STARTTLS".into(), "false".into()),
|
||||||
|
("SMTP_TIMEOUT_SECONDS".into(), "3".into()),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
let db = crate::db::Db::open(&config).unwrap();
|
||||||
|
let state = AppState::new(config, db);
|
||||||
|
let settings = Settings {
|
||||||
|
smtp_host: Some("127.0.0.1".into()),
|
||||||
|
smtp_port: port as i64,
|
||||||
|
smtp_login: Some("sender@example.test".into()),
|
||||||
|
smtp_password: Some("password".into()),
|
||||||
|
recipient_email: Some("recipient@example.test".into()),
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
let attachment = dir.path().join("fixture.backup");
|
||||||
|
std::fs::write(&attachment, b"backup-bytes").unwrap();
|
||||||
|
send_email(
|
||||||
|
&state,
|
||||||
|
&settings,
|
||||||
|
"Fixture subject",
|
||||||
|
"Fixture body",
|
||||||
|
Some(&attachment),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let content = tokio::time::timeout(Duration::from_secs(3), received)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(content.contains("Fixture subject"));
|
||||||
|
assert!(content.contains("Fixture body"));
|
||||||
|
assert!(content.contains("fixture.backup"));
|
||||||
|
let part = content
|
||||||
|
.split("\r\n--")
|
||||||
|
.find(|part| part.contains("filename=\"fixture.backup\""))
|
||||||
|
.expect("attachment MIME part");
|
||||||
|
let (headers, body) = part.split_once("\r\n\r\n").unwrap();
|
||||||
|
let body = body.trim_end_matches(['\r', '\n']);
|
||||||
|
let decoded = if headers
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.contains("content-transfer-encoding: base64")
|
||||||
|
{
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(body.replace("\r\n", ""))
|
||||||
|
.unwrap()
|
||||||
|
} else if headers
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.contains("content-transfer-encoding: quoted-printable")
|
||||||
|
{
|
||||||
|
let bytes = body.as_bytes();
|
||||||
|
let mut decoded = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'=' && i + 2 < bytes.len() {
|
||||||
|
if &bytes[i + 1..i + 3] == b"\r\n" {
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
decoded.push(
|
||||||
|
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap(), 16)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
i += 3;
|
||||||
|
} else {
|
||||||
|
decoded.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decoded
|
||||||
|
} else {
|
||||||
|
body.as_bytes().to_vec()
|
||||||
|
};
|
||||||
|
assert_eq!(decoded, b"backup-bytes");
|
||||||
|
assert!(!content.contains("password"));
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pushover_protocol_success_retry_and_rejection_against_local_fixture() {
|
||||||
|
use axum::{Router, http::StatusCode, response::IntoResponse, routing::post};
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
let count = Arc::new(AtomicUsize::new(0));
|
||||||
|
let requests = count.clone();
|
||||||
|
let app = Router::new()
|
||||||
|
.route(
|
||||||
|
"/retry",
|
||||||
|
post(move |body: String| {
|
||||||
|
let requests = requests.clone();
|
||||||
|
async move {
|
||||||
|
assert!(body.contains("token=fixture-token"));
|
||||||
|
assert!(body.contains("user=fixture-user"));
|
||||||
|
assert!(body.contains("message=fixture+message"));
|
||||||
|
if requests.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||||
|
(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
axum::Json(serde_json::json!({"status":0})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
} else {
|
||||||
|
(StatusCode::OK, axum::Json(serde_json::json!({"status":1})))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/reject",
|
||||||
|
post(|| async {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
axum::Json(serde_json::json!({"status":0})),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/reject-body",
|
||||||
|
post(|| async { axum::Json(serde_json::json!({"status":0})) }),
|
||||||
|
);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
let settings = Settings {
|
||||||
|
pushover_token: Some("fixture-token".into()),
|
||||||
|
pushover_userkey: Some("fixture-user".into()),
|
||||||
|
..Settings::default()
|
||||||
|
};
|
||||||
|
send_pushover_to(
|
||||||
|
&settings,
|
||||||
|
"fixture message",
|
||||||
|
&format!("http://127.0.0.1:{port}/retry"),
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count.load(Ordering::SeqCst), 2);
|
||||||
|
assert!(
|
||||||
|
send_pushover_to(
|
||||||
|
&settings,
|
||||||
|
"fixture message",
|
||||||
|
&format!("http://127.0.0.1:{port}/reject"),
|
||||||
|
Duration::ZERO
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("400")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
send_pushover_to(
|
||||||
|
&settings,
|
||||||
|
"fixture message",
|
||||||
|
&format!("http://127.0.0.1:{port}/reject-body"),
|
||||||
|
Duration::ZERO
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("rejected")
|
||||||
|
);
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
+2375
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,345 @@
|
|||||||
|
//! 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};
|
||||||
|
use chrono_tz::Tz;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::Duration as StdDuration,
|
||||||
|
};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Field {
|
||||||
|
values: HashSet<u32>,
|
||||||
|
}
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Cron {
|
||||||
|
minute: Field,
|
||||||
|
hour: Field,
|
||||||
|
day: Field,
|
||||||
|
month: Field,
|
||||||
|
weekday: Field,
|
||||||
|
last_day: bool,
|
||||||
|
timezone: Tz,
|
||||||
|
}
|
||||||
|
fn value(raw: &str, minimum: u32, maximum: u32, names: &[&str]) -> Result<u32> {
|
||||||
|
let v = if let Ok(number) = raw.parse() {
|
||||||
|
number
|
||||||
|
} else {
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.position(|name| raw.eq_ignore_ascii_case(name))
|
||||||
|
.map(|i| i as u32 + minimum)
|
||||||
|
.context("Invalid cron field value")?
|
||||||
|
};
|
||||||
|
ensure!(
|
||||||
|
(minimum..=maximum).contains(&v),
|
||||||
|
"Cron field value is out of range"
|
||||||
|
);
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
fn field(raw: &str, minimum: u32, maximum: u32, names: &[&str]) -> Result<Field> {
|
||||||
|
let mut values = HashSet::new();
|
||||||
|
for item in raw.split(',') {
|
||||||
|
let (range, step) = if let Some((range, step)) = item.split_once('/') {
|
||||||
|
(range, step.parse::<u32>().context("Invalid cron step")?)
|
||||||
|
} else {
|
||||||
|
(item, 1)
|
||||||
|
};
|
||||||
|
ensure!(
|
||||||
|
step > 0 && step <= maximum - minimum + 1,
|
||||||
|
"Cron step is out of range"
|
||||||
|
);
|
||||||
|
let (start, end) = if range == "*" {
|
||||||
|
(minimum, maximum)
|
||||||
|
} else if let Some((a, b)) = range.split_once('-') {
|
||||||
|
(
|
||||||
|
value(a, minimum, maximum, names)?,
|
||||||
|
value(b, minimum, maximum, names)?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let start = value(range, minimum, maximum, names)?;
|
||||||
|
(start, if item.contains('/') { maximum } else { start })
|
||||||
|
};
|
||||||
|
ensure!(start <= end, "Cron range start exceeds end");
|
||||||
|
for v in (start..=end).step_by(step as usize) {
|
||||||
|
values.insert(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensure!(!values.is_empty(), "Empty cron field");
|
||||||
|
Ok(Field { values })
|
||||||
|
}
|
||||||
|
impl Cron {
|
||||||
|
fn parse(expression: &str, timezone: &str) -> Result<Self> {
|
||||||
|
let parts: Vec<_> = expression.split_whitespace().collect();
|
||||||
|
ensure!(
|
||||||
|
parts.len() == 5,
|
||||||
|
"Cron expression must contain exactly 5 fields"
|
||||||
|
);
|
||||||
|
let last_day = parts[2].eq_ignore_ascii_case("last");
|
||||||
|
Ok(Self {
|
||||||
|
minute: field(parts[0], 0, 59, &[])?,
|
||||||
|
hour: field(parts[1], 0, 23, &[])?,
|
||||||
|
day: field(if last_day { "*" } else { parts[2] }, 1, 31, &[])?,
|
||||||
|
month: field(
|
||||||
|
parts[3],
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
&[
|
||||||
|
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov",
|
||||||
|
"dec",
|
||||||
|
],
|
||||||
|
)?,
|
||||||
|
weekday: field(
|
||||||
|
parts[4],
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
&["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
|
||||||
|
)?,
|
||||||
|
last_day,
|
||||||
|
timezone: timezone.parse().context("Unknown timezone")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn matches(&self, time: DateTime<Utc>) -> bool {
|
||||||
|
let local = time.with_timezone(&self.timezone);
|
||||||
|
let last = !self.last_day
|
||||||
|
|| local
|
||||||
|
.date_naive()
|
||||||
|
.succ_opt()
|
||||||
|
.is_some_and(|next| next.month() != local.month());
|
||||||
|
self.minute.values.contains(&local.minute())
|
||||||
|
&& self.hour.values.contains(&local.hour())
|
||||||
|
&& self.day.values.contains(&local.day())
|
||||||
|
&& self.month.values.contains(&local.month())
|
||||||
|
&& self
|
||||||
|
.weekday
|
||||||
|
.values
|
||||||
|
.contains(&local.weekday().num_days_from_monday())
|
||||||
|
&& last
|
||||||
|
}
|
||||||
|
fn next(&self, from: DateTime<Utc>, count: usize) -> Vec<DateTime<Tz>> {
|
||||||
|
let Some(mut time) = Utc
|
||||||
|
.timestamp_opt((from.timestamp() / 60 + 1) * 60, 0)
|
||||||
|
.single()
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut runs = Vec::new();
|
||||||
|
let limit = time + Duration::days(366 * 400);
|
||||||
|
while runs.len() < count && time < limit {
|
||||||
|
let local = time.with_timezone(&self.timezone);
|
||||||
|
if !self.month.values.contains(&local.month())
|
||||||
|
|| !self.day.values.contains(&local.day())
|
||||||
|
|| !self
|
||||||
|
.weekday
|
||||||
|
.values
|
||||||
|
.contains(&local.weekday().num_days_from_monday())
|
||||||
|
|| (self.last_day
|
||||||
|
&& local
|
||||||
|
.date_naive()
|
||||||
|
.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()
|
||||||
|
{
|
||||||
|
let next = next.with_timezone(&Utc);
|
||||||
|
if next > time {
|
||||||
|
time = next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.matches(time) {
|
||||||
|
runs.push(time.with_timezone(&self.timezone));
|
||||||
|
}
|
||||||
|
time += Duration::minutes(1);
|
||||||
|
}
|
||||||
|
runs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn validate_cron(expression: &str, timezone: &str) -> Result<()> {
|
||||||
|
Cron::parse(expression, timezone).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cron_status(key: &str, label: &str, enabled: bool, expression: &str, timezone: &str) -> Value {
|
||||||
|
if !enabled || expression.trim().is_empty() {
|
||||||
|
return json!({"key":key,"label":label,"enabled":false,"cron":if expression.is_empty(){None}else{Some(expression)},"description":"settings.scheduleDisabledHint","description_params":null,"valid":true,"next_runs":[],"error":null});
|
||||||
|
}
|
||||||
|
match Cron::parse(expression, timezone) {
|
||||||
|
Ok(cron) => {
|
||||||
|
json!({"key":key,"label":label,"enabled":true,"cron":expression,"description":"settings.schedulerCronDescription","description_params":{"description":expression},"valid":true,"next_runs":cron.next(Utc::now(),3),"error":null})
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
json!({"key":key,"label":label,"enabled":true,"cron":expression,"description":"settings.schedulerInvalidCron","description_params":null,"valid":false,"next_runs":[],"error":error.to_string()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn status(state: &AppState) -> Result<Value> {
|
||||||
|
let s = state.db.get_settings()?;
|
||||||
|
let minutes = s.connection_test_interval_minutes;
|
||||||
|
let now = Utc::now();
|
||||||
|
let runs: Vec<_> = if minutes > 0 {
|
||||||
|
(1..=3)
|
||||||
|
.map(|n| now + Duration::minutes(minutes * n))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
Ok(
|
||||||
|
json!({"timezone":state.config.timezone,"running":true,"jobs":[
|
||||||
|
cron_status("auto_export","settings.schedulerAutoExportLabel",s.enable_auto_export,&s.export_cron,&state.config.timezone),
|
||||||
|
cron_status("auto_binary","settings.schedulerBinaryLabel",!s.binary_cron.is_empty(),&s.binary_cron,&state.config.timezone),
|
||||||
|
cron_status("retention","settings.schedulerRetentionLabel",!s.retention_cron.is_empty(),&s.retention_cron,&state.config.timezone),
|
||||||
|
{"key":"connection_probe","label":"settings.schedulerConnectionLabel","enabled":minutes>0,"cron":null,"description":if minutes>0{"settings.connectionTestsEverySummary"}else{"settings.connectionTestsDisabledHint"},"description_params":{"minutes":minutes},"valid":true,"next_runs":runs,"error":null},
|
||||||
|
{"key":"log_cleanup","label":"settings.schedulerLogsLabel","enabled":true,"cron":null,"description":"settings.schedulerLogsDescription","description_params":null,"valid":true,"next_runs":[],"error":null}
|
||||||
|
]}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RunningGuard {
|
||||||
|
key: String,
|
||||||
|
set: Arc<Mutex<HashSet<String>>>,
|
||||||
|
}
|
||||||
|
impl Drop for RunningGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(mut set) = self.set.lock() {
|
||||||
|
set.remove(&self.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn job(state: &AppState, key: &str) -> Result<()> {
|
||||||
|
match key {
|
||||||
|
"auto_export" => {
|
||||||
|
backup::run_all(state, None, "export").await?;
|
||||||
|
}
|
||||||
|
"auto_binary" => {
|
||||||
|
backup::run_all(state, None, "binary").await?;
|
||||||
|
}
|
||||||
|
"retention" => {
|
||||||
|
backup::cleanup(state).await?;
|
||||||
|
}
|
||||||
|
"connection_probe" => {
|
||||||
|
for router in state.db.routers(None)? {
|
||||||
|
routeros::test_connection(state, &router).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"log_cleanup" => {
|
||||||
|
let s = state.db.get_settings()?;
|
||||||
|
let cutoff = (Utc::now() - Duration::days(s.log_retention_days.max(0))).to_rfc3339();
|
||||||
|
let count = state.db.with_conn(|conn| {
|
||||||
|
Ok(conn.execute(
|
||||||
|
"DELETE FROM operation_logs WHERE julianday(timestamp)<julianday(?1)",
|
||||||
|
[cutoff],
|
||||||
|
)?)
|
||||||
|
})?;
|
||||||
|
state.db.log(&format!(
|
||||||
|
"Log retention cleanup removed {count} entries older than {} days",
|
||||||
|
s.log_retention_days
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
_ => bail!("Unknown scheduler job"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signature(s: &Settings) -> String {
|
||||||
|
format!(
|
||||||
|
"{}|{}|{}|{}|{}",
|
||||||
|
s.enable_auto_export,
|
||||||
|
s.export_cron,
|
||||||
|
s.binary_cron,
|
||||||
|
s.retention_cron,
|
||||||
|
s.connection_test_interval_minutes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pub fn start(state: AppState, mut shutdown: watch::Receiver<bool>) -> tokio::task::JoinHandle<()> {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(StdDuration::from_secs(1));
|
||||||
|
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
let running = Arc::new(Mutex::new(HashSet::<String>::new()));
|
||||||
|
let mut tasks = tokio::task::JoinSet::new();
|
||||||
|
let mut schedules: HashMap<String, (Cron, DateTime<Utc>)> = HashMap::new();
|
||||||
|
let mut last_signature = String::new();
|
||||||
|
let mut next_probe = None;
|
||||||
|
let mut next_logs = Utc::now() + Duration::days(1);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
changed=shutdown.changed()=>{if changed.is_err()||*shutdown.borrow(){break;}},
|
||||||
|
Some(_)=tasks.join_next(),if !tasks.is_empty()=>{},
|
||||||
|
_=tick.tick()=>{
|
||||||
|
let now=Utc::now();let settings=match state.db.get_settings(){Ok(s)=>s,Err(error)=>{tracing::error!("Scheduler settings unavailable: {error}");continue;}};
|
||||||
|
let current_signature=signature(&settings);
|
||||||
|
if current_signature!=last_signature{
|
||||||
|
schedules.clear();
|
||||||
|
for (key,enabled,expression) in [("auto_export",settings.enable_auto_export,settings.export_cron.as_str()),("auto_binary",true,settings.binary_cron.as_str()),("retention",true,settings.retention_cron.as_str())]{
|
||||||
|
if enabled&&!expression.trim().is_empty(){match Cron::parse(expression,&state.config.timezone){Ok(cron)=>{if let Some(next)=cron.next(now,1).first(){schedules.insert(key.to_owned(),(cron.clone(),next.with_timezone(&Utc)));}},Err(error)=>{let _=state.db.log(&format!("Scheduler skipped invalid {key} cron: {error}"));}}}
|
||||||
|
}
|
||||||
|
next_probe=(settings.connection_test_interval_minutes>0).then(||now+Duration::minutes(settings.connection_test_interval_minutes));last_signature=current_signature;
|
||||||
|
}
|
||||||
|
let mut due=Vec::new();
|
||||||
|
for (key,(cron,next)) in &mut schedules{if now>=*next{if now.signed_duration_since(*next)<=Duration::minutes(5){due.push(key.clone());}
|
||||||
|
if let Some(value)=cron.next(now,1).first(){*next=value.with_timezone(&Utc);}else{*next=now+Duration::days(366*400);}}}
|
||||||
|
if next_probe.is_some_and(|time|now>=time){due.push("connection_probe".into());next_probe=Some(now+Duration::minutes(settings.connection_test_interval_minutes.max(1)));}
|
||||||
|
if now>=next_logs{due.push("log_cleanup".into());next_logs=now+Duration::days(1);}
|
||||||
|
for key in due{let inserted=running.lock().map(|mut set|set.insert(key.clone())).unwrap_or(false);if !inserted{continue;}let state=state.clone();let guard=RunningGuard{key:key.clone(),set:running.clone()};tasks.spawn(async move{let _guard=guard;if let Err(error)=job(&state,&key).await{let _=state.db.log(&format!("Scheduled {key} failed: {error:#}"));}});}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Allow active jobs to finish; individual network operations have hard deadlines.
|
||||||
|
while tasks.join_next().await.is_some() {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn validates_cron_and_ranges() {
|
||||||
|
assert!(validate_cron("15 2 * * *", "Europe/Warsaw").is_ok());
|
||||||
|
for expression in [
|
||||||
|
"bad cron",
|
||||||
|
"61 * * * *",
|
||||||
|
"*/0 * * * *",
|
||||||
|
"0 0 * * 7",
|
||||||
|
"0 0 * 13 *",
|
||||||
|
] {
|
||||||
|
assert!(validate_cron(expression, "Europe/Warsaw").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn monday_zero_matches_apscheduler() {
|
||||||
|
let cron = Cron::parse("0 2 * * 0", "UTC").unwrap();
|
||||||
|
assert!(cron.matches(Utc.with_ymd_and_hms(2026, 10, 26, 2, 0, 0).unwrap()));
|
||||||
|
assert!(!cron.matches(Utc.with_ymd_and_hms(2026, 10, 25, 2, 0, 0).unwrap()));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn timezone_and_dst_preview() {
|
||||||
|
let cron = Cron::parse("30 2 * * *", "Europe/Warsaw").unwrap();
|
||||||
|
let start = Utc.with_ymd_and_hms(2026, 3, 28, 12, 0, 0).unwrap();
|
||||||
|
let runs = cron.next(start, 2);
|
||||||
|
assert_eq!(runs.len(), 2);
|
||||||
|
assert_eq!(runs[0].day(), 30);
|
||||||
|
assert_eq!(runs[0].hour(), 2);
|
||||||
|
assert!(runs[0] < runs[1]);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn named_weekdays_and_last_day() {
|
||||||
|
let cron = Cron::parse("0 0 last * *", "UTC").unwrap();
|
||||||
|
assert!(cron.matches(Utc.with_ymd_and_hms(2028, 2, 29, 0, 0, 0).unwrap()));
|
||||||
|
assert!(!cron.matches(Utc.with_ymd_and_hms(2028, 2, 28, 0, 0, 0).unwrap()));
|
||||||
|
assert!(validate_cron("*/15 1-6/2 * jan-mar mon-fri", "UTC").is_ok());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn rare_calendar_schedule_is_not_lost() {
|
||||||
|
let cron = Cron::parse("0 0 29 feb mon", "UTC").unwrap();
|
||||||
|
let runs = cron.next(Utc.with_ymd_and_hms(2026, 10, 27, 0, 0, 0).unwrap(), 1);
|
||||||
|
assert_eq!(runs.len(), 1);
|
||||||
|
assert_eq!(runs[0].year(), 2044);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- Current schema for a new installation. Never applied to an existing database.
|
||||||
|
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);
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
preferred_language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
preferred_font TEXT NOT NULL DEFAULT 'default'
|
||||||
|
);
|
||||||
|
CREATE TABLE routers (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
device_type TEXT NOT NULL DEFAULT 'routeros',
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 22,
|
||||||
|
ssh_user TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
use_global_switchos_credentials INTEGER CHECK (use_global_switchos_credentials IS NULL OR use_global_switchos_credentials IN (0,1)),
|
||||||
|
ssh_key TEXT,
|
||||||
|
ssh_password TEXT,
|
||||||
|
last_connection_status INTEGER,
|
||||||
|
last_connection_tested_at TEXT,
|
||||||
|
last_connection_error TEXT,
|
||||||
|
last_connection_hostname TEXT,
|
||||||
|
last_connection_model TEXT,
|
||||||
|
last_connection_version TEXT,
|
||||||
|
last_connection_uptime TEXT,
|
||||||
|
last_connection_transport TEXT,
|
||||||
|
last_connection_server TEXT,
|
||||||
|
last_connection_auth_mode TEXT,
|
||||||
|
last_connection_http_status TEXT,
|
||||||
|
last_connection_backup_available INTEGER,
|
||||||
|
disable_all_backups INTEGER NOT NULL DEFAULT 0,
|
||||||
|
disable_export_backups INTEGER NOT NULL DEFAULT 0,
|
||||||
|
disable_binary_backups INTEGER NOT NULL DEFAULT 0,
|
||||||
|
disable_ping INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE backups (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
router_id INTEGER NOT NULL REFERENCES routers(id) ON DELETE CASCADE,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
backup_type TEXT NOT NULL DEFAULT 'export',
|
||||||
|
checksum TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE operation_logs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE global_settings (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
backup_retention_days INTEGER DEFAULT 7,
|
||||||
|
log_retention_days INTEGER DEFAULT 7,
|
||||||
|
export_cron TEXT DEFAULT '',
|
||||||
|
binary_cron TEXT DEFAULT '',
|
||||||
|
retention_cron TEXT DEFAULT '',
|
||||||
|
enable_auto_export INTEGER DEFAULT 0,
|
||||||
|
connection_test_interval_minutes INTEGER DEFAULT 0,
|
||||||
|
global_ssh_key TEXT,
|
||||||
|
default_switchos_username TEXT,
|
||||||
|
default_switchos_password TEXT,
|
||||||
|
pushover_token TEXT,
|
||||||
|
pushover_userkey TEXT,
|
||||||
|
notify_failures_only INTEGER DEFAULT 1,
|
||||||
|
smtp_host TEXT,
|
||||||
|
smtp_port INTEGER DEFAULT 587,
|
||||||
|
smtp_login TEXT,
|
||||||
|
smtp_password TEXT,
|
||||||
|
smtp_notifications_enabled INTEGER DEFAULT 0,
|
||||||
|
recipient_email TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_routers_owner_id ON routers(owner_id);
|
||||||
|
CREATE INDEX ix_backups_router_id ON backups(router_id);
|
||||||
|
CREATE INDEX ix_backups_created_at ON backups(created_at);
|
||||||
|
CREATE INDEX ix_operation_logs_timestamp ON operation_logs(timestamp);
|
||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
//! 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};
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::Sha256;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
|
use crate::{config::Config, models::User};
|
||||||
|
|
||||||
|
pub fn hash_password(password: &str) -> Result<String> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map(|hash| hash.to_string())
|
||||||
|
.map_err(|_| anyhow::anyhow!("Password hashing failed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_password(password: &str, encoded: &str) -> bool {
|
||||||
|
if encoded.starts_with("$argon2id$") {
|
||||||
|
let Ok(hash) = PasswordHash::new(encoded) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for (key, limit) in [("m", 262_144), ("t", 10), ("p", 16)] {
|
||||||
|
if hash
|
||||||
|
.params
|
||||||
|
.get_decimal(key)
|
||||||
|
.is_none_or(|value| value > limit || value == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &hash)
|
||||||
|
.is_ok();
|
||||||
|
}
|
||||||
|
if encoded.starts_with("$pbkdf2-sha256$") {
|
||||||
|
return verify_passlib(password, encoded);
|
||||||
|
}
|
||||||
|
if encoded.starts_with("pbkdf2:sha256:") {
|
||||||
|
return verify_werkzeug(password, encoded);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_passlib(password: &str, encoded: &str) -> bool {
|
||||||
|
let parts: Vec<&str> = encoded.split('$').collect();
|
||||||
|
if parts.len() != 5 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(rounds) = parts[2].parse::<u32>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !(1..=2_000_000).contains(&rounds) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let decode = |s: &str| STANDARD_NO_PAD.decode(s.replace('.', "+").trim_end_matches('='));
|
||||||
|
let (Ok(salt), Ok(expected)) = (decode(parts[3]), decode(parts[4])) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if expected.len() != 32 || salt.is_empty() || salt.len() > 1024 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut actual = [0_u8; 32];
|
||||||
|
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), &salt, rounds, &mut actual);
|
||||||
|
actual.ct_eq(expected.as_slice()).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_werkzeug(password: &str, encoded: &str) -> bool {
|
||||||
|
let parts: Vec<&str> = encoded.split('$').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(rounds) = parts[0]
|
||||||
|
.strip_prefix("pbkdf2:sha256:")
|
||||||
|
.and_then(|n| n.parse::<u32>().ok())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !(1..=2_000_000).contains(&rounds) || parts[2].len() != 64 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut expected = [0_u8; 32];
|
||||||
|
for (index, value) in expected.iter_mut().enumerate() {
|
||||||
|
let Some(pair) = parts[2].get(index * 2..index * 2 + 2) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(byte) = u8::from_str_radix(pair, 16) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
*value = byte;
|
||||||
|
}
|
||||||
|
let mut actual = [0_u8; 32];
|
||||||
|
pbkdf2::pbkdf2_hmac::<Sha256>(
|
||||||
|
password.as_bytes(),
|
||||||
|
parts[1].as_bytes(),
|
||||||
|
rounds,
|
||||||
|
&mut actual,
|
||||||
|
);
|
||||||
|
actual.ct_eq(&expected).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_token(config: &Config, user: &User) -> Result<String> {
|
||||||
|
let now = chrono::Utc::now().timestamp() as u64;
|
||||||
|
let expires = config
|
||||||
|
.access_token_expire_minutes
|
||||||
|
.checked_mul(60)
|
||||||
|
.and_then(|seconds| now.checked_add(seconds))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Token lifetime overflow"))?;
|
||||||
|
jsonwebtoken::encode(
|
||||||
|
&Header::new(Algorithm::HS256),
|
||||||
|
&Claims {
|
||||||
|
sub: user.username.clone(),
|
||||||
|
exp: expires,
|
||||||
|
},
|
||||||
|
&EncodingKey::from_secret(config.secret_key.as_bytes()),
|
||||||
|
)
|
||||||
|
.context("Could not create token")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_token(config: &Config, token: &str) -> Result<String> {
|
||||||
|
if token.len() > 16_384 {
|
||||||
|
bail!("Invalid token");
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
&DecodingKey::from_secret(config.secret_key.as_bytes()),
|
||||||
|
&validation,
|
||||||
|
)
|
||||||
|
.context("Invalid or expired token")?;
|
||||||
|
if result.claims.sub.is_empty() || result.claims.sub.chars().count() > 120 {
|
||||||
|
bail!("Invalid token subject");
|
||||||
|
}
|
||||||
|
Ok(result.claims.sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
fn config() -> (tempfile::TempDir, Config) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = Config::from_values(&HashMap::from([(
|
||||||
|
"DATA_DIR".into(),
|
||||||
|
dir.path().display().to_string(),
|
||||||
|
)]))
|
||||||
|
.unwrap();
|
||||||
|
(dir, config)
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn new_passwords_use_argon2id() {
|
||||||
|
let hash = hash_password("Correct-password-42").unwrap();
|
||||||
|
assert!(hash.starts_with("$argon2id$"));
|
||||||
|
assert!(verify_password("Correct-password-42", &hash));
|
||||||
|
assert!(!verify_password("wrong-password", &hash));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn malformed_hashes_fail_closed() {
|
||||||
|
for hash in [
|
||||||
|
"",
|
||||||
|
"plaintext",
|
||||||
|
"$argon2id$bad",
|
||||||
|
"$pbkdf2-sha256$0$x$x",
|
||||||
|
"$pbkdf2-sha256$999999999$x$x",
|
||||||
|
"pbkdf2:sha256:1$a$öö",
|
||||||
|
] {
|
||||||
|
assert!(!verify_password("secret", hash));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn verifies_python_passlib_fixture_without_reset() {
|
||||||
|
let hash = "$pbkdf2-sha256$29000$bWlrcm90aWstZml4dHVyZS1zYWx0$U42r8QMNijFErfH30hM61hCC21kPwzJVPqTeKTBa1IY";
|
||||||
|
assert!(verify_password("zażółć-gęślą", hash));
|
||||||
|
assert!(!verify_password("wrong", hash));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn verifies_legacy_werkzeug_fixture() {
|
||||||
|
let hash = "pbkdf2:sha256:260000$legacy-salt$f9d48932701d66074e54aa6092097a7211cad564803d599f232ff1ec28ec4475";
|
||||||
|
assert!(verify_password("legacy-password", hash));
|
||||||
|
assert!(!verify_password("wrong", hash));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn jwt_has_original_username_subject() {
|
||||||
|
let (_dir, config) = config();
|
||||||
|
let user = User {
|
||||||
|
id: 4,
|
||||||
|
username: "operator".into(),
|
||||||
|
password_hash: "".into(),
|
||||||
|
created_at: "".into(),
|
||||||
|
preferred_language: "pl".into(),
|
||||||
|
preferred_font: "default".into(),
|
||||||
|
};
|
||||||
|
let token = create_token(&config, &user).unwrap();
|
||||||
|
assert_eq!(verify_token(&config, &token).unwrap(), "operator");
|
||||||
|
let mut wrong = config.clone();
|
||||||
|
wrong.secret_key.push('x');
|
||||||
|
assert!(verify_token(&wrong, &token).is_err());
|
||||||
|
let mut unicode = user;
|
||||||
|
unicode.username = "ż".repeat(120);
|
||||||
|
let token = create_token(&config, &unicode).unwrap();
|
||||||
|
assert_eq!(verify_token(&config, &token).unwrap(), unicode.username);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn expired_and_wrong_algorithm_tokens_are_rejected() {
|
||||||
|
let (_dir, config) = config();
|
||||||
|
for (algorithm, expiry) in [(Algorithm::HS256, 1), (Algorithm::HS384, u64::MAX / 2)] {
|
||||||
|
let token = jsonwebtoken::encode(
|
||||||
|
&Header::new(algorithm),
|
||||||
|
&Claims {
|
||||||
|
sub: "admin".into(),
|
||||||
|
exp: expiry,
|
||||||
|
},
|
||||||
|
&EncodingKey::from_secret(config.secret_key.as_bytes()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(verify_token(&config, &token).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signed_json(config: &Config, claims: serde_json::Value) -> String {
|
||||||
|
jsonwebtoken::encode(
|
||||||
|
&Header::new(Algorithm::HS256),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(config.secret_key.as_bytes()),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"),
|
||||||
|
serde_json::json!(null),
|
||||||
|
serde_json::json!(true),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!({"seconds": 4102444800_u64}),
|
||||||
|
serde_json::json!(-1),
|
||||||
|
serde_json::json!(4102444800.5_f64),
|
||||||
|
] {
|
||||||
|
let token = signed_json(
|
||||||
|
&config,
|
||||||
|
serde_json::json!({"sub": "admin", "exp": expiration}),
|
||||||
|
);
|
||||||
|
assert!(verify_token(&config, &token).is_err(), "exp={expiration}");
|
||||||
|
}
|
||||||
|
let missing = signed_json(&config, serde_json::json!({"sub": "admin"}));
|
||||||
|
assert!(verify_token(&config, &missing).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_before_is_optional_but_must_be_valid_and_reached() {
|
||||||
|
let (_dir, config) = config();
|
||||||
|
let now = chrono::Utc::now().timestamp() as u64;
|
||||||
|
for not_before in [
|
||||||
|
serde_json::json!("yesterday"),
|
||||||
|
serde_json::json!("946684800"),
|
||||||
|
serde_json::json!(null),
|
||||||
|
serde_json::json!([]),
|
||||||
|
serde_json::json!(true),
|
||||||
|
serde_json::json!(now + 3_600),
|
||||||
|
] {
|
||||||
|
let token = signed_json(
|
||||||
|
&config,
|
||||||
|
serde_json::json!({"sub": "admin", "exp": now + 7_200, "nbf": not_before}),
|
||||||
|
);
|
||||||
|
assert!(verify_token(&config, &token).is_err(), "nbf={not_before}");
|
||||||
|
}
|
||||||
|
for claims in [
|
||||||
|
serde_json::json!({"sub": "admin", "exp": now + 7_200}),
|
||||||
|
serde_json::json!({"sub": "admin", "exp": now + 7_200, "nbf": now - 60}),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
verify_token(&config, &signed_json(&config, claims)).unwrap(),
|
||||||
|
"admin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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");
|
||||||
|
assert!(verify_token(&config, &tampered).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build current sources, then start one native process. Rust reads .env as data.
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
project_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$project_dir"
|
||||||
|
# Kept for compatibility: native starts always build now.
|
||||||
|
if [[ "${1:-}" == "--build" ]]; then shift; fi
|
||||||
|
if [[ "${1:-}" == "--docker" ]]; then
|
||||||
|
shift
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "Docker is required for --docker. Install Docker Engine with Compose, or run ./start.sh natively." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec docker compose -f compose.yaml up -d --build "$@"
|
||||||
|
fi
|
||||||
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
|
echo "Install Rust via https://rustup.rs and the build dependencies listed in README.md, or use ./start.sh --docker." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Use the same explicit directory for Cargo and the executable. Relative custom
|
||||||
|
# paths are resolved from the project, including when invoked from elsewhere.
|
||||||
|
build_dir="${CARGO_TARGET_DIR:-$project_dir/target}"
|
||||||
|
if [[ "$build_dir" != /* ]]; then build_dir="$project_dir/$build_dir"; fi
|
||||||
|
CARGO_TARGET_DIR="$build_dir" cargo build --release --locked
|
||||||
|
binary="$build_dir/release/mikrotik-backup"
|
||||||
|
if [[ ! -x "$binary" ]]; then
|
||||||
|
echo "Cargo did not produce the native executable at $binary. Check your Cargo target configuration." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ $# -eq 0 ]]; then set -- serve; fi
|
||||||
|
exec "$binary" "$@"
|
||||||
File diff suppressed because it is too large.
Load diff
Vendored
+162
@@ -0,0 +1,162 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"left": "",
|
||||||
|
"right": "",
|
||||||
|
"unified": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "",
|
||||||
|
"right": "a\nb",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -0,0 +1,2 @@\n+a\n+b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "a\nb",
|
||||||
|
"right": "",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,2 +0,0 @@\n-a\n-b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "a\nb\nc",
|
||||||
|
"right": "a\nx\nc",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,3 +1,3 @@\n a\n-b\n+x\n c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "a\na\nb\na",
|
||||||
|
"right": "a\nb\na\na",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,4 +1,4 @@\n-a\n a\n b\n a\n+a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "\n",
|
||||||
|
"right": "x\n",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1 +1 @@\n-\n+x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "a\n\nc",
|
||||||
|
"right": "a\nx\nc",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,3 +1,3 @@\n a\n-\n+x\n c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "x\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\na\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx",
|
||||||
|
"right": "x\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nb\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx\nx",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -248,254 +248,254 @@\n x\n x\n x\n-a\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n-x\n+b\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x\n+x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "alpha\n\nbeta\nbeta\n/ip address\nbeta\ndelta\nalpha\nbeta",
|
||||||
|
"right": "alpha\n\nbeta\nbeta\n/ip address\nbeta\nalpha\nbeta",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -4,6 +4,5 @@\n beta\n /ip address\n beta\n-delta\n alpha\n beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "/ip address\n\nalpha\nalpha\nadd address=10.0.0.1\ndelta\nbeta\n\ndelta\nadd address=10.0.0.1\nadd address=10.0.0.1\nbeta\ngamma\ndelta\n\nbeta\nbeta\ndelta\n\ngamma\ndelta\nbeta\ngamma\ngamma\ngamma\nalpha",
|
||||||
|
"right": "/ip address\n\nalpha\nalpha\nalpha\nadd address=10.0.0.1\ndelta\nbeta\n\ndelta\nadd address=10.0.0.1\nadd address=10.0.0.1\nbeta\ngamma\ndelta\n\nbeta\nbeta\ndelta\n\ngamma\ndelta\nbeta\ngamma\ngamma\ngamma\nalpha",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,5 +1,6 @@\n /ip address\n \n+alpha\n alpha\n alpha\n add address=10.0.0.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "gamma\n/ip address\nadd address=10.0.0.1\n/ip address\nadd address=10.0.0.1\nbeta\ngamma\nalpha\ngamma\nbeta\n/ip address\n\ndelta\ngamma\n/ip address\ngamma\nadd address=10.0.0.1",
|
||||||
|
"right": "gamma\n/ip address\nadd address=10.0.0.1\n/ip address\nadd address=10.0.0.1\nbeta\ngamma\nalpha\ngamma\nbeta\nbeta\n/ip address\n\ngamma\n/ip address\ngamma\nadd address=10.0.0.1",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -8,9 +8,9 @@\n alpha\n gamma\n beta\n+beta\n /ip address\n \n-delta\n gamma\n /ip address\n gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "add address=10.0.0.1\ngamma\n/ip address\nbeta\nalpha\nbeta\ndelta\n/ip address\n\ngamma\ngamma\n\nbeta\n/ip address\n\nalpha\n/ip address\nadd address=10.0.0.1\n/ip address\n/ip address\nalpha\nbeta\nalpha\ndelta\nbeta\n\n/ip address",
|
||||||
|
"right": "add address=10.0.0.1\ngamma\n/ip address\nbeta\nalpha\nbeta\ndelta\n\n/ip address\ngamma\ngamma\n\nbeta\n/ip address\n\nalpha\n/ip address\nadd address=10.0.0.1\n/ip address\nalpha\nbeta\nalpha\ndelta\nbeta\n\n/ip address",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -5,8 +5,8 @@\n alpha\n beta\n delta\n+\n /ip address\n-\n gamma\n gamma\n \n@@ -17,7 +17,6 @@\n /ip address\n add address=10.0.0.1\n /ip address\n-/ip address\n alpha\n beta\n alpha"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "\n/ip address\n/ip address\ngamma\n",
|
||||||
|
"right": "alpha\n\n/ip address\nbeta\n\n",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,4 +1,5 @@\n+alpha\n \n /ip address\n-/ip address\n-gamma\n+beta\n+"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "beta\nbeta\nalpha\n/ip address\nbeta\nbeta\nbeta\n/ip address\n/ip address\n\ndelta\n\nadd address=10.0.0.1\nadd address=10.0.0.1\nadd address=10.0.0.1\ngamma\nbeta\nadd address=10.0.0.1\n/ip address\nadd address=10.0.0.1\ndelta",
|
||||||
|
"right": "beta\nalpha\n/ip address\nbeta\nbeta\nnew\nbeta\n/ip address\n/ip address\ndelta\nadd address=10.0.0.1\nalpha\nadd address=10.0.0.1\nadd address=10.0.0.1\nbeta\ngamma\nbeta\nadd address=10.0.0.1\n/ip address\nadd address=10.0.0.1\ndelta",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,18 +1,18 @@\n-beta\n beta\n alpha\n /ip address\n beta\n beta\n+new\n beta\n /ip address\n /ip address\n-\n delta\n-\n+add address=10.0.0.1\n+alpha\n add address=10.0.0.1\n add address=10.0.0.1\n-add address=10.0.0.1\n+beta\n gamma\n beta\n add address=10.0.0.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "alpha\nbeta\ndelta\n\ndelta\n/ip address\n/ip address\nalpha\nbeta\nadd address=10.0.0.1\nalpha\n/ip address\nadd address=10.0.0.1\nadd address=10.0.0.1\n\ngamma\ndelta\nalpha\nalpha\ngamma\nbeta\n\n/ip address",
|
||||||
|
"right": "alpha\nbeta\ndelta\n\ndelta\n\nbeta\n/ip address\nalpha\nbeta\nbeta\nadd address=10.0.0.1\n/ip address\nadd address=10.0.0.1\nadd address=10.0.0.1\n\ngamma\ndelta\nalpha\nalpha\ngamma\nbeta\n\n/ip address",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -3,12 +3,13 @@\n delta\n \n delta\n-/ip address\n+\n+beta\n /ip address\n alpha\n beta\n+beta\n add address=10.0.0.1\n-alpha\n /ip address\n add address=10.0.0.1\n add address=10.0.0.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "beta\nbeta\nbeta\ndelta\nbeta\nbeta\n\nalpha\n\ndelta\nadd address=10.0.0.1\ndelta\nadd address=10.0.0.1\ngamma\nalpha\nalpha\nalpha\nadd address=10.0.0.1\ngamma\nbeta\n\nalpha\nadd address=10.0.0.1\ngamma\nalpha\nalpha\ngamma\ndelta\n\nadd address=10.0.0.1\nbeta\n/ip address\nbeta\n\nbeta\ngamma\ngamma\n\nalpha",
|
||||||
|
"right": "beta\nbeta\nbeta\ndelta\nbeta\nbeta\n\nalpha\n\ndelta\nadd address=10.0.0.1\ngamma\nalpha\nalpha\nalpha\nadd address=10.0.0.1\ngamma\nbeta\n\nalpha\nadd address=10.0.0.1\ngamma\nalpha\nalpha\ngamma\nadd address=10.0.0.1\nbeta\n/ip address\nbeta\nalpha\n\nnew\nbeta\ngamma\ngamma",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -7,8 +7,6 @@\n \n alpha\n \n-delta\n-add address=10.0.0.1\n delta\n add address=10.0.0.1\n gamma\n@@ -25,15 +23,13 @@\n alpha\n alpha\n gamma\n-delta\n-\n add address=10.0.0.1\n beta\n /ip address\n beta\n+alpha\n \n+new\n beta\n gamma\n gamma\n-\n-alpha"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "add address=10.0.0.1\nbeta\nalpha\n/ip address\nbeta\n/ip address\ngamma\nadd address=10.0.0.1\nbeta\n/ip address\nalpha\ngamma\nalpha\ndelta\nbeta\ndelta\n/ip address\n\ngamma\ngamma",
|
||||||
|
"right": "beta\n/ip address\nbeta\n/ip address\ngamma\nadd address=10.0.0.1\nbeta\nalpha\ngamma\nalpha\nbeta\n\ndelta\n/ip address\n\nnew\nalpha\ngamma\ngamma",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,20 +1,19 @@\n-add address=10.0.0.1\n beta\n-alpha\n /ip address\n beta\n /ip address\n gamma\n add address=10.0.0.1\n beta\n-/ip address\n alpha\n gamma\n alpha\n-delta\n beta\n+\n delta\n /ip address\n \n+new\n+alpha\n gamma\n gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "beta\nalpha\nbeta\ngamma\ngamma\nalpha\nadd address=10.0.0.1\ngamma\ndelta",
|
||||||
|
"right": "new\nbeta\nalpha\nbeta\ngamma\ngamma\nalpha\nadd address=10.0.0.1\ngamma\ndelta",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,3 +1,4 @@\n+new\n beta\n alpha\n beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "gamma\nalpha",
|
||||||
|
"right": "gamma\nnew",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,2 +1,2 @@\n gamma\n-alpha\n+new"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "/ip address\ndelta\nbeta\ngamma\ndelta\nadd address=10.0.0.1\ndelta\n/ip address\nadd address=10.0.0.1\nbeta\n\nadd address=10.0.0.1\ndelta\n/ip address\nadd address=10.0.0.1\nadd address=10.0.0.1\ndelta\ngamma",
|
||||||
|
"right": "/ip address\n\ndelta\n\nbeta\ngamma\ndelta\ndelta\nbeta\n/ip address\nalpha\nadd address=10.0.0.1\nbeta\n\nadd address=10.0.0.1\ndelta\n/ip address\nadd address=10.0.0.1\nalpha\ngamma",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,11 +1,14 @@\n /ip address\n+\n delta\n+\n beta\n gamma\n delta\n-add address=10.0.0.1\n delta\n+beta\n /ip address\n+alpha\n add address=10.0.0.1\n beta\n \n@@ -13,6 +16,5 @@\n delta\n /ip address\n add address=10.0.0.1\n-add address=10.0.0.1\n-delta\n+alpha\n gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "beta\nalpha\nbeta\ndelta\n\nalpha\nalpha\nadd address=10.0.0.1\nalpha\ndelta\nadd address=10.0.0.1\nadd address=10.0.0.1\nadd address=10.0.0.1\n/ip address\ndelta\nadd address=10.0.0.1\nbeta\n/ip address\nalpha\nadd address=10.0.0.1\nbeta",
|
||||||
|
"right": "beta\nalpha\nalpha\nbeta\ndelta\n\nalpha\nadd address=10.0.0.1\nalpha\ndelta\nadd address=10.0.0.1\nadd address=10.0.0.1\nadd address=10.0.0.1\nnew\n/ip address\ndelta\nadd address=10.0.0.1\n/ip address\nalpha\nadd address=10.0.0.1\nbeta",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,9 +1,9 @@\n beta\n+alpha\n alpha\n beta\n delta\n \n-alpha\n alpha\n add address=10.0.0.1\n alpha\n@@ -11,10 +11,10 @@\n add address=10.0.0.1\n add address=10.0.0.1\n add address=10.0.0.1\n+new\n /ip address\n delta\n add address=10.0.0.1\n-beta\n /ip address\n alpha\n add address=10.0.0.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "/ip address\ngamma\n/ip address\n/ip address\ndelta\ndelta\n/ip address\nbeta\n\n\nadd address=10.0.0.1\nbeta\nalpha\ngamma\nbeta\ngamma\n/ip address\nbeta\n/ip address\ndelta\ndelta\ndelta\n\nalpha\n/ip address\ndelta\ngamma\ndelta",
|
||||||
|
"right": "/ip address\nbeta\ngamma\nbeta\n/ip address\n/ip address\ndelta\ndelta\n/ip address\nbeta\n\n\nadd address=10.0.0.1\nbeta\nalpha\ngamma\nbeta\n/ip address\nbeta\ndelta\ndelta\ndelta\n\nnew\nalpha\ndelta\ngamma\ndelta",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,5 +1,7 @@\n /ip address\n+beta\n gamma\n+beta\n /ip address\n /ip address\n delta\n@@ -13,16 +15,14 @@\n alpha\n gamma\n beta\n-gamma\n /ip address\n beta\n-/ip address\n delta\n delta\n delta\n \n+new\n alpha\n-/ip address\n delta\n gamma\n delta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "add address=10.0.0.1\nalpha\n\nalpha\n\nadd address=10.0.0.1\n\ngamma\n\n\n/ip address\nbeta\nadd address=10.0.0.1\n/ip address\nadd address=10.0.0.1\n/ip address",
|
||||||
|
"right": "add address=10.0.0.1\nalpha\n\nalpha\n\nadd address=10.0.0.1\n\ngamma\n\n\n/ip address\nbeta\nbeta\nadd address=10.0.0.1\nalpha\n/ip address\nadd address=10.0.0.1\n/ip address\n",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -10,7 +10,9 @@\n \n /ip address\n beta\n+beta\n add address=10.0.0.1\n+alpha\n /ip address\n add address=10.0.0.1\n /ip address"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "alpha\nbeta\nalpha\nalpha\ndelta\nadd address=10.0.0.1\n\nalpha\ngamma\nalpha\n\n/ip address\n/ip address\ngamma\nalpha\n/ip address\ngamma\n/ip address\nbeta\ngamma\nbeta",
|
||||||
|
"right": "alpha\nbeta\nalpha\nalpha\ndelta\nadd address=10.0.0.1\n\nalpha\ngamma\nalpha\n\n/ip address\n/ip address\ngamma\nbeta\nalpha\n/ip address\ngamma\n/ip address\nbeta\ngamma",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -12,10 +12,10 @@\n /ip address\n /ip address\n gamma\n+beta\n alpha\n /ip address\n gamma\n /ip address\n beta\n gamma\n-beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "delta\nbeta\nadd address=10.0.0.1\nadd address=10.0.0.1\n\nbeta\ndelta\n\nbeta\ngamma",
|
||||||
|
"right": "beta\nadd address=10.0.0.1\n\n\nbeta\ndelta\n\ngamma",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,10 +1,8 @@\n-delta\n beta\n add address=10.0.0.1\n-add address=10.0.0.1\n+\n \n beta\n delta\n \n-beta\n gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "\n\nadd address=10.0.0.1\ndelta\nbeta\ngamma\ngamma\nbeta\ndelta\ndelta\nalpha\ngamma\nalpha\ndelta",
|
||||||
|
"right": "beta\nalpha\n\n\nadd address=10.0.0.1\ndelta\ngamma\ngamma\nbeta\ndelta\ndelta\nalpha\ngamma\nalpha\ndelta",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,8 +1,9 @@\n+beta\n+alpha\n \n \n add address=10.0.0.1\n delta\n-beta\n gamma\n gamma\n beta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "",
|
||||||
|
"right": "alpha",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -0,0 +1 @@\n+alpha"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "/ip address\n/ip address\n\nbeta\n/ip address\nalpha",
|
||||||
|
"right": "/ip address\n\n\nbeta\nalpha",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,6 +1,5 @@\n-/ip address\n /ip address\n \n+\n beta\n-/ip address\n alpha"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "\n/ip address\n\ngamma\nalpha\nalpha\n/ip address\n/ip address\n\n/ip address\nbeta\n\nadd address=10.0.0.1\nadd address=10.0.0.1\n\n\ndelta\nalpha\ngamma\n/ip address\nbeta\nbeta\nbeta\nalpha\n/ip address\nalpha\n\nalpha\nadd address=10.0.0.1\ndelta\ngamma\ndelta\ndelta\nadd address=10.0.0.1\nadd address=10.0.0.1\nadd address=10.0.0.1\nbeta\nbeta",
|
||||||
|
"right": "\n/ip address\n\ngamma\nalpha\nalpha\n/ip address\n/ip address\n\n/ip address\nbeta\n\nadd address=10.0.0.1\nadd address=10.0.0.1\n\ndelta\nalpha\ngamma\n/ip address\nbeta\nbeta\nbeta\nalpha\n/ip address\nalpha\n\nalpha\nadd address=10.0.0.1\ndelta\ngamma\ndelta\ndelta\n\nadd address=10.0.0.1\nadd address=10.0.0.1\nadd address=10.0.0.1\nbeta\nbeta",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -12,7 +12,6 @@\n \n add address=10.0.0.1\n add address=10.0.0.1\n-\n \n delta\n alpha\n@@ -31,6 +30,7 @@\n gamma\n delta\n delta\n+\n add address=10.0.0.1\n add address=10.0.0.1\n add address=10.0.0.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "add address=10.0.0.1\nadd address=10.0.0.1\n/ip address\n\nadd address=10.0.0.1\nbeta\nbeta\ndelta\nadd address=10.0.0.1\ngamma\nadd address=10.0.0.1",
|
||||||
|
"right": "/ip address\nnew\n\nadd address=10.0.0.1\nbeta\ndelta\nadd address=10.0.0.1",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,11 +1,7 @@\n-add address=10.0.0.1\n-add address=10.0.0.1\n /ip address\n+new\n \n add address=10.0.0.1\n beta\n-beta\n delta\n add address=10.0.0.1\n-gamma\n-add address=10.0.0.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "gamma",
|
||||||
|
"right": "",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1 +0,0 @@\n-gamma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"left": "add address=10.0.0.1\nadd address=10.0.0.1\n\nalpha\ngamma\nbeta\nalpha\nalpha\ndelta\nbeta\nbeta\n/ip address\nbeta\nalpha\n/ip address\nbeta\nbeta\nbeta\n/ip address\n/ip address\n\ngamma\nbeta\nbeta\ngamma\ngamma\ngamma\ndelta\nadd address=10.0.0.1\n\n/ip address",
|
||||||
|
"right": "add address=10.0.0.1\nadd address=10.0.0.1\nalpha\ngamma\nbeta\nalpha\ndelta\nbeta\nbeta\n/ip address\nbeta\nalpha\n/ip address\nbeta\nbeta\nbeta\n/ip address\n\ngamma\nbeta\ngamma\ngamma\ndelta\nadd address=10.0.0.1\n\n/ip address",
|
||||||
|
"unified": "--- left\n+++ right\n@@ -1,10 +1,8 @@\n add address=10.0.0.1\n add address=10.0.0.1\n-\n alpha\n gamma\n beta\n-alpha\n alpha\n delta\n beta\n@@ -17,12 +15,9 @@\n beta\n beta\n /ip address\n-/ip address\n \n gamma\n beta\n-beta\n-gamma\n gamma\n gamma\n delta"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
//! The explicit importer must preserve source paths and never start on help/error.
|
||||||
|
use std::{fs, path::PathBuf, process::Command};
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
_temporary: tempfile::TempDir,
|
||||||
|
root: PathBuf,
|
||||||
|
launcher: PathBuf,
|
||||||
|
arguments: PathBuf,
|
||||||
|
environment: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let temporary = tempfile::tempdir().unwrap();
|
||||||
|
let root = temporary.path().to_path_buf();
|
||||||
|
let project = root.join("new application");
|
||||||
|
fs::create_dir(&project).unwrap();
|
||||||
|
let launcher = project.join("import-python.sh");
|
||||||
|
fs::write(&launcher, include_str!("../import-python.sh")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
project.join("start.sh"),
|
||||||
|
"#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\0' \"$@\" > \"$IMPORT_TEST_ARGS\"\nprintf '%s\\0' \"${DATA_DIR-}\" \"${DATABASE_PATH-}\" > \"$IMPORT_TEST_ENV\"\nexit \"${IMPORT_TEST_STATUS:-0}\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Self {
|
||||||
|
_temporary: temporary,
|
||||||
|
launcher,
|
||||||
|
arguments: root.join("arguments"),
|
||||||
|
environment: root.join("environment"),
|
||||||
|
root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(&self) -> Command {
|
||||||
|
let mut command = Command::new("bash");
|
||||||
|
command
|
||||||
|
.arg(&self.launcher)
|
||||||
|
.current_dir(&self.root)
|
||||||
|
.env("IMPORT_TEST_ARGS", &self.arguments)
|
||||||
|
.env("IMPORT_TEST_ENV", &self.environment)
|
||||||
|
.env_remove("IMPORT_TEST_STATUS");
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arguments(&self) -> Vec<String> {
|
||||||
|
let mut bytes = fs::read(&self.arguments).unwrap();
|
||||||
|
assert_eq!(bytes.pop(), Some(0));
|
||||||
|
bytes
|
||||||
|
.split(|byte| *byte == 0)
|
||||||
|
.map(|part| String::from_utf8(part.to_vec()).unwrap())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_and_invalid_sources_do_not_build_or_import() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let help = fixture.command().arg("--help").output().unwrap();
|
||||||
|
assert!(help.status.success());
|
||||||
|
assert!(
|
||||||
|
String::from_utf8(help.stdout)
|
||||||
|
.unwrap()
|
||||||
|
.contains("SOURCE_DIRECTORY")
|
||||||
|
);
|
||||||
|
assert!(!fixture.arguments.exists());
|
||||||
|
|
||||||
|
for arguments in [vec![], vec![""], vec!["missing source"]] {
|
||||||
|
let result = fixture.command().args(arguments).output().unwrap();
|
||||||
|
assert_eq!(result.status.code(), Some(2));
|
||||||
|
assert!(!fixture.arguments.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_source_and_preserves_arguments_and_destination_settings() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let source_name = "old app; literal $(no-command)";
|
||||||
|
let source = fixture.root.join(source_name);
|
||||||
|
fs::create_dir(&source).unwrap();
|
||||||
|
let result = fixture
|
||||||
|
.command()
|
||||||
|
.args([
|
||||||
|
source_name,
|
||||||
|
"--database",
|
||||||
|
"docker-data/db/original data.db",
|
||||||
|
"--backup-root",
|
||||||
|
"/srv/original backups",
|
||||||
|
])
|
||||||
|
.env("DATA_DIR", "./new storage")
|
||||||
|
.env("DATABASE_PATH", "./new storage/custom.db")
|
||||||
|
.env("CDPATH", &fixture.root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.status.success(), "{:?}", result);
|
||||||
|
assert_eq!(
|
||||||
|
fixture.arguments(),
|
||||||
|
[
|
||||||
|
"import-python",
|
||||||
|
"--source",
|
||||||
|
source.canonicalize().unwrap().to_str().unwrap(),
|
||||||
|
"--database",
|
||||||
|
"docker-data/db/original data.db",
|
||||||
|
"--backup-root",
|
||||||
|
"/srv/original backups",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&fixture.environment).unwrap(),
|
||||||
|
b"./new storage\0./new storage/custom.db\0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forwards_import_failure_without_falling_back_to_serve() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fs::create_dir(fixture.root.join("source")).unwrap();
|
||||||
|
let result = fixture
|
||||||
|
.command()
|
||||||
|
.arg("source")
|
||||||
|
.env("IMPORT_TEST_STATUS", "23")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.status.code(), Some(23));
|
||||||
|
assert_eq!(fixture.arguments().len(), 3);
|
||||||
|
assert_eq!(fixture.arguments()[0], "import-python");
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! Published documentation must match live routes and remain local to this server.
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use mikrotik_backup::{AppState, api, config::Config, db::Db};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn fixture(prefix: &str) -> (tempfile::TempDir, Router) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let config = Config::from_values(&HashMap::from([
|
||||||
|
("DATA_DIR".into(), dir.path().display().to_string()),
|
||||||
|
("DEFAULT_ADMIN_PASSWORD".into(), "docs-test-password".into()),
|
||||||
|
("API_PREFIX".into(), prefix.into()),
|
||||||
|
("APP_NAME".into(), "MikroTik API contract test".into()),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
let db = Db::open(&config).unwrap();
|
||||||
|
(dir, api::router(AppState::new(config, db)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_references(value: &Value, document: &Value) {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => {
|
||||||
|
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
|
||||||
|
assert!(reference.starts_with("#/components/"));
|
||||||
|
assert!(
|
||||||
|
document.pointer(&reference[1..]).is_some(),
|
||||||
|
"Broken ref {reference}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for child in object.values() {
|
||||||
|
check_references(child, document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(items) => {
|
||||||
|
for child in items {
|
||||||
|
check_references(child, document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn openapi_covers_live_routes_and_honors_custom_prefix() {
|
||||||
|
let (_dir, app) = fixture("/v2/api");
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(Request::get("/openapi.json").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(response.headers()["content-type"], "application/json");
|
||||||
|
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let document: Value = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
assert_eq!(document["openapi"], "3.1.0");
|
||||||
|
assert_eq!(document["info"]["title"], "MikroTik API contract test");
|
||||||
|
assert_eq!(document["info"]["version"], env!("CARGO_PKG_VERSION"));
|
||||||
|
assert_eq!(document["servers"][0]["url"], "/v2/api");
|
||||||
|
assert_eq!(
|
||||||
|
document["tags"],
|
||||||
|
serde_json::json!([
|
||||||
|
{"name": "Account"},
|
||||||
|
{"name": "Devices"},
|
||||||
|
{"name": "Backups"},
|
||||||
|
{"name": "Settings"},
|
||||||
|
{"name": "Logs"},
|
||||||
|
{"name": "System"}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
document["info"]["description"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("Documentation for the live API.")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document["paths"]["/auth/login"]["post"]["summary"],
|
||||||
|
"Log in"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document["paths"]["/routers"]["get"]["tags"],
|
||||||
|
serde_json::json!(["Devices"])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document["components"]["securitySchemes"]["OAuth2PasswordBearer"]["flows"]["password"]["tokenUrl"],
|
||||||
|
"/v2/api/auth/login"
|
||||||
|
);
|
||||||
|
check_references(&document, &document);
|
||||||
|
let mut operation_ids = HashSet::new();
|
||||||
|
let mut operation_count = 0;
|
||||||
|
for (path, methods) in document["paths"].as_object().unwrap() {
|
||||||
|
let concrete = ["id", "backup_id", "left", "right", "days"]
|
||||||
|
.into_iter()
|
||||||
|
.fold(path.clone(), |path, name| {
|
||||||
|
path.replace(&format!("{{{name}}}"), "1")
|
||||||
|
});
|
||||||
|
for (method, operation) in methods.as_object().unwrap() {
|
||||||
|
operation_count += 1;
|
||||||
|
assert!(operation_ids.insert(operation["operationId"].as_str().unwrap()));
|
||||||
|
let public = ["/health", "/auth/login", "/auth/register"].contains(&path.as_str());
|
||||||
|
assert_eq!(operation.get("security").is_some(), public);
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method(method.to_ascii_uppercase().as_str())
|
||||||
|
.uri(format!("/v2/api{concrete}"))
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from("{}"))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"Undocumented route mismatch: {method} {path}"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
|
"Undocumented method mismatch: {method} {path}"
|
||||||
|
);
|
||||||
|
if !public {
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Protected route {method} {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(operation_count, 36);
|
||||||
|
assert_eq!(
|
||||||
|
document["components"]["schemas"]["Device"]["properties"]["ssh_password"]["type"],
|
||||||
|
"null"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
document["components"]["schemas"]["DeviceUpdate"]["properties"]
|
||||||
|
.get("use_global_switchos_credentials")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn docs_and_embedded_assets_work_without_a_cdn() {
|
||||||
|
let (_dir, app) = fixture("/api");
|
||||||
|
let mut documentation_html = None;
|
||||||
|
for path in ["/docs", "/docs/"] {
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(Request::get(path).body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert!(
|
||||||
|
response.headers()["content-security-policy"]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("script-src 'self'")
|
||||||
|
);
|
||||||
|
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let html = std::str::from_utf8(&bytes).unwrap();
|
||||||
|
assert!(html.contains("<html lang=\"en\">"));
|
||||||
|
assert!(html.contains("aria-label=\"API documentation\""));
|
||||||
|
assert!(html.contains("Back to the application"));
|
||||||
|
assert!(html.contains(env!("DOCS_JS_ROUTE")));
|
||||||
|
assert!(html.contains(env!("DOCS_CSS_ROUTE")));
|
||||||
|
assert!(!html.contains("?v="));
|
||||||
|
assert_eq!(html.matches("<script ").count(), 1);
|
||||||
|
assert!(!html.contains("https://"));
|
||||||
|
if let Some(previous) = &documentation_html {
|
||||||
|
assert_eq!(html, previous);
|
||||||
|
} else {
|
||||||
|
documentation_html = Some(html.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let documentation_html = documentation_html.unwrap();
|
||||||
|
for (path, mime) in [
|
||||||
|
(env!("DOCS_JS_ROUTE"), "text/javascript"),
|
||||||
|
(env!("DOCS_CSS_ROUTE"), "text/css"),
|
||||||
|
] {
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(Request::get(path).body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert!(
|
||||||
|
response.headers()["content-type"]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with(mime)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers()["cache-control"].to_str().unwrap(),
|
||||||
|
"public, max-age=31536000, immutable"
|
||||||
|
);
|
||||||
|
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
assert!(bytes.len() > 1000);
|
||||||
|
assert!(documentation_html.contains(&format!("\"{path}\"")));
|
||||||
|
if path.ends_with(".js") {
|
||||||
|
let javascript = std::str::from_utf8(&bytes).unwrap();
|
||||||
|
assert!(javascript.contains("validatorUrl: null"));
|
||||||
|
assert!(javascript.contains("queryConfigEnabled: false"));
|
||||||
|
assert!(javascript.contains("persistAuthorization: false"));
|
||||||
|
assert!(
|
||||||
|
javascript.contains("API documentation can send requests only to this instance.")
|
||||||
|
);
|
||||||
|
assert!(javascript.ends_with(include_str!("../docs/api-ui/init.js")));
|
||||||
|
} else {
|
||||||
|
let stylesheet = std::str::from_utf8(&bytes).unwrap();
|
||||||
|
assert!(stylesheet.ends_with(include_str!("../docs/api-ui/style.css")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let response = app
|
||||||
|
.oneshot(Request::get("/redoc").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
|
||||||
|
assert_eq!(response.headers()["location"], "/docs");
|
||||||
|
}
|
||||||
Vendored
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# Vendored from crates.io libssh2-sys 0.3.3; see PROVENANCE.md.
|
||||||
|
[package]
|
||||||
|
name = "libssh2-sys"
|
||||||
|
version = "0.3.3"
|
||||||
|
publish = false
|
||||||
|
authors = [
|
||||||
|
"Alex Crichton <alex@alexcrichton.com>",
|
||||||
|
"Wez Furlong <wez@wezfurlong.org>",
|
||||||
|
"Matteo Bigoi <bigo@crisidev.org>",
|
||||||
|
]
|
||||||
|
build = "build.rs"
|
||||||
|
links = "ssh2"
|
||||||
|
autolib = false
|
||||||
|
autobins = false
|
||||||
|
autoexamples = false
|
||||||
|
autotests = false
|
||||||
|
autobenches = false
|
||||||
|
description = "Native bindings to the libssh2 library"
|
||||||
|
documentation = "https://docs.rs/libssh2-sys"
|
||||||
|
readme = false
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/alexcrichton/ssh2-rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
openssl-on-win32 = ["openssl-sys"]
|
||||||
|
vendored-openssl = ["openssl-sys/vendored"]
|
||||||
|
zlib-ng-compat = ["libz-sys/zlib-ng"]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "libssh2_sys"
|
||||||
|
path = "lib.rs"
|
||||||
|
|
||||||
|
[dependencies.libc]
|
||||||
|
version = "0.2"
|
||||||
|
|
||||||
|
[dependencies.libz-sys]
|
||||||
|
version = "1.1.0"
|
||||||
|
features = ["libc"]
|
||||||
|
default-features = false
|
||||||
|
|
||||||
|
[build-dependencies.cc]
|
||||||
|
version = "1.0.25"
|
||||||
|
|
||||||
|
[target."cfg(unix)".dependencies.openssl-sys]
|
||||||
|
version = "0.9.35"
|
||||||
|
|
||||||
|
[target."cfg(windows)".dependencies.openssl-sys]
|
||||||
|
version = "0.9.35"
|
||||||
|
optional = true
|
||||||
Vendored
+201
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
Copyright (c) 2014 Alex Crichton
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any
|
||||||
|
person obtaining a copy of this software and associated
|
||||||
|
documentation files (the "Software"), to deal in the
|
||||||
|
Software without restriction, including without
|
||||||
|
limitation the rights to use, copy, modify, merge,
|
||||||
|
publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software
|
||||||
|
is furnished to do so, subject to the following
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice
|
||||||
|
shall be included in all copies or substantial portions
|
||||||
|
of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||||
|
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||||
|
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||||
|
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||||
|
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||||
|
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||||
|
DEALINGS IN THE SOFTWARE.
|
||||||
Vendored
+93
@@ -0,0 +1,93 @@
|
|||||||
|
# Vendored libssh2 security backports
|
||||||
|
|
||||||
|
This directory contains the compilation sources of **libssh2-sys 0.3.3** from
|
||||||
|
crates.io, with narrowly scoped libssh2 security backports. The application uses
|
||||||
|
it through Cargo's `[patch.crates-io]` mechanism. It is not a new upstream release
|
||||||
|
and does not claim to be libssh2 1.11.2.
|
||||||
|
|
||||||
|
## Source identity and licensing
|
||||||
|
|
||||||
|
- Published package: <https://crates.io/crates/libssh2-sys/0.3.3>.
|
||||||
|
- Original archive: <https://static.crates.io/crates/libssh2-sys/libssh2-sys-0.3.3.crate>.
|
||||||
|
- Archive SHA-256: `0f5eb74291e8691cab524a01274a1b1e7742b1a94f29d8b101d8aadc8372c1cd`.
|
||||||
|
- Wrapper repository revision: `1abdbdb031cfea7e9e3dca7980ea36845bba1da8`.
|
||||||
|
- Bundled libssh2 revision: `090f23d1852a0b62e081bd12c4d05adc1c4af47a`.
|
||||||
|
- Wrapper license: `LICENSE-MIT` or `LICENSE-APACHE`, preserved verbatim.
|
||||||
|
- Native library license and additional notices: `libssh2/COPYING`,
|
||||||
|
`libssh2/LICENSES/`, and the original source headers, preserved verbatim.
|
||||||
|
|
||||||
|
Only files needed by the original Cargo build and licenses were copied. Upstream
|
||||||
|
examples, CI files, manuals and unrelated build-system files are omitted. The
|
||||||
|
original Rust FFI source and C headers are unchanged. The small tests in this
|
||||||
|
directory are new regression checks for this distribution, licensed under MIT.
|
||||||
|
|
||||||
|
The published source is based on libssh2 1.11.1 and already includes backports for
|
||||||
|
CVE-2025-15661, CVE-2026-55199 and CVE-2026-55200, plus the SFTP status follow-up
|
||||||
|
`4ed26f5740bdd409269ed9fb48a28bf8f565b681`. Those fixes are retained.
|
||||||
|
|
||||||
|
## Additional patches
|
||||||
|
|
||||||
|
The unmodified upstream patch downloads are in `patches/upstream/`.
|
||||||
|
`patches/upstream-manifest.json` records each URL, full commit and SHA-256.
|
||||||
|
|
||||||
|
| Issue | Upstream commit | Change carried into the bundled code |
|
||||||
|
|---|---|---|
|
||||||
|
| CVE-2026-7598 | [256d04b60d80](https://github.com/libssh2/libssh2/commit/256d04b60d80bf1190e96b0ad1e91b2174d744b1) | Reject overflowing username lengths in authentication APIs; correct the password-change length comparison. |
|
||||||
|
| CVE-2026-66032 | [5e4776146552](https://github.com/libssh2/libssh2/commit/5e4776146552d898b9c0e1b313cd093fa8dc92d0) | Null the freed SFTP response pointer before waiting for a handle response. |
|
||||||
|
| CVE-2026-66033 | [a2ed82d40964](https://github.com/libssh2/libssh2/commit/a2ed82d40964bbc0d64cd717aa0a5a892117d2e6) | Apply AES-GCM bounds checks in release builds before computing the cipher length. |
|
||||||
|
| CVE-2026-66034 | [a13bb6c773f0](https://github.com/libssh2/libssh2/commit/a13bb6c773f0d55ad1628cede57e99803cd898d9) | Check remaining publickey response data before consuming a server-supplied comment length. |
|
||||||
|
| CVE-2026-66035 | [42e33d81577e](https://github.com/libssh2/libssh2/commit/42e33d81577ed4b95d4b4f6f845e5ee8efe5eeb4) | Reject an ETM packet too short for its MAC, length and first cipher block before decrypting. |
|
||||||
|
| CVE-2026-58050 | [34497525929b](https://github.com/libssh2/libssh2/commit/34497525929b9a47f03dfb81887ac896202b7e12) | Bound publickey attribute count to 1024 before multiplying for allocation. |
|
||||||
|
| CVE-2026-58051 | [a9758da45a52](https://github.com/libssh2/libssh2/commit/a9758da45a52bc8c630ec9493804d0c6ea30b24a) | Initialize newly allocated publickey list entries before error cleanup can inspect them. |
|
||||||
|
| Publickey cleanup follow-up | [c2f1a3a21b4c](https://github.com/libssh2/libssh2/commit/c2f1a3a21b4c922cbcf50bf7c009b740141a1e7a) | Initialize all new entries and free partially initialized attribute allocations. |
|
||||||
|
| Publickey terminator follow-up | [ad16eb112f4a](https://github.com/libssh2/libssh2/commit/ad16eb112f4a9591acdf5f6f2981328613e6b626) | Include the reserved terminator entry in zero-initialization. |
|
||||||
|
|
||||||
|
Upstream renamed private symbols after the bundled base revision. Backports use
|
||||||
|
the corresponding old `_libssh2_*` / `LIBSSH2_*` names and local formatting.
|
||||||
|
Upstream `SSH2_SAFEFREE(session, p->payload)` is represented by the equivalent
|
||||||
|
`LIBSSH2_FREE(session, p->payload); p->payload = NULL;`. The three publickey
|
||||||
|
initialization/cleanup patches are combined into their final corrected form.
|
||||||
|
These mechanical adaptations do not change their conditions or error codes.
|
||||||
|
|
||||||
|
## Reproducible patch and build selection
|
||||||
|
|
||||||
|
`patches/security-backports.patch` applies to an extracted, original 0.3.3 crate
|
||||||
|
with `patch -p1`. Its SHA-256 is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
b6f970cfc5749e0ba35695b7d49b0774a191779d159a438249f9065ecc8a88bd
|
||||||
|
```
|
||||||
|
|
||||||
|
Replay was checked on a fresh copy: all seven modified files were byte-identical
|
||||||
|
to the shipped source. `SHA256SUMS` covers the shipped vendor files except the
|
||||||
|
checksum list itself. Verify it from this directory with `sha256sum -c SHA256SUMS`.
|
||||||
|
|
||||||
|
`build.rs` always compiles the bundled C source. It rejects
|
||||||
|
`LIBSSH2_SYS_USE_PKG_CONFIG`; automatic vcpkg fallback and the build-time Git
|
||||||
|
submodule command are removed. Source/header changes trigger a rebuild. This
|
||||||
|
prevents a local system libssh2 from silently replacing the corrected source.
|
||||||
|
OpenSSL and zlib remain the original supported backend dependencies. Their
|
||||||
|
operating-system packages require the usual security maintenance.
|
||||||
|
|
||||||
|
The wrapper manifest is marked `publish = false`; unused pkg-config/vcpkg direct
|
||||||
|
build dependencies are removed. All other wrapper features and dependencies are
|
||||||
|
preserved.
|
||||||
|
|
||||||
|
## Regression verification
|
||||||
|
|
||||||
|
After a Linux Cargo build, use the archive emitted by this exact path dependency:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vendor/libssh2-sys/tests/run-security-regressions.sh \
|
||||||
|
/absolute/path/to/target/debug/build/libssh2-sys-<build-id>/out/build/libssh2.a
|
||||||
|
```
|
||||||
|
|
||||||
|
The test passes deliberately malformed short and oversized AES-GCM blocks and
|
||||||
|
overflowing username lengths, without a network connection. It requires a C
|
||||||
|
compiler and the OpenSSL/zlib development libraries used by the normal build.
|
||||||
|
The application integration tests separately exercise real SSH, SFTP, SCP,
|
||||||
|
configuration export and binary upload against a local SSH server.
|
||||||
|
|
||||||
|
The published `README.md` records which checks were actually run. A
|
||||||
|
clean RustSec report alone does not audit bundled C code, prove absence of other
|
||||||
|
vulnerabilities, or replace device testing.
|
||||||
Vendored
+91
@@ -0,0 +1,91 @@
|
|||||||
|
67553c86d569577e9ab8548c8d819ef71760d73d34374f23c0c0cd21c8c1f8ee Cargo.toml
|
||||||
|
a60eea817514531668d7e00765731449fe14d059d3249e0bc93b36de45f759f2 LICENSE-APACHE
|
||||||
|
378f5840b258e2779c39418f3f2d7b2ba96f1c7917dd6be0713f88305dbda397 LICENSE-MIT
|
||||||
|
57601e789769e6719ef9b6d7eff6f83e7c8a07a92afb471de629e21e316ce1d5 PROVENANCE.md
|
||||||
|
f4db47339ee857174c97128c78bd9b9abc95220b2eb4cc64e9dc4d9e38dae341 build.rs
|
||||||
|
d7b081dc4c07012e5e125352e2f2b939cce93a7a70ae657c77655aa93a9f05ec lib.rs
|
||||||
|
f7f9633cf9ff2f1333f3d7ce46973a8716a4d2a2815ad56f30d437d5fea7bafe libssh2/COPYING
|
||||||
|
3aedabc2618dd11c18747e1ccc680b2841b4aa414de661ae9dc1d4602c0b93f0 libssh2/LICENSES/BSD-2-Clause.txt
|
||||||
|
3cf06aba3588c41c514f6946bb2d757b413ff6491647d474800f55edca75dcb4 libssh2/LICENSES/BSD-3-Clause.txt
|
||||||
|
ffff0ec9c19fc90efbad30c66b7e335ab1f824a7ef806d943b45d28b67c75f9a libssh2/LICENSES/FSFULLR.txt
|
||||||
|
002c2696d92b5c8cf956c11072baa58eaf9f6ade995c031ea635c6a1ee342ad1 libssh2/LICENSES/MIT.txt
|
||||||
|
49f2fc047b5f5cc66be8a70dd65825a37386f9bb8d08d6ed939351a3a7750b90 libssh2/LICENSES/SAX-PD-2.0.txt
|
||||||
|
d3483fbbf511197d9b8402e48bf4bb5dc8c082abef0ae957334a2c3e8f32e976 libssh2/LICENSES/curl.txt
|
||||||
|
767d54ff5ae2cd0dab48895ccbcb8ee045a17f826f77a24c06461634fef62778 libssh2/include/libssh2.h
|
||||||
|
f1cd086f3950e65635827ee3332c1c6ca62887c0f84369ec58e31974debb36e9 libssh2/include/libssh2_publickey.h
|
||||||
|
b5d864f19af69521278ed953b20b76a32cfdc08014da81d38f59964e7e2e2575 libssh2/include/libssh2_sftp.h
|
||||||
|
938b40033559b6b82d8f98872180dc9c0d7953b4dbd2f9217b3b77e506bac330 libssh2/libssh2.pc.in
|
||||||
|
11691629d5f88a4031d5ccf6ac756a66d1b1abfe6e138e81161e82b3ff4cbbbf libssh2/src/.gitignore
|
||||||
|
3857309526ae9794db496de79b509bda925798fcc857f5b30c50509823ac7841 libssh2/src/CMakeLists.txt
|
||||||
|
a71aa8dde39f76e726d57514f71c88402523f241a6c3edae8b269664e8ea0aa5 libssh2/src/Makefile.am
|
||||||
|
ed8a644a5c476a0eea976189d1dc277af23426046cadfbd0095db44305fdbd6f libssh2/src/Makefile.inc
|
||||||
|
a2e9b5a7c2bcba8fd252219f4308ddf5c192695a0957e1934000a9d3a6cbd470 libssh2/src/agent.c
|
||||||
|
3236e068f656cb381fac00350f14b281cb55cc84e1495af41dfec930e85c2f5d libssh2/src/agent_win.c
|
||||||
|
8df5f079c77ae9e5b452a575e2ecf33bf4478b356d2975c9a55eef364c84a415 libssh2/src/bcrypt_pbkdf.c
|
||||||
|
baeedac57701200ce12ab4beef6ff56dd6bfb787eaa9d9c1f4fe16fbdf690a32 libssh2/src/blowfish.c
|
||||||
|
0a1efb5884580f0b279fcd8782e455f8e3ca07bbdfebc457706f6e8bf18f296c libssh2/src/chacha.c
|
||||||
|
b42346b01acd60d6502e689802059648205c118035ede40bec8e0d60cf238518 libssh2/src/chacha.h
|
||||||
|
79c49c36e65178847087eb3a1c140772c291899b838c705b579214929a14742e libssh2/src/channel.c
|
||||||
|
0ed48da58c98ab7afcbb9a86177a29c92bb66dbd8793e1a8e477da0ef2f66e6d libssh2/src/channel.h
|
||||||
|
d9f806b81541de62184e3190b7e1c4f7ab576fd290e3558fe25bf0e00f115f5d libssh2/src/cipher-chachapoly.c
|
||||||
|
f6a69fb48994da6bf7f286884ed473ee0546027bba3af433b38b95d1b4442f5f libssh2/src/cipher-chachapoly.h
|
||||||
|
b2d1642bc02e0b50b02f2619393ac9afd15985bdbc088231a6ea876a82f23226 libssh2/src/comp.c
|
||||||
|
95aa07dee4c6f2c33f6bc762c6532997e37303bf1be1bb1276f4ecd5e7092443 libssh2/src/comp.h
|
||||||
|
2d69312c6059192560bb72e4bfa967e4b6e7c4453383580da90ab86233e98aa4 libssh2/src/crypt.c
|
||||||
|
f3eaf5a3a69d91b144f3d4c2f36313dc3264aada85aa1f42d7c1f6bbf969116f libssh2/src/crypto.c
|
||||||
|
1d3d7a56800f0b5b0d008a68d09aaaa26e326879cfd8ace7df8b99406e97f659 libssh2/src/crypto.h
|
||||||
|
e541d1a3b2a5e74733ce745a4d016175fa4ed1fb6571b8a4d69fdf5efdb576c2 libssh2/src/crypto_config.h
|
||||||
|
a6c325a335875d6d0fa0dbe544334a469cec32b45879b9372a49419da13bb492 libssh2/src/global.c
|
||||||
|
32edcf5a3023c67740b8a5e290a2db4957a547be7fb9f1778c143f08a622b65f libssh2/src/hostkey.c
|
||||||
|
93c0427472296781c0e01275e60b1d74c0f678112c45eebd5e233b31f4e4bbca libssh2/src/keepalive.c
|
||||||
|
50b90f522575d794b853802221e8b2f2cc99c3b68ff191e1252d70ed1276b6b5 libssh2/src/kex.c
|
||||||
|
f466648bff0197c06e3ea2380b9083b634f0002d41293f1102341247dd208872 libssh2/src/knownhost.c
|
||||||
|
90c46b20d6cc0288f4f3f272d5a4e8d2c0495d3f728d0b62ed9e0e026baf2532 libssh2/src/libgcrypt.c
|
||||||
|
0ae0284295424a04dba948432527b70fb12d447e8be8ce28e2be1e480323d4ac libssh2/src/libgcrypt.h
|
||||||
|
26d6cae6049b6fb7d32df0ea8a30ffbc0c52f1871be257bcb2986f40555ddada libssh2/src/libssh2.rc
|
||||||
|
a2d971a484dc63bea9cc46693ffc3de6227e118a32153df7db3df9139be14b00 libssh2/src/libssh2_config_cmake.h.in
|
||||||
|
c3da0fff8726b52b3dd2429bbaf26f75d93f0188367d650557d6710017ebb890 libssh2/src/libssh2_priv.h
|
||||||
|
d5d1c31758f49ef67e6afac92a7c8fc0ccb53b1fd2bba550e57a3042c17ba55b libssh2/src/libssh2_setup.h
|
||||||
|
b21e1b85582e43c9696c74fce826c664003326522d1bafb7a9ba6b28a0a6f64d libssh2/src/mac.c
|
||||||
|
acdc44807370d226c64958e870f3daec767378b45246bbcdee37db87120dab61 libssh2/src/mac.h
|
||||||
|
e56b0dafc27140de389926bb7e8417ab3e5255e74126ee4a588c18d70ad188cd libssh2/src/mbedtls.c
|
||||||
|
f26002e949125aac18471f10e590cbb5bee47c70de8a7798763604ef0ddcd44c libssh2/src/mbedtls.h
|
||||||
|
c4e749f78c4f5224fcb02eb50de65dc804ed4aaaa59a734b10a12f54fe2fa085 libssh2/src/misc.c
|
||||||
|
608eb5736eb367b82e93c96b8bc94fb87b23141f4658ee991307e64a721c4fa9 libssh2/src/misc.h
|
||||||
|
2df90530491643625921e42abbab3f7c4ffc981ce2276e1d0826cc38e896f484 libssh2/src/openssl.c
|
||||||
|
bdc6cc39f5a075d4497cc0222892ac371418133e6cfcaa357b131cd62d54e82a libssh2/src/openssl.h
|
||||||
|
31e230fdef9acdb23cd2de667b532f7b6853aacd403f54dbf1f700d2e68dbaca libssh2/src/os400qc3.c
|
||||||
|
c1f8a950a42a8a05ca00ac142915d9fd5bd99ef0c9a3545a12823c03ccb8673d libssh2/src/os400qc3.h
|
||||||
|
6ec4270e721ec1e0a5e57af52f59006b87438568bcca6057eb76cdcfd3a0b6ca libssh2/src/packet.c
|
||||||
|
66ded0eea0be5b4c1f5217e053941a848e5106311eb9338dd39f1bc48713a4c4 libssh2/src/packet.h
|
||||||
|
eba1a89ba4beb0aa9aaf4e4da68be5f2f890e7367001e8bb705c697b9485d416 libssh2/src/pem.c
|
||||||
|
cc0597e7de057ab22ab988bb98d4418d716ea26bb2ad418879007dd38dce2ebf libssh2/src/poly1305.c
|
||||||
|
58a34be15d390cae4cb5a215ccf139f0ca359cdb47ea846e6e7e1e6141d3409e libssh2/src/poly1305.h
|
||||||
|
ba80e17329a880d5bea1f8cf035dcf140c002ba1ed83eceaaec8dc352b6b3d28 libssh2/src/publickey.c
|
||||||
|
6a12ddc4c246b510ff61337d908c683714d4223fdb27f1256605a29f3925dc1a libssh2/src/scp.c
|
||||||
|
9f9c0a55ee7ca5204f73f2eb6e6c37957d1fe3f160ea7a819248310e1a58d8aa libssh2/src/session.c
|
||||||
|
19ebee873e9f5e1f56850bf263053d2527c28a184f30ac42f66aa09fb38d1b5a libssh2/src/session.h
|
||||||
|
93d9ed061a7b050649804e13aa208e9a726a6e694d696926bffddd5f011d84ec libssh2/src/sftp.c
|
||||||
|
9c53d613cfdb14d4244ab0643918de60363d9a360a40413cd2e4d682f3e32125 libssh2/src/sftp.h
|
||||||
|
5291489a6da89018354d67edb1675cec9ea14dcae232628527078e7094b6cae2 libssh2/src/transport.c
|
||||||
|
f2a55152b28ab3290d426767d9e33b053312300b0b4105cc0d20814c1b133ff3 libssh2/src/transport.h
|
||||||
|
4fff87fde157b9936c536fe545c2966a1b2d7f4e387db352849220020f98eadc libssh2/src/userauth.c
|
||||||
|
23eb97c342ab6bc51606fe917828e8c01cd4bda4c956d5f22b66d86b6db0d1a3 libssh2/src/userauth.h
|
||||||
|
e58477ff04c25b7a7f3c0654e36cbc483eef6a4ccc2d66b88223678d757502d2 libssh2/src/userauth_kbd_packet.c
|
||||||
|
570d9cbca0e3969d1f7dac2e3d89b0ad3b79bd9f0993d60b830a3b30113ae9f3 libssh2/src/userauth_kbd_packet.h
|
||||||
|
2710abb5561557c62613f1780fb593d9bc423105bdee81d0ace9bc1ab3b5fb23 libssh2/src/version.c
|
||||||
|
cd98e55761c4adebec10256ebe0f0b4f59ab23605d5663f5ccb7e7e2827debfe libssh2/src/wincng.c
|
||||||
|
78deb417e2cb9e514ad9da35db2f918d2649034318959b2b7a0fa15a9e5f960a libssh2/src/wincng.h
|
||||||
|
b6f970cfc5749e0ba35695b7d49b0774a191779d159a438249f9065ecc8a88bd patches/security-backports.patch
|
||||||
|
9cb033e07004a755675e2095eb7f05ebeaafa70b0920a1fdb8f6ad23498047d9 patches/upstream/58050-34497525929b.patch
|
||||||
|
0aee020bbc88acd2a3d828f1c85861f9f5a36204c07543cddc0abd68d4b5ed0a patches/upstream/58051-a9758da45a52.patch
|
||||||
|
a70ff256ce24fc5e4144fcc4409a5874c397e1f9e1b1d437120e4be3f5fc051d patches/upstream/66032-5e4776146552.patch
|
||||||
|
f59ff6d6575ec8f17af41825aac4dc6c8ddcdd6909dc6ae3a535fa13dba8a290 patches/upstream/66033-a2ed82d40964.patch
|
||||||
|
896ff800992af462519f0b2d2f47a84f5096728f27c9faf36b1676eaa9c6620f patches/upstream/66034-a13bb6c773f0.patch
|
||||||
|
0422c11e22df21dc8154fa0aeb812157d0798d990bd9ab2cd05844dc21653693 patches/upstream/66035-42e33d81577e.patch
|
||||||
|
fd4775228fe04a11532043355099fe2643993aae4fb63576620b1941f9a80645 patches/upstream/7598-256d04b60d80.patch
|
||||||
|
8d0e86562978b93406b85531e1f391d2f03c0387c057fb8b3a6e14b228aabdb5 patches/upstream/publickey-leak-c2f1a3a21b4c.patch
|
||||||
|
6464c70d8d34249e5d0a0969136f35c1a2a0e3d0424c2889c5b8e6fbe874f232 patches/upstream/publickey-terminator-ad16eb112f4a.patch
|
||||||
|
3c238183c67b3aad6497cdc8368d32cde5a4392dcba3134da0fa40c50882963e patches/upstream-manifest.json
|
||||||
|
00641403f44417872ef9fe45fe2a581451e4b9fd12305d1b3902f1433036edcc tests/run-security-regressions.sh
|
||||||
|
4d21796cd08f3dbf44664f3e53122960847940d306792ac7efcb8720794320cf tests/security-regression.c
|
||||||
Vendored
+180
@@ -0,0 +1,180 @@
|
|||||||
|
extern crate cc;
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// This distribution contains reviewed security backports that an
|
||||||
|
// arbitrary system libssh2 may lack. Always compile this source tree.
|
||||||
|
println!("cargo:rerun-if-env-changed=LIBSSH2_SYS_USE_PKG_CONFIG");
|
||||||
|
if env::var_os("LIBSSH2_SYS_USE_PKG_CONFIG").is_some() {
|
||||||
|
panic!("This distribution requires its patched bundled libssh2; unset LIBSSH2_SYS_USE_PKG_CONFIG");
|
||||||
|
}
|
||||||
|
println!("cargo:rerun-if-changed=libssh2/src");
|
||||||
|
println!("cargo:rerun-if-changed=libssh2/include");
|
||||||
|
println!("cargo:rerun-if-changed=libssh2/libssh2.pc.in");
|
||||||
|
|
||||||
|
let target = env::var("TARGET").unwrap();
|
||||||
|
let profile = env::var("PROFILE").unwrap();
|
||||||
|
let dst = PathBuf::from(env::var_os("OUT_DIR").unwrap());
|
||||||
|
let mut cfg = cc::Build::new();
|
||||||
|
|
||||||
|
let include = dst.join("include");
|
||||||
|
println!("cargo:include={}", include.display());
|
||||||
|
println!("cargo:root={}", dst.display());
|
||||||
|
let build = dst.join("build");
|
||||||
|
cfg.out_dir(&build);
|
||||||
|
fs::create_dir_all(&build).unwrap();
|
||||||
|
fs::create_dir_all(&include).unwrap();
|
||||||
|
|
||||||
|
fs::copy("libssh2/include/libssh2.h", include.join("libssh2.h")).unwrap();
|
||||||
|
fs::copy(
|
||||||
|
"libssh2/include/libssh2_publickey.h",
|
||||||
|
include.join("libssh2_publickey.h"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::copy(
|
||||||
|
"libssh2/include/libssh2_sftp.h",
|
||||||
|
include.join("libssh2_sftp.h"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
cfg.file("libssh2/src/agent.c")
|
||||||
|
.file("libssh2/src/bcrypt_pbkdf.c")
|
||||||
|
.file("libssh2/src/blowfish.c")
|
||||||
|
.file("libssh2/src/chacha.c")
|
||||||
|
.file("libssh2/src/channel.c")
|
||||||
|
.file("libssh2/src/cipher-chachapoly.c")
|
||||||
|
.file("libssh2/src/comp.c")
|
||||||
|
.file("libssh2/src/crypt.c")
|
||||||
|
.file("libssh2/src/crypto.c")
|
||||||
|
.file("libssh2/src/global.c")
|
||||||
|
.file("libssh2/src/hostkey.c")
|
||||||
|
.file("libssh2/src/keepalive.c")
|
||||||
|
.file("libssh2/src/kex.c")
|
||||||
|
.file("libssh2/src/knownhost.c")
|
||||||
|
.file("libssh2/src/mac.c")
|
||||||
|
.file("libssh2/src/misc.c")
|
||||||
|
.file("libssh2/src/packet.c")
|
||||||
|
.file("libssh2/src/pem.c")
|
||||||
|
.file("libssh2/src/poly1305.c")
|
||||||
|
.file("libssh2/src/publickey.c")
|
||||||
|
.file("libssh2/src/scp.c")
|
||||||
|
.file("libssh2/src/session.c")
|
||||||
|
.file("libssh2/src/sftp.c")
|
||||||
|
.file("libssh2/src/transport.c")
|
||||||
|
.file("libssh2/src/userauth.c")
|
||||||
|
.file("libssh2/src/userauth_kbd_packet.c")
|
||||||
|
.include(&include)
|
||||||
|
.include("libssh2/src");
|
||||||
|
|
||||||
|
cfg.define("HAVE_LONGLONG", None);
|
||||||
|
|
||||||
|
if target.contains("windows") {
|
||||||
|
cfg.include("libssh2/win32");
|
||||||
|
cfg.define("LIBSSH2_WIN32", None);
|
||||||
|
cfg.file("libssh2/src/agent_win.c");
|
||||||
|
|
||||||
|
if env::var_os("CARGO_FEATURE_OPENSSL_ON_WIN32").is_some() {
|
||||||
|
cfg.define("LIBSSH2_OPENSSL", None);
|
||||||
|
cfg.define("HAVE_EVP_AES_128_CTR", None);
|
||||||
|
let lib_prefix = if target.contains("windows-msvc") {
|
||||||
|
"lib"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
println!("cargo:rustc-link-lib=static={lib_prefix}ssl");
|
||||||
|
println!("cargo:rustc-link-lib=static={lib_prefix}crypto");
|
||||||
|
println!("cargo:rustc-link-lib=advapi32");
|
||||||
|
} else {
|
||||||
|
cfg.define("LIBSSH2_WINCNG", None);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg.flag("-fvisibility=hidden");
|
||||||
|
cfg.define("HAVE_SNPRINTF", None);
|
||||||
|
cfg.define("HAVE_UNISTD_H", None);
|
||||||
|
cfg.define("HAVE_INTTYPES_H", None);
|
||||||
|
cfg.define("HAVE_STDLIB_H", None);
|
||||||
|
cfg.define("HAVE_SYS_SELECT_H", None);
|
||||||
|
cfg.define("HAVE_SYS_SOCKET_H", None);
|
||||||
|
cfg.define("HAVE_SYS_IOCTL_H", None);
|
||||||
|
cfg.define("HAVE_SYS_TIME_H", None);
|
||||||
|
cfg.define("HAVE_SYS_UN_H", None);
|
||||||
|
cfg.define("HAVE_O_NONBLOCK", None);
|
||||||
|
cfg.define("LIBSSH2_OPENSSL", None);
|
||||||
|
cfg.define("HAVE_LIBCRYPT32", None);
|
||||||
|
cfg.define("HAVE_EVP_AES_128_CTR", None);
|
||||||
|
cfg.define("HAVE_POLL", None);
|
||||||
|
cfg.define("HAVE_GETTIMEOFDAY", None);
|
||||||
|
|
||||||
|
// Create `libssh2_config.h`
|
||||||
|
let config = fs::read_to_string("libssh2/src/libssh2_config_cmake.h.in").unwrap();
|
||||||
|
let config = config
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.contains("#cmakedefine"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
fs::write(build.join("libssh2_config.h"), &config).unwrap();
|
||||||
|
cfg.include(&build);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enable newer diffie-hellman-group-exchange-sha1 syntax */
|
||||||
|
cfg.define("LIBSSH2_DH_GEX_NEW", None);
|
||||||
|
|
||||||
|
cfg.define("LIBSSH2_HAVE_ZLIB", None);
|
||||||
|
|
||||||
|
if profile.contains("debug") {
|
||||||
|
cfg.define("LIBSSH2DEBUG", None);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-env-changed=DEP_Z_INCLUDE");
|
||||||
|
if let Some(path) = env::var_os("DEP_Z_INCLUDE") {
|
||||||
|
cfg.include(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-env-changed=DEP_OPENSSL_INCLUDE");
|
||||||
|
if let Some(path) = env::var_os("DEP_OPENSSL_INCLUDE") {
|
||||||
|
if let Some(path) = env::split_paths(&path).next() {
|
||||||
|
if let Some(path) = path.to_str() {
|
||||||
|
if path.len() > 0 {
|
||||||
|
cfg.include(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let libssh2h = fs::read_to_string("libssh2/include/libssh2.h").unwrap();
|
||||||
|
let version_line = libssh2h
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.contains("LIBSSH2_VERSION"))
|
||||||
|
.unwrap();
|
||||||
|
let version = &version_line[version_line.find('"').unwrap() + 1..version_line.len() - 1];
|
||||||
|
|
||||||
|
let pkgconfig = dst.join("lib/pkgconfig");
|
||||||
|
fs::create_dir_all(&pkgconfig).unwrap();
|
||||||
|
fs::write(
|
||||||
|
pkgconfig.join("libssh2.pc"),
|
||||||
|
fs::read_to_string("libssh2/libssh2.pc.in")
|
||||||
|
.unwrap()
|
||||||
|
.replace("@prefix@", dst.to_str().unwrap())
|
||||||
|
.replace("@exec_prefix@", "")
|
||||||
|
.replace("@libdir@", dst.join("lib").to_str().unwrap())
|
||||||
|
.replace("@includedir@", include.to_str().unwrap())
|
||||||
|
.replace("@LIBS@", "")
|
||||||
|
.replace("@LIBSREQUIRED@", "")
|
||||||
|
.replace("@LIBSSH2VER@", version),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
cfg.warnings(false);
|
||||||
|
cfg.compile("ssh2");
|
||||||
|
|
||||||
|
if target.contains("windows") {
|
||||||
|
println!("cargo:rustc-link-lib=bcrypt");
|
||||||
|
println!("cargo:rustc-link-lib=crypt32");
|
||||||
|
println!("cargo:rustc-link-lib=user32");
|
||||||
|
println!("cargo:rustc-link-lib=ntdll");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+787
@@ -0,0 +1,787 @@
|
|||||||
|
#![doc(html_root_url = "http://alexcrichton.com/ssh2-rs")]
|
||||||
|
#![allow(bad_style)]
|
||||||
|
#![allow(unused_extern_crates)]
|
||||||
|
|
||||||
|
extern crate libc;
|
||||||
|
|
||||||
|
extern crate libz_sys;
|
||||||
|
#[cfg(unix)]
|
||||||
|
extern crate openssl_sys;
|
||||||
|
|
||||||
|
use libc::ssize_t;
|
||||||
|
use libc::{c_char, c_int, c_long, c_uchar, c_uint, c_ulong, c_void, size_t};
|
||||||
|
|
||||||
|
pub const SSH_DISCONNECT_HOST_NOT_ALLOWED_TO_CONNECT: c_int = 1;
|
||||||
|
pub const SSH_DISCONNECT_PROTOCOL_ERROR: c_int = 2;
|
||||||
|
pub const SSH_DISCONNECT_KEY_EXCHANGE_FAILED: c_int = 3;
|
||||||
|
pub const SSH_DISCONNECT_RESERVED: c_int = 4;
|
||||||
|
pub const SSH_DISCONNECT_MAC_ERROR: c_int = 5;
|
||||||
|
pub const SSH_DISCONNECT_COMPRESSION_ERROR: c_int = 6;
|
||||||
|
pub const SSH_DISCONNECT_SERVICE_NOT_AVAILABLE: c_int = 7;
|
||||||
|
pub const SSH_DISCONNECT_PROTOCOL_VERSION_NOT_SUPPORTED: c_int = 8;
|
||||||
|
pub const SSH_DISCONNECT_HOST_KEY_NOT_VERIFIABLE: c_int = 9;
|
||||||
|
pub const SSH_DISCONNECT_CONNECTION_LOST: c_int = 10;
|
||||||
|
pub const SSH_DISCONNECT_BY_APPLICATION: c_int = 11;
|
||||||
|
pub const SSH_DISCONNECT_TOO_MANY_CONNECTIONS: c_int = 12;
|
||||||
|
pub const SSH_DISCONNECT_AUTH_CANCELLED_BY_USER: c_int = 13;
|
||||||
|
pub const SSH_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE: c_int = 14;
|
||||||
|
pub const SSH_DISCONNECT_ILLEGAL_USER_NAME: c_int = 15;
|
||||||
|
|
||||||
|
pub const LIBSSH2_FLAG_SIGPIPE: c_int = 1;
|
||||||
|
pub const LIBSSH2_FLAG_COMPRESS: c_int = 2;
|
||||||
|
|
||||||
|
pub const LIBSSH2_HOSTKEY_TYPE_UNKNOWN: c_int = 0;
|
||||||
|
pub const LIBSSH2_HOSTKEY_TYPE_RSA: c_int = 1;
|
||||||
|
pub const LIBSSH2_HOSTKEY_TYPE_DSS: c_int = 2;
|
||||||
|
pub const LIBSSH2_HOSTKEY_TYPE_ECDSA_256: c_int = 3;
|
||||||
|
pub const LIBSSH2_HOSTKEY_TYPE_ECDSA_384: c_int = 4;
|
||||||
|
pub const LIBSSH2_HOSTKEY_TYPE_ECDSA_521: c_int = 5;
|
||||||
|
pub const LIBSSH2_HOSTKEY_TYPE_ED25519: c_int = 6;
|
||||||
|
|
||||||
|
pub const LIBSSH2_METHOD_KEX: c_int = 0;
|
||||||
|
pub const LIBSSH2_METHOD_HOSTKEY: c_int = 1;
|
||||||
|
pub const LIBSSH2_METHOD_CRYPT_CS: c_int = 2;
|
||||||
|
pub const LIBSSH2_METHOD_CRYPT_SC: c_int = 3;
|
||||||
|
pub const LIBSSH2_METHOD_MAC_CS: c_int = 4;
|
||||||
|
pub const LIBSSH2_METHOD_MAC_SC: c_int = 5;
|
||||||
|
pub const LIBSSH2_METHOD_COMP_CS: c_int = 6;
|
||||||
|
pub const LIBSSH2_METHOD_COMP_SC: c_int = 7;
|
||||||
|
pub const LIBSSH2_METHOD_LANG_CS: c_int = 8;
|
||||||
|
pub const LIBSSH2_METHOD_LANG_SC: c_int = 9;
|
||||||
|
pub const LIBSSH2_METHOD_SIGN_ALGO: c_int = 10;
|
||||||
|
|
||||||
|
pub const LIBSSH2_CHANNEL_PACKET_DEFAULT: c_uint = 32768;
|
||||||
|
pub const LIBSSH2_CHANNEL_WINDOW_DEFAULT: c_uint = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
pub const LIBSSH2_ERROR_BANNER_RECV: c_int = -2;
|
||||||
|
pub const LIBSSH2_ERROR_BANNER_SEND: c_int = -3;
|
||||||
|
pub const LIBSSH2_ERROR_INVALID_MAC: c_int = -4;
|
||||||
|
pub const LIBSSH2_ERROR_KEX_FAILURE: c_int = -5;
|
||||||
|
pub const LIBSSH2_ERROR_ALLOC: c_int = -6;
|
||||||
|
pub const LIBSSH2_ERROR_SOCKET_SEND: c_int = -7;
|
||||||
|
pub const LIBSSH2_ERROR_KEY_EXCHANGE_FAILURE: c_int = -8;
|
||||||
|
pub const LIBSSH2_ERROR_TIMEOUT: c_int = -9;
|
||||||
|
pub const LIBSSH2_ERROR_HOSTKEY_INIT: c_int = -10;
|
||||||
|
pub const LIBSSH2_ERROR_HOSTKEY_SIGN: c_int = -11;
|
||||||
|
pub const LIBSSH2_ERROR_DECRYPT: c_int = -12;
|
||||||
|
pub const LIBSSH2_ERROR_SOCKET_DISCONNECT: c_int = -13;
|
||||||
|
pub const LIBSSH2_ERROR_PROTO: c_int = -14;
|
||||||
|
pub const LIBSSH2_ERROR_PASSWORD_EXPIRED: c_int = -15;
|
||||||
|
pub const LIBSSH2_ERROR_FILE: c_int = -16;
|
||||||
|
pub const LIBSSH2_ERROR_METHOD_NONE: c_int = -17;
|
||||||
|
pub const LIBSSH2_ERROR_AUTHENTICATION_FAILED: c_int = -18;
|
||||||
|
pub const LIBSSH2_ERROR_PUBLICKEY_UNRECOGNIZED: c_int = LIBSSH2_ERROR_AUTHENTICATION_FAILED;
|
||||||
|
pub const LIBSSH2_ERROR_PUBLICKEY_UNVERIFIED: c_int = -19;
|
||||||
|
pub const LIBSSH2_ERROR_CHANNEL_OUTOFORDER: c_int = -20;
|
||||||
|
pub const LIBSSH2_ERROR_CHANNEL_FAILURE: c_int = -21;
|
||||||
|
pub const LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED: c_int = -22;
|
||||||
|
pub const LIBSSH2_ERROR_CHANNEL_UNKNOWN: c_int = -23;
|
||||||
|
pub const LIBSSH2_ERROR_CHANNEL_WINDOW_EXCEEDED: c_int = -24;
|
||||||
|
pub const LIBSSH2_ERROR_CHANNEL_PACKET_EXCEEDED: c_int = -25;
|
||||||
|
pub const LIBSSH2_ERROR_CHANNEL_CLOSED: c_int = -26;
|
||||||
|
pub const LIBSSH2_ERROR_CHANNEL_EOF_SENT: c_int = -27;
|
||||||
|
pub const LIBSSH2_ERROR_SCP_PROTOCOL: c_int = -28;
|
||||||
|
pub const LIBSSH2_ERROR_ZLIB: c_int = -29;
|
||||||
|
pub const LIBSSH2_ERROR_SOCKET_TIMEOUT: c_int = -30;
|
||||||
|
pub const LIBSSH2_ERROR_SFTP_PROTOCOL: c_int = -31;
|
||||||
|
pub const LIBSSH2_ERROR_REQUEST_DENIED: c_int = -32;
|
||||||
|
pub const LIBSSH2_ERROR_METHOD_NOT_SUPPORTED: c_int = -33;
|
||||||
|
pub const LIBSSH2_ERROR_INVAL: c_int = -34;
|
||||||
|
pub const LIBSSH2_ERROR_INVALID_POLL_TYPE: c_int = -35;
|
||||||
|
pub const LIBSSH2_ERROR_PUBLICKEY_PROTOCOL: c_int = -36;
|
||||||
|
pub const LIBSSH2_ERROR_EAGAIN: c_int = -37;
|
||||||
|
pub const LIBSSH2_ERROR_BUFFER_TOO_SMALL: c_int = -38;
|
||||||
|
pub const LIBSSH2_ERROR_BAD_USE: c_int = -39;
|
||||||
|
pub const LIBSSH2_ERROR_COMPRESS: c_int = -40;
|
||||||
|
pub const LIBSSH2_ERROR_OUT_OF_BOUNDARY: c_int = -41;
|
||||||
|
pub const LIBSSH2_ERROR_AGENT_PROTOCOL: c_int = -42;
|
||||||
|
pub const LIBSSH2_ERROR_SOCKET_RECV: c_int = -43;
|
||||||
|
pub const LIBSSH2_ERROR_ENCRYPT: c_int = -44;
|
||||||
|
pub const LIBSSH2_ERROR_BAD_SOCKET: c_int = -45;
|
||||||
|
pub const LIBSSH2_ERROR_KNOWN_HOSTS: c_int = -46;
|
||||||
|
pub const LIBSSH2_ERROR_CHANNEL_WINDOW_FULL: c_int = -47;
|
||||||
|
pub const LIBSSH2_ERROR_KEYFILE_AUTH_FAILED: c_int = -48;
|
||||||
|
pub const LIBSSH2_ERROR_RANDGEN: c_int = -49;
|
||||||
|
pub const LIBSSH2_ERROR_MISSING_USERAUTH_BANNER: c_int = -50;
|
||||||
|
pub const LIBSSH2_ERROR_ALGO_UNSUPPORTED: c_int = -51;
|
||||||
|
|
||||||
|
pub const LIBSSH2_FX_EOF: c_int = 1;
|
||||||
|
pub const LIBSSH2_FX_NO_SUCH_FILE: c_int = 2;
|
||||||
|
pub const LIBSSH2_FX_PERMISSION_DENIED: c_int = 3;
|
||||||
|
pub const LIBSSH2_FX_FAILURE: c_int = 4;
|
||||||
|
pub const LIBSSH2_FX_BAD_MESSAGE: c_int = 5;
|
||||||
|
pub const LIBSSH2_FX_NO_CONNECTION: c_int = 6;
|
||||||
|
pub const LIBSSH2_FX_CONNECTION_LOST: c_int = 7;
|
||||||
|
pub const LIBSSH2_FX_OP_UNSUPPORTED: c_int = 8;
|
||||||
|
pub const LIBSSH2_FX_INVALID_HANDLE: c_int = 9;
|
||||||
|
pub const LIBSSH2_FX_NO_SUCH_PATH: c_int = 10;
|
||||||
|
pub const LIBSSH2_FX_FILE_ALREADY_EXISTS: c_int = 11;
|
||||||
|
pub const LIBSSH2_FX_WRITE_PROTECT: c_int = 12;
|
||||||
|
pub const LIBSSH2_FX_NO_MEDIA: c_int = 13;
|
||||||
|
pub const LIBSSH2_FX_NO_SPACE_ON_FILESYSTEM: c_int = 14;
|
||||||
|
pub const LIBSSH2_FX_QUOTA_EXCEEDED: c_int = 15;
|
||||||
|
pub const LIBSSH2_FX_UNKNOWN_PRINCIPAL: c_int = 16;
|
||||||
|
pub const LIBSSH2_FX_LOCK_CONFLICT: c_int = 17;
|
||||||
|
pub const LIBSSH2_FX_DIR_NOT_EMPTY: c_int = 18;
|
||||||
|
pub const LIBSSH2_FX_NOT_A_DIRECTORY: c_int = 19;
|
||||||
|
pub const LIBSSH2_FX_INVALID_FILENAME: c_int = 20;
|
||||||
|
pub const LIBSSH2_FX_LINK_LOOP: c_int = 21;
|
||||||
|
|
||||||
|
pub const LIBSSH2_HOSTKEY_HASH_MD5: c_int = 1;
|
||||||
|
pub const LIBSSH2_HOSTKEY_HASH_SHA1: c_int = 2;
|
||||||
|
pub const LIBSSH2_HOSTKEY_HASH_SHA256: c_int = 3;
|
||||||
|
|
||||||
|
pub const LIBSSH2_KNOWNHOST_FILE_OPENSSH: c_int = 1;
|
||||||
|
|
||||||
|
pub const LIBSSH2_KNOWNHOST_CHECK_MATCH: c_int = 0;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_CHECK_MISMATCH: c_int = 1;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_CHECK_NOTFOUND: c_int = 2;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_CHECK_FAILURE: c_int = 3;
|
||||||
|
|
||||||
|
pub const LIBSSH2_KNOWNHOST_TYPE_PLAIN: c_int = 1;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_TYPE_SHA1: c_int = 2;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_TYPE_CUSTOM: c_int = 3;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_KEYENC_RAW: c_int = 1 << 16;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_KEYENC_BASE64: c_int = 2 << 16;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_KEY_RSA1: c_int = 1 << 18;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_KEY_SSHRSA: c_int = 2 << 18;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_KEY_SSHDSS: c_int = 3 << 18;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_KEY_ECDSA_256: c_int = 4 << 18;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_KEY_ECDSA_384: c_int = 5 << 18;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_KEY_ECDSA_521: c_int = 6 << 18;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_KEY_ED25519: c_int = 7 << 18;
|
||||||
|
pub const LIBSSH2_KNOWNHOST_KEY_UNKNOWN: c_int = 15 << 18;
|
||||||
|
|
||||||
|
pub const LIBSSH2_FXF_READ: c_ulong = 0x00000001;
|
||||||
|
pub const LIBSSH2_FXF_WRITE: c_ulong = 0x00000002;
|
||||||
|
pub const LIBSSH2_FXF_APPEND: c_ulong = 0x00000004;
|
||||||
|
pub const LIBSSH2_FXF_CREAT: c_ulong = 0x00000008;
|
||||||
|
pub const LIBSSH2_FXF_TRUNC: c_ulong = 0x00000010;
|
||||||
|
pub const LIBSSH2_FXF_EXCL: c_ulong = 0x00000020;
|
||||||
|
|
||||||
|
pub const LIBSSH2_SFTP_OPENFILE: c_int = 0;
|
||||||
|
pub const LIBSSH2_SFTP_OPENDIR: c_int = 1;
|
||||||
|
|
||||||
|
pub const LIBSSH2_SFTP_ATTR_SIZE: c_ulong = 0x00000001;
|
||||||
|
pub const LIBSSH2_SFTP_ATTR_UIDGID: c_ulong = 0x00000002;
|
||||||
|
pub const LIBSSH2_SFTP_ATTR_PERMISSIONS: c_ulong = 0x00000004;
|
||||||
|
pub const LIBSSH2_SFTP_ATTR_ACMODTIME: c_ulong = 0x00000008;
|
||||||
|
pub const LIBSSH2_SFTP_ATTR_EXTENDED: c_ulong = 0x80000000;
|
||||||
|
|
||||||
|
pub const LIBSSH2_SFTP_STAT: c_int = 0;
|
||||||
|
pub const LIBSSH2_SFTP_LSTAT: c_int = 1;
|
||||||
|
pub const LIBSSH2_SFTP_SETSTAT: c_int = 2;
|
||||||
|
|
||||||
|
pub const LIBSSH2_SFTP_SYMLINK: c_int = 0;
|
||||||
|
pub const LIBSSH2_SFTP_READLINK: c_int = 1;
|
||||||
|
pub const LIBSSH2_SFTP_REALPATH: c_int = 2;
|
||||||
|
|
||||||
|
pub const LIBSSH2_SFTP_RENAME_OVERWRITE: c_long = 0x1;
|
||||||
|
pub const LIBSSH2_SFTP_RENAME_ATOMIC: c_long = 0x2;
|
||||||
|
pub const LIBSSH2_SFTP_RENAME_NATIVE: c_long = 0x4;
|
||||||
|
|
||||||
|
pub const LIBSSH2_INIT_NO_CRYPTO: c_int = 0x1;
|
||||||
|
|
||||||
|
pub const LIBSSH2_SFTP_S_IFMT: c_ulong = 0o170000;
|
||||||
|
pub const LIBSSH2_SFTP_S_IFIFO: c_ulong = 0o010000;
|
||||||
|
pub const LIBSSH2_SFTP_S_IFCHR: c_ulong = 0o020000;
|
||||||
|
pub const LIBSSH2_SFTP_S_IFDIR: c_ulong = 0o040000;
|
||||||
|
pub const LIBSSH2_SFTP_S_IFBLK: c_ulong = 0o060000;
|
||||||
|
pub const LIBSSH2_SFTP_S_IFREG: c_ulong = 0o100000;
|
||||||
|
pub const LIBSSH2_SFTP_S_IFLNK: c_ulong = 0o120000;
|
||||||
|
pub const LIBSSH2_SFTP_S_IFSOCK: c_ulong = 0o140000;
|
||||||
|
|
||||||
|
pub const LIBSSH2_CHANNEL_EXTENDED_DATA_NORMAL: c_int = 0;
|
||||||
|
pub const LIBSSH2_CHANNEL_EXTENDED_DATA_IGNORE: c_int = 1;
|
||||||
|
pub const LIBSSH2_CHANNEL_EXTENDED_DATA_MERGE: c_int = 2;
|
||||||
|
|
||||||
|
pub const LIBSSH2_SESSION_BLOCK_INBOUND: c_int = 1;
|
||||||
|
pub const LIBSSH2_SESSION_BLOCK_OUTBOUND: c_int = 2;
|
||||||
|
|
||||||
|
pub const LIBSSH2_TRACE_TRANS : c_int = 1<<1;
|
||||||
|
pub const LIBSSH2_TRACE_KEX : c_int = 1<<2;
|
||||||
|
pub const LIBSSH2_TRACE_AUTH : c_int = 1<<3;
|
||||||
|
pub const LIBSSH2_TRACE_CONN : c_int = 1<<4;
|
||||||
|
pub const LIBSSH2_TRACE_SCP : c_int = 1<<5;
|
||||||
|
pub const LIBSSH2_TRACE_SFTP : c_int = 1<<6;
|
||||||
|
pub const LIBSSH2_TRACE_ERROR : c_int = 1<<7;
|
||||||
|
pub const LIBSSH2_TRACE_PUBLICKEY : c_int = 1<<8;
|
||||||
|
pub const LIBSSH2_TRACE_SOCKET : c_int = 1<<9;
|
||||||
|
pub enum LIBSSH2_SESSION {}
|
||||||
|
pub enum LIBSSH2_AGENT {}
|
||||||
|
pub enum LIBSSH2_CHANNEL {}
|
||||||
|
pub enum LIBSSH2_LISTENER {}
|
||||||
|
pub enum LIBSSH2_KNOWNHOSTS {}
|
||||||
|
pub enum LIBSSH2_SFTP {}
|
||||||
|
pub enum LIBSSH2_SFTP_HANDLE {}
|
||||||
|
|
||||||
|
pub type libssh2_int64_t = i64;
|
||||||
|
pub type libssh2_uint64_t = u64;
|
||||||
|
|
||||||
|
// libssh2_struct_stat is a typedef for libc::stat on all platforms, however,
|
||||||
|
// Windows has a bunch of legacy around struct stat that makes things more
|
||||||
|
// complicated to validate with systest.
|
||||||
|
// The most reasonable looking solution to this is a newtype that derefs
|
||||||
|
// to libc::stat.
|
||||||
|
// We cannot use `pub struct libssh2_struct_stat(pub libc::stat)` because
|
||||||
|
// that triggers a `no tuple structs in FFI` error.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct libssh2_struct_stat(libc::stat);
|
||||||
|
|
||||||
|
impl std::ops::Deref for libssh2_struct_stat {
|
||||||
|
type Target = libc::stat;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct libssh2_agent_publickey {
|
||||||
|
pub magic: c_uint,
|
||||||
|
pub node: *mut c_void,
|
||||||
|
pub blob: *mut c_uchar,
|
||||||
|
pub blob_len: size_t,
|
||||||
|
pub comment: *mut c_char,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct libssh2_knownhost {
|
||||||
|
pub magic: c_uint,
|
||||||
|
pub node: *mut c_void,
|
||||||
|
pub name: *mut c_char,
|
||||||
|
pub key: *mut c_char,
|
||||||
|
pub typemask: c_int,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
pub struct LIBSSH2_SFTP_ATTRIBUTES {
|
||||||
|
pub flags: c_ulong,
|
||||||
|
pub filesize: libssh2_uint64_t,
|
||||||
|
pub uid: c_ulong,
|
||||||
|
pub gid: c_ulong,
|
||||||
|
pub permissions: c_ulong,
|
||||||
|
pub atime: c_ulong,
|
||||||
|
pub mtime: c_ulong,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
pub struct LIBSSH2_SFTP_STATVFS {
|
||||||
|
pub f_bsize: libssh2_uint64_t,
|
||||||
|
pub f_frsize: libssh2_uint64_t,
|
||||||
|
pub f_blocks: libssh2_uint64_t,
|
||||||
|
pub f_bfree: libssh2_uint64_t,
|
||||||
|
pub f_bavail: libssh2_uint64_t,
|
||||||
|
pub f_files: libssh2_uint64_t,
|
||||||
|
pub f_ffree: libssh2_uint64_t,
|
||||||
|
pub f_favail: libssh2_uint64_t,
|
||||||
|
pub f_fsid: libssh2_uint64_t,
|
||||||
|
pub f_flag: libssh2_uint64_t,
|
||||||
|
pub f_namemax: libssh2_uint64_t,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type LIBSSH2_ALLOC_FUNC = extern "C" fn(size_t, *mut *mut c_void) -> *mut c_void;
|
||||||
|
pub type LIBSSH2_FREE_FUNC = extern "C" fn(*mut c_void, *mut *mut c_void);
|
||||||
|
pub type LIBSSH2_REALLOC_FUNC = extern "C" fn(*mut c_void, size_t, *mut *mut c_void) -> *mut c_void;
|
||||||
|
pub type LIBSSH2_PASSWD_CHANGEREQ_FUNC = extern "C" fn(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
newpw: *mut *mut c_char,
|
||||||
|
newpw_len: *mut c_int,
|
||||||
|
abstrakt: *mut *mut c_void,
|
||||||
|
);
|
||||||
|
|
||||||
|
pub type LIBSSH2_USERAUTH_KBDINT_RESPONSE_FUNC = extern "C" fn(
|
||||||
|
username: *const c_char,
|
||||||
|
username_len: c_int,
|
||||||
|
instruction: *const c_char,
|
||||||
|
instruction_len: c_int,
|
||||||
|
num_prompts: c_int,
|
||||||
|
prompts: *const LIBSSH2_USERAUTH_KBDINT_PROMPT,
|
||||||
|
responses: *mut LIBSSH2_USERAUTH_KBDINT_RESPONSE,
|
||||||
|
abstrakt: *mut *mut c_void,
|
||||||
|
);
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct LIBSSH2_USERAUTH_KBDINT_PROMPT {
|
||||||
|
pub text: *mut c_uchar,
|
||||||
|
pub length: size_t,
|
||||||
|
pub echo: c_uchar,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct LIBSSH2_USERAUTH_KBDINT_RESPONSE {
|
||||||
|
pub text: *mut c_char,
|
||||||
|
pub length: c_uint,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub type libssh2_socket_t = c_int;
|
||||||
|
#[cfg(all(windows, target_pointer_width = "32"))]
|
||||||
|
pub type libssh2_socket_t = u32;
|
||||||
|
#[cfg(all(windows, target_pointer_width = "64"))]
|
||||||
|
pub type libssh2_socket_t = u64;
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
// misc
|
||||||
|
pub fn libssh2_init(flag: c_int) -> c_int;
|
||||||
|
pub fn libssh2_exit();
|
||||||
|
pub fn libssh2_free(sess: *mut LIBSSH2_SESSION, ptr: *mut c_void);
|
||||||
|
pub fn libssh2_hostkey_hash(session: *mut LIBSSH2_SESSION, hash_type: c_int) -> *const c_char;
|
||||||
|
pub fn libssh2_trace(session: *mut LIBSSH2_SESSION, bitmask: c_int) -> c_int;
|
||||||
|
|
||||||
|
// session
|
||||||
|
pub fn libssh2_session_init_ex(
|
||||||
|
alloc: Option<LIBSSH2_ALLOC_FUNC>,
|
||||||
|
free: Option<LIBSSH2_FREE_FUNC>,
|
||||||
|
realloc: Option<LIBSSH2_REALLOC_FUNC>,
|
||||||
|
abstrakt: *mut c_void,
|
||||||
|
) -> *mut LIBSSH2_SESSION;
|
||||||
|
pub fn libssh2_session_abstract(session: *mut LIBSSH2_SESSION) -> *mut *mut c_void;
|
||||||
|
pub fn libssh2_session_free(sess: *mut LIBSSH2_SESSION) -> c_int;
|
||||||
|
pub fn libssh2_session_banner_get(sess: *mut LIBSSH2_SESSION) -> *const c_char;
|
||||||
|
pub fn libssh2_session_banner_set(sess: *mut LIBSSH2_SESSION, banner: *const c_char) -> c_int;
|
||||||
|
pub fn libssh2_session_disconnect_ex(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
reason: c_int,
|
||||||
|
description: *const c_char,
|
||||||
|
lang: *const c_char,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_session_flag(sess: *mut LIBSSH2_SESSION, flag: c_int, value: c_int) -> c_int;
|
||||||
|
pub fn libssh2_session_get_blocking(session: *mut LIBSSH2_SESSION) -> c_int;
|
||||||
|
pub fn libssh2_session_get_timeout(sess: *mut LIBSSH2_SESSION) -> c_long;
|
||||||
|
pub fn libssh2_session_hostkey(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
len: *mut size_t,
|
||||||
|
kind: *mut c_int,
|
||||||
|
) -> *const c_char;
|
||||||
|
pub fn libssh2_session_method_pref(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
method_type: c_int,
|
||||||
|
prefs: *const c_char,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_session_methods(sess: *mut LIBSSH2_SESSION, method_type: c_int)
|
||||||
|
-> *const c_char;
|
||||||
|
pub fn libssh2_session_set_blocking(session: *mut LIBSSH2_SESSION, blocking: c_int);
|
||||||
|
pub fn libssh2_session_set_timeout(session: *mut LIBSSH2_SESSION, timeout: c_long);
|
||||||
|
pub fn libssh2_session_supported_algs(
|
||||||
|
session: *mut LIBSSH2_SESSION,
|
||||||
|
method_type: c_int,
|
||||||
|
algs: *mut *mut *const c_char,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_session_last_errno(sess: *mut LIBSSH2_SESSION) -> c_int;
|
||||||
|
pub fn libssh2_session_last_error(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
msg: *mut *mut c_char,
|
||||||
|
len: *mut c_int,
|
||||||
|
want_buf: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_session_handshake(sess: *mut LIBSSH2_SESSION, socket: libssh2_socket_t)
|
||||||
|
-> c_int;
|
||||||
|
pub fn libssh2_keepalive_config(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
want_reply: c_int,
|
||||||
|
interval: c_uint,
|
||||||
|
);
|
||||||
|
pub fn libssh2_keepalive_send(sess: *mut LIBSSH2_SESSION, seconds_to_next: *mut c_int)
|
||||||
|
-> c_int;
|
||||||
|
pub fn libssh2_session_block_directions(sess: *mut LIBSSH2_SESSION) -> c_int;
|
||||||
|
|
||||||
|
// agent
|
||||||
|
pub fn libssh2_agent_init(sess: *mut LIBSSH2_SESSION) -> *mut LIBSSH2_AGENT;
|
||||||
|
pub fn libssh2_agent_free(agent: *mut LIBSSH2_AGENT);
|
||||||
|
pub fn libssh2_agent_connect(agent: *mut LIBSSH2_AGENT) -> c_int;
|
||||||
|
pub fn libssh2_agent_disconnect(agent: *mut LIBSSH2_AGENT) -> c_int;
|
||||||
|
pub fn libssh2_agent_list_identities(agent: *mut LIBSSH2_AGENT) -> c_int;
|
||||||
|
pub fn libssh2_agent_get_identity(
|
||||||
|
agent: *mut LIBSSH2_AGENT,
|
||||||
|
store: *mut *mut libssh2_agent_publickey,
|
||||||
|
prev: *mut libssh2_agent_publickey,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_agent_userauth(
|
||||||
|
agent: *mut LIBSSH2_AGENT,
|
||||||
|
username: *const c_char,
|
||||||
|
identity: *mut libssh2_agent_publickey,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_agent_set_identity_path(agent: *mut LIBSSH2_AGENT, path: *const c_char);
|
||||||
|
pub fn libssh2_agent_get_identity_path(agent: *mut LIBSSH2_AGENT) -> *const c_char;
|
||||||
|
|
||||||
|
// channels
|
||||||
|
pub fn libssh2_channel_free(chan: *mut LIBSSH2_CHANNEL) -> c_int;
|
||||||
|
pub fn libssh2_channel_close(chan: *mut LIBSSH2_CHANNEL) -> c_int;
|
||||||
|
pub fn libssh2_channel_wait_closed(chan: *mut LIBSSH2_CHANNEL) -> c_int;
|
||||||
|
pub fn libssh2_channel_wait_eof(chan: *mut LIBSSH2_CHANNEL) -> c_int;
|
||||||
|
pub fn libssh2_channel_eof(chan: *mut LIBSSH2_CHANNEL) -> c_int;
|
||||||
|
pub fn libssh2_channel_process_startup(
|
||||||
|
chan: *mut LIBSSH2_CHANNEL,
|
||||||
|
req: *const c_char,
|
||||||
|
req_len: c_uint,
|
||||||
|
msg: *const c_char,
|
||||||
|
msg_len: c_uint,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_channel_flush_ex(chan: *mut LIBSSH2_CHANNEL, streamid: c_int) -> c_int;
|
||||||
|
pub fn libssh2_channel_write_ex(
|
||||||
|
chan: *mut LIBSSH2_CHANNEL,
|
||||||
|
stream_id: c_int,
|
||||||
|
buf: *const c_char,
|
||||||
|
buflen: size_t,
|
||||||
|
) -> ssize_t;
|
||||||
|
pub fn libssh2_channel_get_exit_signal(
|
||||||
|
chan: *mut LIBSSH2_CHANNEL,
|
||||||
|
exitsignal: *mut *mut c_char,
|
||||||
|
exitsignal_len: *mut size_t,
|
||||||
|
errmsg: *mut *mut c_char,
|
||||||
|
errmsg_len: *mut size_t,
|
||||||
|
langtag: *mut *mut c_char,
|
||||||
|
langtag_len: *mut size_t,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_channel_get_exit_status(chan: *mut LIBSSH2_CHANNEL) -> c_int;
|
||||||
|
pub fn libssh2_channel_open_ex(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
channel_type: *const c_char,
|
||||||
|
channel_type_len: c_uint,
|
||||||
|
window_size: c_uint,
|
||||||
|
packet_size: c_uint,
|
||||||
|
message: *const c_char,
|
||||||
|
message_len: c_uint,
|
||||||
|
) -> *mut LIBSSH2_CHANNEL;
|
||||||
|
pub fn libssh2_channel_read_ex(
|
||||||
|
chan: *mut LIBSSH2_CHANNEL,
|
||||||
|
stream_id: c_int,
|
||||||
|
buf: *mut c_char,
|
||||||
|
buflen: size_t,
|
||||||
|
) -> ssize_t;
|
||||||
|
pub fn libssh2_channel_setenv_ex(
|
||||||
|
chan: *mut LIBSSH2_CHANNEL,
|
||||||
|
var: *const c_char,
|
||||||
|
varlen: c_uint,
|
||||||
|
val: *const c_char,
|
||||||
|
vallen: c_uint,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_channel_send_eof(chan: *mut LIBSSH2_CHANNEL) -> c_int;
|
||||||
|
pub fn libssh2_channel_request_pty_ex(
|
||||||
|
chan: *mut LIBSSH2_CHANNEL,
|
||||||
|
term: *const c_char,
|
||||||
|
termlen: c_uint,
|
||||||
|
modes: *const c_char,
|
||||||
|
modeslen: c_uint,
|
||||||
|
width: c_int,
|
||||||
|
height: c_int,
|
||||||
|
width_px: c_int,
|
||||||
|
height_px: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_channel_request_pty_size_ex(
|
||||||
|
chan: *mut LIBSSH2_CHANNEL,
|
||||||
|
width: c_int,
|
||||||
|
height: c_int,
|
||||||
|
width_px: c_int,
|
||||||
|
height_px: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_channel_window_read_ex(
|
||||||
|
chan: *mut LIBSSH2_CHANNEL,
|
||||||
|
read_avail: *mut c_ulong,
|
||||||
|
window_size_initial: *mut c_ulong,
|
||||||
|
) -> c_ulong;
|
||||||
|
pub fn libssh2_channel_window_write_ex(
|
||||||
|
chan: *mut LIBSSH2_CHANNEL,
|
||||||
|
window_size_initial: *mut c_ulong,
|
||||||
|
) -> c_ulong;
|
||||||
|
pub fn libssh2_channel_receive_window_adjust2(
|
||||||
|
chan: *mut LIBSSH2_CHANNEL,
|
||||||
|
adjust: c_ulong,
|
||||||
|
force: c_uchar,
|
||||||
|
window: *mut c_uint,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_channel_direct_tcpip_ex(
|
||||||
|
ses: *mut LIBSSH2_SESSION,
|
||||||
|
host: *const c_char,
|
||||||
|
port: c_int,
|
||||||
|
shost: *const c_char,
|
||||||
|
sport: c_int,
|
||||||
|
) -> *mut LIBSSH2_CHANNEL;
|
||||||
|
pub fn libssh2_channel_direct_streamlocal_ex(
|
||||||
|
ses: *mut LIBSSH2_SESSION,
|
||||||
|
socket_path: *const c_char,
|
||||||
|
shost: *const c_char,
|
||||||
|
sport: c_int,
|
||||||
|
) -> *mut LIBSSH2_CHANNEL;
|
||||||
|
pub fn libssh2_channel_forward_accept(listener: *mut LIBSSH2_LISTENER) -> *mut LIBSSH2_CHANNEL;
|
||||||
|
pub fn libssh2_channel_forward_cancel(listener: *mut LIBSSH2_LISTENER) -> c_int;
|
||||||
|
pub fn libssh2_channel_forward_listen_ex(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
host: *const c_char,
|
||||||
|
port: c_int,
|
||||||
|
bound_port: *mut c_int,
|
||||||
|
queue_maxsize: c_int,
|
||||||
|
) -> *mut LIBSSH2_LISTENER;
|
||||||
|
pub fn libssh2_channel_handle_extended_data2(
|
||||||
|
channel: *mut LIBSSH2_CHANNEL,
|
||||||
|
mode: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_channel_request_auth_agent(channel: *mut LIBSSH2_CHANNEL) -> c_int;
|
||||||
|
|
||||||
|
// userauth
|
||||||
|
pub fn libssh2_userauth_banner(sess: *mut LIBSSH2_SESSION, banner: *mut *mut c_char) -> c_int;
|
||||||
|
pub fn libssh2_userauth_authenticated(sess: *mut LIBSSH2_SESSION) -> c_int;
|
||||||
|
pub fn libssh2_userauth_list(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
username: *const c_char,
|
||||||
|
username_len: c_uint,
|
||||||
|
) -> *mut c_char;
|
||||||
|
pub fn libssh2_userauth_hostbased_fromfile_ex(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
username: *const c_char,
|
||||||
|
username_len: c_uint,
|
||||||
|
publickey: *const c_char,
|
||||||
|
privatekey: *const c_char,
|
||||||
|
passphrase: *const c_char,
|
||||||
|
hostname: *const c_char,
|
||||||
|
hostname_len: c_uint,
|
||||||
|
local_username: *const c_char,
|
||||||
|
local_len: c_uint,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_userauth_publickey_fromfile_ex(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
username: *const c_char,
|
||||||
|
username_len: c_uint,
|
||||||
|
publickey: *const c_char,
|
||||||
|
privatekey: *const c_char,
|
||||||
|
passphrase: *const c_char,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_userauth_publickey_frommemory(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
username: *const c_char,
|
||||||
|
username_len: size_t,
|
||||||
|
publickeydata: *const c_char,
|
||||||
|
publickeydata_len: size_t,
|
||||||
|
privatekeydata: *const c_char,
|
||||||
|
privatekeydata_len: size_t,
|
||||||
|
passphrase: *const c_char,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_userauth_password_ex(
|
||||||
|
session: *mut LIBSSH2_SESSION,
|
||||||
|
username: *const c_char,
|
||||||
|
username_len: c_uint,
|
||||||
|
password: *const c_char,
|
||||||
|
password_len: c_uint,
|
||||||
|
password_change_cb: Option<LIBSSH2_PASSWD_CHANGEREQ_FUNC>,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_userauth_keyboard_interactive_ex(
|
||||||
|
session: *mut LIBSSH2_SESSION,
|
||||||
|
username: *const c_char,
|
||||||
|
username_len: c_uint,
|
||||||
|
callback: Option<LIBSSH2_USERAUTH_KBDINT_RESPONSE_FUNC>,
|
||||||
|
) -> c_int;
|
||||||
|
|
||||||
|
// knownhost
|
||||||
|
pub fn libssh2_knownhost_free(hosts: *mut LIBSSH2_KNOWNHOSTS);
|
||||||
|
pub fn libssh2_knownhost_addc(
|
||||||
|
hosts: *mut LIBSSH2_KNOWNHOSTS,
|
||||||
|
host: *const c_char,
|
||||||
|
salt: *const c_char,
|
||||||
|
key: *const c_char,
|
||||||
|
keylen: size_t,
|
||||||
|
comment: *const c_char,
|
||||||
|
commentlen: size_t,
|
||||||
|
typemask: c_int,
|
||||||
|
store: *mut *mut libssh2_knownhost,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_knownhost_check(
|
||||||
|
hosts: *mut LIBSSH2_KNOWNHOSTS,
|
||||||
|
host: *const c_char,
|
||||||
|
key: *const c_char,
|
||||||
|
keylen: size_t,
|
||||||
|
typemask: c_int,
|
||||||
|
knownhost: *mut *mut libssh2_knownhost,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_knownhost_checkp(
|
||||||
|
hosts: *mut LIBSSH2_KNOWNHOSTS,
|
||||||
|
host: *const c_char,
|
||||||
|
port: c_int,
|
||||||
|
key: *const c_char,
|
||||||
|
keylen: size_t,
|
||||||
|
typemask: c_int,
|
||||||
|
knownhost: *mut *mut libssh2_knownhost,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_knownhost_del(
|
||||||
|
hosts: *mut LIBSSH2_KNOWNHOSTS,
|
||||||
|
entry: *mut libssh2_knownhost,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_knownhost_get(
|
||||||
|
hosts: *mut LIBSSH2_KNOWNHOSTS,
|
||||||
|
store: *mut *mut libssh2_knownhost,
|
||||||
|
prev: *mut libssh2_knownhost,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_knownhost_readfile(
|
||||||
|
hosts: *mut LIBSSH2_KNOWNHOSTS,
|
||||||
|
filename: *const c_char,
|
||||||
|
kind: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_knownhost_readline(
|
||||||
|
hosts: *mut LIBSSH2_KNOWNHOSTS,
|
||||||
|
line: *const c_char,
|
||||||
|
len: size_t,
|
||||||
|
kind: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_knownhost_writefile(
|
||||||
|
hosts: *mut LIBSSH2_KNOWNHOSTS,
|
||||||
|
filename: *const c_char,
|
||||||
|
kind: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_knownhost_writeline(
|
||||||
|
hosts: *mut LIBSSH2_KNOWNHOSTS,
|
||||||
|
known: *mut libssh2_knownhost,
|
||||||
|
buffer: *mut c_char,
|
||||||
|
buflen: size_t,
|
||||||
|
outlen: *mut size_t,
|
||||||
|
kind: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_knownhost_init(sess: *mut LIBSSH2_SESSION) -> *mut LIBSSH2_KNOWNHOSTS;
|
||||||
|
|
||||||
|
// scp
|
||||||
|
#[deprecated(note = "dangerously unsafe on windows, use libssh2_scp_recv2 instead")]
|
||||||
|
pub fn libssh2_scp_recv(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
path: *const c_char,
|
||||||
|
sb: *mut libc::stat,
|
||||||
|
) -> *mut LIBSSH2_CHANNEL;
|
||||||
|
|
||||||
|
pub fn libssh2_scp_recv2(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
path: *const c_char,
|
||||||
|
sb: *mut libssh2_struct_stat,
|
||||||
|
) -> *mut LIBSSH2_CHANNEL;
|
||||||
|
|
||||||
|
pub fn libssh2_scp_send64(
|
||||||
|
sess: *mut LIBSSH2_SESSION,
|
||||||
|
path: *const c_char,
|
||||||
|
mode: c_int,
|
||||||
|
size: libssh2_int64_t,
|
||||||
|
mtime: libc::time_t,
|
||||||
|
atime: libc::time_t,
|
||||||
|
) -> *mut LIBSSH2_CHANNEL;
|
||||||
|
|
||||||
|
// sftp
|
||||||
|
pub fn libssh2_sftp_init(sess: *mut LIBSSH2_SESSION) -> *mut LIBSSH2_SFTP;
|
||||||
|
pub fn libssh2_sftp_shutdown(sftp: *mut LIBSSH2_SFTP) -> c_int;
|
||||||
|
pub fn libssh2_sftp_last_error(sftp: *mut LIBSSH2_SFTP) -> c_ulong;
|
||||||
|
pub fn libssh2_sftp_open_ex(
|
||||||
|
sftp: *mut LIBSSH2_SFTP,
|
||||||
|
filename: *const c_char,
|
||||||
|
filename_len: c_uint,
|
||||||
|
flags: c_ulong,
|
||||||
|
mode: c_long,
|
||||||
|
open_type: c_int,
|
||||||
|
) -> *mut LIBSSH2_SFTP_HANDLE;
|
||||||
|
pub fn libssh2_sftp_close_handle(handle: *mut LIBSSH2_SFTP_HANDLE) -> c_int;
|
||||||
|
pub fn libssh2_sftp_mkdir_ex(
|
||||||
|
sftp: *mut LIBSSH2_SFTP,
|
||||||
|
path: *const c_char,
|
||||||
|
path_len: c_uint,
|
||||||
|
mode: c_long,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_sftp_fsync(handle: *mut LIBSSH2_SFTP_HANDLE) -> c_int;
|
||||||
|
pub fn libssh2_sftp_fstat_ex(
|
||||||
|
handle: *mut LIBSSH2_SFTP_HANDLE,
|
||||||
|
attrs: *mut LIBSSH2_SFTP_ATTRIBUTES,
|
||||||
|
setstat: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_sftp_fstatvfs(
|
||||||
|
handle: *mut LIBSSH2_SFTP_HANDLE,
|
||||||
|
attrs: *mut LIBSSH2_SFTP_STATVFS,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_sftp_stat_ex(
|
||||||
|
sftp: *mut LIBSSH2_SFTP,
|
||||||
|
path: *const c_char,
|
||||||
|
path_len: c_uint,
|
||||||
|
stat_type: c_int,
|
||||||
|
attrs: *mut LIBSSH2_SFTP_ATTRIBUTES,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_sftp_read(
|
||||||
|
handle: *mut LIBSSH2_SFTP_HANDLE,
|
||||||
|
buf: *mut c_char,
|
||||||
|
len: size_t,
|
||||||
|
) -> ssize_t;
|
||||||
|
pub fn libssh2_sftp_symlink_ex(
|
||||||
|
sftp: *mut LIBSSH2_SFTP,
|
||||||
|
path: *const c_char,
|
||||||
|
path_len: c_uint,
|
||||||
|
target: *mut c_char,
|
||||||
|
target_len: c_uint,
|
||||||
|
link_type: c_int,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_sftp_rename_ex(
|
||||||
|
sftp: *mut LIBSSH2_SFTP,
|
||||||
|
src: *const c_char,
|
||||||
|
src_len: c_uint,
|
||||||
|
dst: *const c_char,
|
||||||
|
dst_len: c_uint,
|
||||||
|
flags: c_long,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_sftp_rmdir_ex(
|
||||||
|
sftp: *mut LIBSSH2_SFTP,
|
||||||
|
path: *const c_char,
|
||||||
|
path_len: c_uint,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_sftp_write(
|
||||||
|
handle: *mut LIBSSH2_SFTP_HANDLE,
|
||||||
|
buffer: *const c_char,
|
||||||
|
len: size_t,
|
||||||
|
) -> ssize_t;
|
||||||
|
pub fn libssh2_sftp_tell64(handle: *mut LIBSSH2_SFTP_HANDLE) -> libssh2_uint64_t;
|
||||||
|
pub fn libssh2_sftp_seek64(handle: *mut LIBSSH2_SFTP_HANDLE, off: libssh2_uint64_t);
|
||||||
|
pub fn libssh2_sftp_readdir_ex(
|
||||||
|
handle: *mut LIBSSH2_SFTP_HANDLE,
|
||||||
|
buffer: *mut c_char,
|
||||||
|
buffer_len: size_t,
|
||||||
|
longentry: *mut c_char,
|
||||||
|
longentry_len: size_t,
|
||||||
|
attrs: *mut LIBSSH2_SFTP_ATTRIBUTES,
|
||||||
|
) -> c_int;
|
||||||
|
pub fn libssh2_sftp_unlink_ex(
|
||||||
|
sftp: *mut LIBSSH2_SFTP,
|
||||||
|
filename: *const c_char,
|
||||||
|
filename_len: c_uint,
|
||||||
|
) -> c_int;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smoke() {
|
||||||
|
unsafe { libssh2_init(0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn issue_14344_workaround() {}
|
||||||
|
|
||||||
|
pub fn init() {
|
||||||
|
use std::sync::Once;
|
||||||
|
|
||||||
|
static INIT: Once = Once::new();
|
||||||
|
INIT.call_once(|| unsafe {
|
||||||
|
platform_init();
|
||||||
|
assert_eq!(libc::atexit(shutdown), 0);
|
||||||
|
});
|
||||||
|
extern "C" fn shutdown() {
|
||||||
|
unsafe {
|
||||||
|
libssh2_exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe fn platform_init() {
|
||||||
|
// On Unix we want to funnel through openssl_sys to initialize OpenSSL,
|
||||||
|
// so be sure to tell libssh2 to not do its own thing as we've already
|
||||||
|
// taken care of it.
|
||||||
|
openssl_sys::init();
|
||||||
|
assert_eq!(libssh2_init(LIBSSH2_INIT_NO_CRYPTO), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe fn platform_init() {
|
||||||
|
// On Windows we want to be sure to tell libssh2 to initialize
|
||||||
|
// everything, as we're not managing crypto elsewhere ourselves. Also to
|
||||||
|
// fix alexcrichton/git2-rs#202
|
||||||
|
assert_eq!(libssh2_init(0), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+43
@@ -0,0 +1,43 @@
|
|||||||
|
/* Copyright (C) 2004-2007 Sara Golemon <sarag@libssh2.org>
|
||||||
|
* Copyright (C) 2005,2006 Mikhail Gusarov <dottedmag@dottedmag.net>
|
||||||
|
* Copyright (C) 2006-2007 The Written Word, Inc.
|
||||||
|
* Copyright (C) 2007 Eli Fant <elifantu@mail.ru>
|
||||||
|
* Copyright (C) 2009-2023 Daniel Stenberg
|
||||||
|
* Copyright (C) 2008, 2009 Simon Josefsson
|
||||||
|
* Copyright (C) 2000 Markus Friedl
|
||||||
|
* Copyright (C) 2015 Microsoft Corp.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Copyright <YEAR> <COPYRIGHT HOLDER>
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Copyright (c) <year> <owner>.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Copyright Free Software Foundation, Inc.
|
||||||
|
#
|
||||||
|
# This file is free software; the Free Software Foundation gives
|
||||||
|
# unlimited permission to copy and/or distribute it, with or without
|
||||||
|
# modifications, as long as this notice is preserved.
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) [year] [fullname]
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
SAX IS FREE -----------
|
||||||
|
|
||||||
|
I hereby abandon any property rights to SAX 2.0 (the Simple API for XML), and release all of the SAX 2.0 source code, compiled code, and documentation contained in this distribution into the Public Domain. SAX comes with NO WARRANTY or guarantee of fitness for any purpose.
|
||||||
|
|
||||||
|
David Megginson, Megginson Technologies Ltd.
|
||||||
|
|
||||||
|
2000-05-05
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
COPYRIGHT AND PERMISSION NOTICE
|
||||||
|
|
||||||
|
Copyright (C) Daniel Stenberg, <daniel@haxx.se>, and many contributors.
|
||||||
|
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and distribute this software for any purpose
|
||||||
|
with or without fee is hereby granted, provided that the above copyright
|
||||||
|
notice and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN
|
||||||
|
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||||
|
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||||
|
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||||
|
OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
Except as contained in this notice, the name of a copyright holder shall not
|
||||||
|
be used in advertising or otherwise to promote the sale, use or other dealings
|
||||||
|
in this Software without prior written authorization of the copyright holder.
|
||||||
+1516
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,128 @@
|
|||||||
|
/* Copyright (C) Sara Golemon <sarag@libssh2.org>
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Note: This include file is only needed for using the
|
||||||
|
* publickey SUBSYSTEM which is not the same as publickey
|
||||||
|
* authentication. For authentication you only need libssh2.h
|
||||||
|
*
|
||||||
|
* For more information on the publickey subsystem,
|
||||||
|
* refer to IETF draft: secsh-publickey
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef LIBSSH2_PUBLICKEY_H
|
||||||
|
#define LIBSSH2_PUBLICKEY_H 1
|
||||||
|
|
||||||
|
#include "libssh2.h"
|
||||||
|
|
||||||
|
typedef struct _LIBSSH2_PUBLICKEY LIBSSH2_PUBLICKEY;
|
||||||
|
|
||||||
|
typedef struct _libssh2_publickey_attribute {
|
||||||
|
const char *name;
|
||||||
|
unsigned long name_len;
|
||||||
|
const char *value;
|
||||||
|
unsigned long value_len;
|
||||||
|
char mandatory;
|
||||||
|
} libssh2_publickey_attribute;
|
||||||
|
|
||||||
|
typedef struct _libssh2_publickey_list {
|
||||||
|
unsigned char *packet; /* For freeing */
|
||||||
|
|
||||||
|
const unsigned char *name;
|
||||||
|
unsigned long name_len;
|
||||||
|
const unsigned char *blob;
|
||||||
|
unsigned long blob_len;
|
||||||
|
unsigned long num_attrs;
|
||||||
|
libssh2_publickey_attribute *attrs; /* free me */
|
||||||
|
} libssh2_publickey_list;
|
||||||
|
|
||||||
|
/* Generally use the first macro here, but if both name and value are string
|
||||||
|
literals, you can use _fast() to take advantage of preprocessing */
|
||||||
|
#define libssh2_publickey_attribute(name, value, mandatory) \
|
||||||
|
{ (name), strlen(name), (value), strlen(value), (mandatory) },
|
||||||
|
#define libssh2_publickey_attribute_fast(name, value, mandatory) \
|
||||||
|
{ (name), sizeof(name) - 1, (value), sizeof(value) - 1, (mandatory) },
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Publickey Subsystem */
|
||||||
|
LIBSSH2_API LIBSSH2_PUBLICKEY *
|
||||||
|
libssh2_publickey_init(LIBSSH2_SESSION *session);
|
||||||
|
|
||||||
|
LIBSSH2_API int
|
||||||
|
libssh2_publickey_add_ex(LIBSSH2_PUBLICKEY *pkey,
|
||||||
|
const unsigned char *name,
|
||||||
|
unsigned long name_len,
|
||||||
|
const unsigned char *blob,
|
||||||
|
unsigned long blob_len, char overwrite,
|
||||||
|
unsigned long num_attrs,
|
||||||
|
const libssh2_publickey_attribute attrs[]);
|
||||||
|
#define libssh2_publickey_add(pkey, name, blob, blob_len, overwrite, \
|
||||||
|
num_attrs, attrs) \
|
||||||
|
libssh2_publickey_add_ex((pkey), \
|
||||||
|
(name), strlen(name), \
|
||||||
|
(blob), (blob_len), \
|
||||||
|
(overwrite), (num_attrs), (attrs))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_publickey_remove_ex(LIBSSH2_PUBLICKEY *pkey,
|
||||||
|
const unsigned char *name,
|
||||||
|
unsigned long name_len,
|
||||||
|
const unsigned char *blob,
|
||||||
|
unsigned long blob_len);
|
||||||
|
#define libssh2_publickey_remove(pkey, name, blob, blob_len) \
|
||||||
|
libssh2_publickey_remove_ex((pkey), \
|
||||||
|
(name), strlen(name), \
|
||||||
|
(blob), (blob_len))
|
||||||
|
|
||||||
|
LIBSSH2_API int
|
||||||
|
libssh2_publickey_list_fetch(LIBSSH2_PUBLICKEY *pkey,
|
||||||
|
unsigned long *num_keys,
|
||||||
|
libssh2_publickey_list **pkey_list);
|
||||||
|
LIBSSH2_API void
|
||||||
|
libssh2_publickey_list_free(LIBSSH2_PUBLICKEY *pkey,
|
||||||
|
libssh2_publickey_list *pkey_list);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_publickey_shutdown(LIBSSH2_PUBLICKEY *pkey);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} /* extern "C" */
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* LIBSSH2_PUBLICKEY_H */
|
||||||
+382
@@ -0,0 +1,382 @@
|
|||||||
|
/* Copyright (C) Sara Golemon <sarag@libssh2.org>
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef LIBSSH2_SFTP_H
|
||||||
|
#define LIBSSH2_SFTP_H 1
|
||||||
|
|
||||||
|
#include "libssh2.h"
|
||||||
|
|
||||||
|
#ifndef _WIN32
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Note: Version 6 was documented at the time of writing
|
||||||
|
* However it was marked as "DO NOT IMPLEMENT" due to pending changes
|
||||||
|
*
|
||||||
|
* Let's start with Version 3 (The version found in OpenSSH) and go from there
|
||||||
|
*/
|
||||||
|
#define LIBSSH2_SFTP_VERSION 3
|
||||||
|
|
||||||
|
typedef struct _LIBSSH2_SFTP LIBSSH2_SFTP;
|
||||||
|
typedef struct _LIBSSH2_SFTP_HANDLE LIBSSH2_SFTP_HANDLE;
|
||||||
|
typedef struct _LIBSSH2_SFTP_ATTRIBUTES LIBSSH2_SFTP_ATTRIBUTES;
|
||||||
|
typedef struct _LIBSSH2_SFTP_STATVFS LIBSSH2_SFTP_STATVFS;
|
||||||
|
|
||||||
|
/* Flags for open_ex() */
|
||||||
|
#define LIBSSH2_SFTP_OPENFILE 0
|
||||||
|
#define LIBSSH2_SFTP_OPENDIR 1
|
||||||
|
|
||||||
|
/* Flags for rename_ex() */
|
||||||
|
#define LIBSSH2_SFTP_RENAME_OVERWRITE 0x00000001
|
||||||
|
#define LIBSSH2_SFTP_RENAME_ATOMIC 0x00000002
|
||||||
|
#define LIBSSH2_SFTP_RENAME_NATIVE 0x00000004
|
||||||
|
|
||||||
|
/* Flags for stat_ex() */
|
||||||
|
#define LIBSSH2_SFTP_STAT 0
|
||||||
|
#define LIBSSH2_SFTP_LSTAT 1
|
||||||
|
#define LIBSSH2_SFTP_SETSTAT 2
|
||||||
|
|
||||||
|
/* Flags for symlink_ex() */
|
||||||
|
#define LIBSSH2_SFTP_SYMLINK 0
|
||||||
|
#define LIBSSH2_SFTP_READLINK 1
|
||||||
|
#define LIBSSH2_SFTP_REALPATH 2
|
||||||
|
|
||||||
|
/* Flags for sftp_mkdir() */
|
||||||
|
#define LIBSSH2_SFTP_DEFAULT_MODE -1
|
||||||
|
|
||||||
|
/* SFTP attribute flag bits */
|
||||||
|
#define LIBSSH2_SFTP_ATTR_SIZE 0x00000001
|
||||||
|
#define LIBSSH2_SFTP_ATTR_UIDGID 0x00000002
|
||||||
|
#define LIBSSH2_SFTP_ATTR_PERMISSIONS 0x00000004
|
||||||
|
#define LIBSSH2_SFTP_ATTR_ACMODTIME 0x00000008
|
||||||
|
#define LIBSSH2_SFTP_ATTR_EXTENDED 0x80000000
|
||||||
|
|
||||||
|
/* SFTP statvfs flag bits */
|
||||||
|
#define LIBSSH2_SFTP_ST_RDONLY 0x00000001
|
||||||
|
#define LIBSSH2_SFTP_ST_NOSUID 0x00000002
|
||||||
|
|
||||||
|
struct _LIBSSH2_SFTP_ATTRIBUTES {
|
||||||
|
/* If flags & ATTR_* bit is set, then the value in this struct will be
|
||||||
|
* meaningful Otherwise it should be ignored
|
||||||
|
*/
|
||||||
|
unsigned long flags;
|
||||||
|
|
||||||
|
libssh2_uint64_t filesize;
|
||||||
|
unsigned long uid, gid;
|
||||||
|
unsigned long permissions;
|
||||||
|
unsigned long atime, mtime;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct _LIBSSH2_SFTP_STATVFS {
|
||||||
|
libssh2_uint64_t f_bsize; /* file system block size */
|
||||||
|
libssh2_uint64_t f_frsize; /* fragment size */
|
||||||
|
libssh2_uint64_t f_blocks; /* size of fs in f_frsize units */
|
||||||
|
libssh2_uint64_t f_bfree; /* # free blocks */
|
||||||
|
libssh2_uint64_t f_bavail; /* # free blocks for non-root */
|
||||||
|
libssh2_uint64_t f_files; /* # inodes */
|
||||||
|
libssh2_uint64_t f_ffree; /* # free inodes */
|
||||||
|
libssh2_uint64_t f_favail; /* # free inodes for non-root */
|
||||||
|
libssh2_uint64_t f_fsid; /* file system ID */
|
||||||
|
libssh2_uint64_t f_flag; /* mount flags */
|
||||||
|
libssh2_uint64_t f_namemax; /* maximum filename length */
|
||||||
|
};
|
||||||
|
|
||||||
|
/* SFTP filetypes */
|
||||||
|
#define LIBSSH2_SFTP_TYPE_REGULAR 1
|
||||||
|
#define LIBSSH2_SFTP_TYPE_DIRECTORY 2
|
||||||
|
#define LIBSSH2_SFTP_TYPE_SYMLINK 3
|
||||||
|
#define LIBSSH2_SFTP_TYPE_SPECIAL 4
|
||||||
|
#define LIBSSH2_SFTP_TYPE_UNKNOWN 5
|
||||||
|
#define LIBSSH2_SFTP_TYPE_SOCKET 6
|
||||||
|
#define LIBSSH2_SFTP_TYPE_CHAR_DEVICE 7
|
||||||
|
#define LIBSSH2_SFTP_TYPE_BLOCK_DEVICE 8
|
||||||
|
#define LIBSSH2_SFTP_TYPE_FIFO 9
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Reproduce the POSIX file modes here for systems that are not POSIX
|
||||||
|
* compliant.
|
||||||
|
*
|
||||||
|
* These is used in "permissions" of "struct _LIBSSH2_SFTP_ATTRIBUTES"
|
||||||
|
*/
|
||||||
|
/* File type */
|
||||||
|
#define LIBSSH2_SFTP_S_IFMT 0170000 /* type of file mask */
|
||||||
|
#define LIBSSH2_SFTP_S_IFIFO 0010000 /* named pipe (fifo) */
|
||||||
|
#define LIBSSH2_SFTP_S_IFCHR 0020000 /* character special */
|
||||||
|
#define LIBSSH2_SFTP_S_IFDIR 0040000 /* directory */
|
||||||
|
#define LIBSSH2_SFTP_S_IFBLK 0060000 /* block special */
|
||||||
|
#define LIBSSH2_SFTP_S_IFREG 0100000 /* regular */
|
||||||
|
#define LIBSSH2_SFTP_S_IFLNK 0120000 /* symbolic link */
|
||||||
|
#define LIBSSH2_SFTP_S_IFSOCK 0140000 /* socket */
|
||||||
|
|
||||||
|
/* File mode */
|
||||||
|
/* Read, write, execute/search by owner */
|
||||||
|
#define LIBSSH2_SFTP_S_IRWXU 0000700 /* RWX mask for owner */
|
||||||
|
#define LIBSSH2_SFTP_S_IRUSR 0000400 /* R for owner */
|
||||||
|
#define LIBSSH2_SFTP_S_IWUSR 0000200 /* W for owner */
|
||||||
|
#define LIBSSH2_SFTP_S_IXUSR 0000100 /* X for owner */
|
||||||
|
/* Read, write, execute/search by group */
|
||||||
|
#define LIBSSH2_SFTP_S_IRWXG 0000070 /* RWX mask for group */
|
||||||
|
#define LIBSSH2_SFTP_S_IRGRP 0000040 /* R for group */
|
||||||
|
#define LIBSSH2_SFTP_S_IWGRP 0000020 /* W for group */
|
||||||
|
#define LIBSSH2_SFTP_S_IXGRP 0000010 /* X for group */
|
||||||
|
/* Read, write, execute/search by others */
|
||||||
|
#define LIBSSH2_SFTP_S_IRWXO 0000007 /* RWX mask for other */
|
||||||
|
#define LIBSSH2_SFTP_S_IROTH 0000004 /* R for other */
|
||||||
|
#define LIBSSH2_SFTP_S_IWOTH 0000002 /* W for other */
|
||||||
|
#define LIBSSH2_SFTP_S_IXOTH 0000001 /* X for other */
|
||||||
|
|
||||||
|
/* macros to check for specific file types, added in 1.2.5 */
|
||||||
|
#define LIBSSH2_SFTP_S_ISLNK(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFLNK)
|
||||||
|
#define LIBSSH2_SFTP_S_ISREG(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFREG)
|
||||||
|
#define LIBSSH2_SFTP_S_ISDIR(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFDIR)
|
||||||
|
#define LIBSSH2_SFTP_S_ISCHR(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFCHR)
|
||||||
|
#define LIBSSH2_SFTP_S_ISBLK(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFBLK)
|
||||||
|
#define LIBSSH2_SFTP_S_ISFIFO(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFIFO)
|
||||||
|
#define LIBSSH2_SFTP_S_ISSOCK(m) \
|
||||||
|
(((m) & LIBSSH2_SFTP_S_IFMT) == LIBSSH2_SFTP_S_IFSOCK)
|
||||||
|
|
||||||
|
/* SFTP File Transfer Flags -- (e.g. flags parameter to sftp_open())
|
||||||
|
* Danger will robinson... APPEND doesn't have any effect on OpenSSH servers */
|
||||||
|
#define LIBSSH2_FXF_READ 0x00000001
|
||||||
|
#define LIBSSH2_FXF_WRITE 0x00000002
|
||||||
|
#define LIBSSH2_FXF_APPEND 0x00000004
|
||||||
|
#define LIBSSH2_FXF_CREAT 0x00000008
|
||||||
|
#define LIBSSH2_FXF_TRUNC 0x00000010
|
||||||
|
#define LIBSSH2_FXF_EXCL 0x00000020
|
||||||
|
|
||||||
|
/* SFTP Status Codes (returned by libssh2_sftp_last_error() ) */
|
||||||
|
#define LIBSSH2_FX_OK 0UL
|
||||||
|
#define LIBSSH2_FX_EOF 1UL
|
||||||
|
#define LIBSSH2_FX_NO_SUCH_FILE 2UL
|
||||||
|
#define LIBSSH2_FX_PERMISSION_DENIED 3UL
|
||||||
|
#define LIBSSH2_FX_FAILURE 4UL
|
||||||
|
#define LIBSSH2_FX_BAD_MESSAGE 5UL
|
||||||
|
#define LIBSSH2_FX_NO_CONNECTION 6UL
|
||||||
|
#define LIBSSH2_FX_CONNECTION_LOST 7UL
|
||||||
|
#define LIBSSH2_FX_OP_UNSUPPORTED 8UL
|
||||||
|
#define LIBSSH2_FX_INVALID_HANDLE 9UL
|
||||||
|
#define LIBSSH2_FX_NO_SUCH_PATH 10UL
|
||||||
|
#define LIBSSH2_FX_FILE_ALREADY_EXISTS 11UL
|
||||||
|
#define LIBSSH2_FX_WRITE_PROTECT 12UL
|
||||||
|
#define LIBSSH2_FX_NO_MEDIA 13UL
|
||||||
|
#define LIBSSH2_FX_NO_SPACE_ON_FILESYSTEM 14UL
|
||||||
|
#define LIBSSH2_FX_QUOTA_EXCEEDED 15UL
|
||||||
|
#define LIBSSH2_FX_UNKNOWN_PRINCIPLE 16UL /* Initial mis-spelling */
|
||||||
|
#define LIBSSH2_FX_UNKNOWN_PRINCIPAL 16UL
|
||||||
|
#define LIBSSH2_FX_LOCK_CONFlICT 17UL /* Initial mis-spelling */
|
||||||
|
#define LIBSSH2_FX_LOCK_CONFLICT 17UL
|
||||||
|
#define LIBSSH2_FX_DIR_NOT_EMPTY 18UL
|
||||||
|
#define LIBSSH2_FX_NOT_A_DIRECTORY 19UL
|
||||||
|
#define LIBSSH2_FX_INVALID_FILENAME 20UL
|
||||||
|
#define LIBSSH2_FX_LINK_LOOP 21UL
|
||||||
|
|
||||||
|
/* Returned by any function that would block during a read/write operation */
|
||||||
|
#define LIBSSH2SFTP_EAGAIN LIBSSH2_ERROR_EAGAIN
|
||||||
|
|
||||||
|
/* SFTP API */
|
||||||
|
LIBSSH2_API LIBSSH2_SFTP *libssh2_sftp_init(LIBSSH2_SESSION *session);
|
||||||
|
LIBSSH2_API int libssh2_sftp_shutdown(LIBSSH2_SFTP *sftp);
|
||||||
|
LIBSSH2_API unsigned long libssh2_sftp_last_error(LIBSSH2_SFTP *sftp);
|
||||||
|
LIBSSH2_API LIBSSH2_CHANNEL *libssh2_sftp_get_channel(LIBSSH2_SFTP *sftp);
|
||||||
|
|
||||||
|
/* File / Directory Ops */
|
||||||
|
LIBSSH2_API LIBSSH2_SFTP_HANDLE *
|
||||||
|
libssh2_sftp_open_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *filename,
|
||||||
|
unsigned int filename_len,
|
||||||
|
unsigned long flags,
|
||||||
|
long mode, int open_type);
|
||||||
|
#define libssh2_sftp_open(sftp, filename, flags, mode) \
|
||||||
|
libssh2_sftp_open_ex((sftp), \
|
||||||
|
(filename), (unsigned int)strlen(filename), \
|
||||||
|
(flags), (mode), LIBSSH2_SFTP_OPENFILE)
|
||||||
|
#define libssh2_sftp_opendir(sftp, path) \
|
||||||
|
libssh2_sftp_open_ex((sftp), \
|
||||||
|
(path), (unsigned int)strlen(path), \
|
||||||
|
0, 0, LIBSSH2_SFTP_OPENDIR)
|
||||||
|
LIBSSH2_API LIBSSH2_SFTP_HANDLE *
|
||||||
|
libssh2_sftp_open_ex_r(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *filename,
|
||||||
|
size_t filename_len,
|
||||||
|
unsigned long flags,
|
||||||
|
long mode, int open_type,
|
||||||
|
LIBSSH2_SFTP_ATTRIBUTES *attrs);
|
||||||
|
#define libssh2_sftp_open_r(sftp, filename, flags, mode, attrs) \
|
||||||
|
libssh2_sftp_open_ex_r((sftp), (filename), strlen(filename), \
|
||||||
|
(flags), (mode), LIBSSH2_SFTP_OPENFILE, \
|
||||||
|
(attrs))
|
||||||
|
|
||||||
|
LIBSSH2_API ssize_t libssh2_sftp_read(LIBSSH2_SFTP_HANDLE *handle,
|
||||||
|
char *buffer, size_t buffer_maxlen);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_readdir_ex(LIBSSH2_SFTP_HANDLE *handle, \
|
||||||
|
char *buffer, size_t buffer_maxlen,
|
||||||
|
char *longentry,
|
||||||
|
size_t longentry_maxlen,
|
||||||
|
LIBSSH2_SFTP_ATTRIBUTES *attrs);
|
||||||
|
#define libssh2_sftp_readdir(handle, buffer, buffer_maxlen, attrs) \
|
||||||
|
libssh2_sftp_readdir_ex((handle), (buffer), (buffer_maxlen), NULL, 0, \
|
||||||
|
(attrs))
|
||||||
|
|
||||||
|
LIBSSH2_API ssize_t libssh2_sftp_write(LIBSSH2_SFTP_HANDLE *handle,
|
||||||
|
const char *buffer, size_t count);
|
||||||
|
LIBSSH2_API int libssh2_sftp_fsync(LIBSSH2_SFTP_HANDLE *handle);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_close_handle(LIBSSH2_SFTP_HANDLE *handle);
|
||||||
|
#define libssh2_sftp_close(handle) libssh2_sftp_close_handle(handle)
|
||||||
|
#define libssh2_sftp_closedir(handle) libssh2_sftp_close_handle(handle)
|
||||||
|
|
||||||
|
LIBSSH2_API void libssh2_sftp_seek(LIBSSH2_SFTP_HANDLE *handle, size_t offset);
|
||||||
|
LIBSSH2_API void libssh2_sftp_seek64(LIBSSH2_SFTP_HANDLE *handle,
|
||||||
|
libssh2_uint64_t offset);
|
||||||
|
#define libssh2_sftp_rewind(handle) libssh2_sftp_seek64((handle), 0)
|
||||||
|
|
||||||
|
LIBSSH2_API size_t libssh2_sftp_tell(LIBSSH2_SFTP_HANDLE *handle);
|
||||||
|
LIBSSH2_API libssh2_uint64_t libssh2_sftp_tell64(LIBSSH2_SFTP_HANDLE *handle);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_fstat_ex(LIBSSH2_SFTP_HANDLE *handle,
|
||||||
|
LIBSSH2_SFTP_ATTRIBUTES *attrs,
|
||||||
|
int setstat);
|
||||||
|
#define libssh2_sftp_fstat(handle, attrs) \
|
||||||
|
libssh2_sftp_fstat_ex((handle), (attrs), 0)
|
||||||
|
#define libssh2_sftp_fsetstat(handle, attrs) \
|
||||||
|
libssh2_sftp_fstat_ex((handle), (attrs), 1)
|
||||||
|
|
||||||
|
/* Miscellaneous Ops */
|
||||||
|
LIBSSH2_API int libssh2_sftp_rename_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *source_filename,
|
||||||
|
unsigned int srouce_filename_len,
|
||||||
|
const char *dest_filename,
|
||||||
|
unsigned int dest_filename_len,
|
||||||
|
long flags);
|
||||||
|
#define libssh2_sftp_rename(sftp, sourcefile, destfile) \
|
||||||
|
libssh2_sftp_rename_ex((sftp), \
|
||||||
|
(sourcefile), (unsigned int)strlen(sourcefile), \
|
||||||
|
(destfile), (unsigned int)strlen(destfile), \
|
||||||
|
LIBSSH2_SFTP_RENAME_OVERWRITE | \
|
||||||
|
LIBSSH2_SFTP_RENAME_ATOMIC | \
|
||||||
|
LIBSSH2_SFTP_RENAME_NATIVE)
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_posix_rename_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *source_filename,
|
||||||
|
size_t srouce_filename_len,
|
||||||
|
const char *dest_filename,
|
||||||
|
size_t dest_filename_len);
|
||||||
|
#define libssh2_sftp_posix_rename(sftp, sourcefile, destfile) \
|
||||||
|
libssh2_sftp_posix_rename_ex((sftp), (sourcefile), strlen(sourcefile), \
|
||||||
|
(destfile), strlen(destfile))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_unlink_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *filename,
|
||||||
|
unsigned int filename_len);
|
||||||
|
#define libssh2_sftp_unlink(sftp, filename) \
|
||||||
|
libssh2_sftp_unlink_ex((sftp), (filename), (unsigned int)strlen(filename))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_fstatvfs(LIBSSH2_SFTP_HANDLE *handle,
|
||||||
|
LIBSSH2_SFTP_STATVFS *st);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_statvfs(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *path,
|
||||||
|
size_t path_len,
|
||||||
|
LIBSSH2_SFTP_STATVFS *st);
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_mkdir_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *path,
|
||||||
|
unsigned int path_len, long mode);
|
||||||
|
#define libssh2_sftp_mkdir(sftp, path, mode) \
|
||||||
|
libssh2_sftp_mkdir_ex((sftp), (path), (unsigned int)strlen(path), (mode))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_rmdir_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *path,
|
||||||
|
unsigned int path_len);
|
||||||
|
#define libssh2_sftp_rmdir(sftp, path) \
|
||||||
|
libssh2_sftp_rmdir_ex((sftp), (path), (unsigned int)strlen(path))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_stat_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *path,
|
||||||
|
unsigned int path_len,
|
||||||
|
int stat_type,
|
||||||
|
LIBSSH2_SFTP_ATTRIBUTES *attrs);
|
||||||
|
#define libssh2_sftp_stat(sftp, path, attrs) \
|
||||||
|
libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \
|
||||||
|
LIBSSH2_SFTP_STAT, (attrs))
|
||||||
|
#define libssh2_sftp_lstat(sftp, path, attrs) \
|
||||||
|
libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \
|
||||||
|
LIBSSH2_SFTP_LSTAT, (attrs))
|
||||||
|
#define libssh2_sftp_setstat(sftp, path, attrs) \
|
||||||
|
libssh2_sftp_stat_ex((sftp), (path), (unsigned int)strlen(path), \
|
||||||
|
LIBSSH2_SFTP_SETSTAT, (attrs))
|
||||||
|
|
||||||
|
LIBSSH2_API int libssh2_sftp_symlink_ex(LIBSSH2_SFTP *sftp,
|
||||||
|
const char *path,
|
||||||
|
unsigned int path_len,
|
||||||
|
char *target,
|
||||||
|
unsigned int target_len,
|
||||||
|
int link_type);
|
||||||
|
#define libssh2_sftp_symlink(sftp, orig, linkpath) \
|
||||||
|
libssh2_sftp_symlink_ex((sftp), \
|
||||||
|
(orig), (unsigned int)strlen(orig), \
|
||||||
|
(linkpath), (unsigned int)strlen(linkpath), \
|
||||||
|
LIBSSH2_SFTP_SYMLINK)
|
||||||
|
#define libssh2_sftp_readlink(sftp, path, target, maxlen) \
|
||||||
|
libssh2_sftp_symlink_ex((sftp), \
|
||||||
|
(path), (unsigned int)strlen(path), \
|
||||||
|
(target), (maxlen), \
|
||||||
|
LIBSSH2_SFTP_READLINK)
|
||||||
|
#define libssh2_sftp_realpath(sftp, path, target, maxlen) \
|
||||||
|
libssh2_sftp_symlink_ex((sftp), \
|
||||||
|
(path), (unsigned int)strlen(path), \
|
||||||
|
(target), (maxlen), \
|
||||||
|
LIBSSH2_SFTP_REALPATH)
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} /* extern "C" */
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* LIBSSH2_SFTP_H */
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
###########################################################################
|
||||||
|
# libssh2 installation details
|
||||||
|
#
|
||||||
|
# Copyright (C) The libssh2 project and its contributors.
|
||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
###########################################################################
|
||||||
|
|
||||||
|
prefix=@prefix@
|
||||||
|
exec_prefix=@exec_prefix@
|
||||||
|
libdir=@libdir@
|
||||||
|
includedir=@includedir@
|
||||||
|
|
||||||
|
Name: libssh2
|
||||||
|
URL: https://libssh2.org/
|
||||||
|
Description: Library for SSH-based communication
|
||||||
|
Version: @LIBSSH2_VERSION@
|
||||||
|
Requires: @LIBSSH2_PC_REQUIRES@
|
||||||
|
Requires.private: @LIBSSH2_PC_REQUIRES_PRIVATE@
|
||||||
|
Libs: -L${libdir} -lssh2 @LIBSSH2_PC_LIBS@
|
||||||
|
Libs.private: @LIBSSH2_PC_LIBS_PRIVATE@
|
||||||
|
Cflags: -I${includedir}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.deps
|
||||||
|
.libs
|
||||||
|
Makefile.inc.cmake
|
||||||
|
libssh2.pc
|
||||||
|
libssh2_config.h
|
||||||
|
libssh2_config.h.in
|
||||||
|
stamp-h1
|
||||||
+341
@@ -0,0 +1,341 @@
|
|||||||
|
# Copyright (C) Alexander Lamaison <alexander.lamaison@gmail.com>
|
||||||
|
# Copyright (C) Viktor Szakats
|
||||||
|
#
|
||||||
|
# Redistribution and use in source and binary forms,
|
||||||
|
# with or without modification, are permitted provided
|
||||||
|
# that the following conditions are met:
|
||||||
|
#
|
||||||
|
# Redistributions of source code must retain the above
|
||||||
|
# copyright notice, this list of conditions and the
|
||||||
|
# following disclaimer.
|
||||||
|
#
|
||||||
|
# Redistributions in binary form must reproduce the above
|
||||||
|
# copyright notice, this list of conditions and the following
|
||||||
|
# disclaimer in the documentation and/or other materials
|
||||||
|
# provided with the distribution.
|
||||||
|
#
|
||||||
|
# Neither the name of the copyright holder nor the names
|
||||||
|
# of any other contributors may be used to endorse or
|
||||||
|
# promote products derived from this software without
|
||||||
|
# specific prior written permission.
|
||||||
|
#
|
||||||
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
# OF SUCH DAMAGE.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
set(_libssh2_soversion 1)
|
||||||
|
set(_libssh2_libversion 1.0.1)
|
||||||
|
|
||||||
|
if(CRYPTO_BACKEND)
|
||||||
|
list(APPEND PRIVATE_COMPILE_DEFINITIONS ${CRYPTO_BACKEND_DEFINE})
|
||||||
|
list(APPEND PRIVATE_INCLUDE_DIRECTORIES ${CRYPTO_BACKEND_INCLUDE_DIR})
|
||||||
|
add_feature_info("Crypto backend" ON "${CRYPTO_BACKEND}")
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "No suitable cryptography backend found.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
unset(_libssh2_definitions)
|
||||||
|
|
||||||
|
option(CLEAR_MEMORY "Enable clearing of memory before being freed" ON)
|
||||||
|
if(NOT CLEAR_MEMORY)
|
||||||
|
list(APPEND _libssh2_definitions "LIBSSH2_NO_CLEAR_MEMORY")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
option(ENABLE_ZLIB_COMPRESSION "Use zlib for compression" OFF)
|
||||||
|
add_feature_info(Compression ENABLE_ZLIB_COMPRESSION
|
||||||
|
"using zlib for compression")
|
||||||
|
if(ENABLE_ZLIB_COMPRESSION)
|
||||||
|
find_package(ZLIB REQUIRED)
|
||||||
|
|
||||||
|
list(APPEND libssh2_INCLUDE_DIRS ${ZLIB_INCLUDE_DIRS})
|
||||||
|
list(APPEND LIBSSH2_LIBS ${ZLIB_LIBRARIES})
|
||||||
|
list(APPEND LIBSSH2_PC_REQUIRES_PRIVATE "zlib")
|
||||||
|
if(ZLIB_FOUND)
|
||||||
|
list(APPEND _libssh2_definitions "LIBSSH2_HAVE_ZLIB")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
list(APPEND LIBSSH2_LIBS ${LIBSSH2_LIBS_SOCKET})
|
||||||
|
|
||||||
|
# to find generated header
|
||||||
|
list(APPEND libssh2_INCLUDE_DIRS ${CMAKE_CURRENT_BINARY_DIR})
|
||||||
|
|
||||||
|
if(MSVC)
|
||||||
|
set(CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS_DEBUG} /Zi /Od")
|
||||||
|
set(CMAKE_SHARED_LINKER_FLAGS_DEBUG "${CMAKE_SHARED_LINKER_FLAGS_DEBUG} /DEBUG")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
include(GNUInstallDirs)
|
||||||
|
transform_makefile_inc("Makefile.inc" "${CMAKE_CURRENT_BINARY_DIR}/Makefile.inc.cmake")
|
||||||
|
# Get 'CSOURCES' and 'HHEADERS' variables
|
||||||
|
include("${CMAKE_CURRENT_BINARY_DIR}/Makefile.inc.cmake")
|
||||||
|
set(_sources ${CSOURCES} ${HHEADERS})
|
||||||
|
|
||||||
|
## Library definition
|
||||||
|
|
||||||
|
# Ensure that the static library and import library filenames are different,
|
||||||
|
# when building both static and shared library. On Windows, with certain
|
||||||
|
# toolchains (e.g. MSVC) these libraries get the same by default, overwriting
|
||||||
|
# each other. MinGW is not affected.
|
||||||
|
if(WIN32 AND BUILD_STATIC_LIBS AND BUILD_SHARED_LIBS AND
|
||||||
|
NOT STATIC_LIB_SUFFIX AND NOT IMPORT_LIB_SUFFIX AND
|
||||||
|
CMAKE_STATIC_LIBRARY_SUFFIX STREQUAL CMAKE_IMPORT_LIBRARY_SUFFIX)
|
||||||
|
set(STATIC_LIB_SUFFIX "_static")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
unset(_libssh2_export)
|
||||||
|
|
||||||
|
# we want it to be called libssh2 on all platforms
|
||||||
|
if(BUILD_STATIC_LIBS)
|
||||||
|
list(APPEND _libssh2_export ${LIB_STATIC})
|
||||||
|
add_library(${LIB_STATIC} STATIC ${_sources})
|
||||||
|
add_library(${PROJECT_NAME}::${LIB_STATIC} ALIAS ${LIB_STATIC})
|
||||||
|
target_compile_definitions(${LIB_STATIC} PRIVATE ${PRIVATE_COMPILE_DEFINITIONS} ${_libssh2_definitions})
|
||||||
|
target_link_libraries(${LIB_STATIC} PRIVATE ${LIBSSH2_LIBS})
|
||||||
|
set_target_properties(${LIB_STATIC} PROPERTIES
|
||||||
|
PREFIX "" OUTPUT_NAME "libssh2" SOVERSION "${_libssh2_soversion}" VERSION "${_libssh2_libversion}"
|
||||||
|
SUFFIX "${STATIC_LIB_SUFFIX}${CMAKE_STATIC_LIBRARY_SUFFIX}")
|
||||||
|
|
||||||
|
target_include_directories(${LIB_STATIC}
|
||||||
|
PRIVATE
|
||||||
|
"${PROJECT_SOURCE_DIR}/include"
|
||||||
|
${libssh2_INCLUDE_DIRS}
|
||||||
|
${PRIVATE_INCLUDE_DIRECTORIES}
|
||||||
|
PUBLIC
|
||||||
|
"$<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>"
|
||||||
|
"$<INSTALL_INTERFACE:$<INSTALL_PREFIX>/${CMAKE_INSTALL_INCLUDEDIR}>")
|
||||||
|
endif()
|
||||||
|
if(BUILD_SHARED_LIBS)
|
||||||
|
list(APPEND _libssh2_export ${LIB_SHARED})
|
||||||
|
add_library(${LIB_SHARED} SHARED ${_sources})
|
||||||
|
add_library(${PROJECT_NAME}::${LIB_SHARED} ALIAS ${LIB_SHARED})
|
||||||
|
if(WIN32)
|
||||||
|
set_property(TARGET ${LIB_SHARED} APPEND PROPERTY SOURCES "libssh2.rc")
|
||||||
|
endif()
|
||||||
|
target_compile_definitions(${LIB_SHARED} PRIVATE ${PRIVATE_COMPILE_DEFINITIONS} ${_libssh2_definitions} ${LIB_SHARED_DEFINITIONS})
|
||||||
|
target_compile_options(${LIB_SHARED} PRIVATE ${LIB_SHARED_C_FLAGS})
|
||||||
|
target_link_libraries(${LIB_SHARED} PRIVATE ${LIBSSH2_LIBS})
|
||||||
|
set_target_properties(${LIB_SHARED} PROPERTIES
|
||||||
|
PREFIX "" OUTPUT_NAME "libssh2" SOVERSION "${_libssh2_soversion}" VERSION "${_libssh2_libversion}"
|
||||||
|
IMPORT_PREFIX "" IMPORT_SUFFIX "${IMPORT_LIB_SUFFIX}${CMAKE_IMPORT_LIBRARY_SUFFIX}"
|
||||||
|
POSITION_INDEPENDENT_CODE ON)
|
||||||
|
|
||||||
|
target_include_directories(${LIB_SHARED}
|
||||||
|
PRIVATE
|
||||||
|
"${PROJECT_SOURCE_DIR}/include"
|
||||||
|
${libssh2_INCLUDE_DIRS}
|
||||||
|
${PRIVATE_INCLUDE_DIRECTORIES}
|
||||||
|
PUBLIC
|
||||||
|
"$<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>"
|
||||||
|
"$<INSTALL_INTERFACE:$<INSTALL_PREFIX>/${CMAKE_INSTALL_INCLUDEDIR}>")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_library(${PROJECT_NAME}::${LIB_NAME} ALIAS ${LIB_SELECTED})
|
||||||
|
add_library(${LIB_NAME} ALIAS ${LIB_SELECTED})
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
install(FILES
|
||||||
|
"${PROJECT_SOURCE_DIR}/include/libssh2.h"
|
||||||
|
"${PROJECT_SOURCE_DIR}/include/libssh2_publickey.h"
|
||||||
|
"${PROJECT_SOURCE_DIR}/include/libssh2_sftp.h"
|
||||||
|
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
|
||||||
|
|
||||||
|
if(BUILD_STATIC_LIBS)
|
||||||
|
install(TARGETS ${LIB_STATIC}
|
||||||
|
EXPORT "${PROJECT_NAME}-targets"
|
||||||
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
|
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
|
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||||
|
endif()
|
||||||
|
if(BUILD_SHARED_LIBS)
|
||||||
|
install(TARGETS ${LIB_SHARED}
|
||||||
|
EXPORT "${PROJECT_NAME}-targets"
|
||||||
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
|
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
|
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR})
|
||||||
|
|
||||||
|
list(APPEND _RUNTIME_DEPENDENCIES $<TARGET_FILE:${LIB_SHARED}>)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(RUNTIME_DEPENDENCIES ${_RUNTIME_DEPENDENCIES} CACHE INTERNAL
|
||||||
|
"Files that must be in the same directory as the executables at runtime.")
|
||||||
|
|
||||||
|
# Package config
|
||||||
|
|
||||||
|
## During package installation, install libssh2-targets.cmake
|
||||||
|
install(EXPORT "${PROJECT_NAME}-targets"
|
||||||
|
NAMESPACE "${PROJECT_NAME}::"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME}")
|
||||||
|
|
||||||
|
## During build, register directly from build tree
|
||||||
|
# create libssh2-targets.cmake
|
||||||
|
export(TARGETS ${_libssh2_export} NAMESPACE "${PROJECT_NAME}::" FILE "${PROJECT_NAME}-targets.cmake")
|
||||||
|
export(PACKAGE ${PROJECT_NAME}) # register it
|
||||||
|
|
||||||
|
# Generate libssh2-config.cmake into build tree and install it with dependencies
|
||||||
|
configure_file("${PROJECT_SOURCE_DIR}/cmake/libssh2-config.cmake.in" "${PROJECT_NAME}-config.cmake" @ONLY)
|
||||||
|
install(
|
||||||
|
FILES
|
||||||
|
"${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config.cmake"
|
||||||
|
"${PROJECT_SOURCE_DIR}/cmake/FindLibgcrypt.cmake"
|
||||||
|
"${PROJECT_SOURCE_DIR}/cmake/FindMbedTLS.cmake"
|
||||||
|
"${PROJECT_SOURCE_DIR}/cmake/FindWolfSSL.cmake"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME}")
|
||||||
|
|
||||||
|
# Calculate variables for pkg-config
|
||||||
|
set(LIBSSH2_PC_LIBS_PRIVATE "")
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
list(APPEND LIBSSH2_PC_LIBS_PRIVATE "-lws2_32")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(_ldflags "")
|
||||||
|
|
||||||
|
# Avoid getting unnecessary -L options for known system directories.
|
||||||
|
unset(_sys_libdirs)
|
||||||
|
foreach(_libdir IN LISTS CMAKE_SYSTEM_PREFIX_PATH)
|
||||||
|
if(_libdir MATCHES "/$")
|
||||||
|
set(_libdir "${_libdir}lib")
|
||||||
|
else()
|
||||||
|
set(_libdir "${_libdir}/lib")
|
||||||
|
endif()
|
||||||
|
if(IS_DIRECTORY "${_libdir}")
|
||||||
|
list(APPEND _sys_libdirs "${_libdir}")
|
||||||
|
endif()
|
||||||
|
if(DEFINED CMAKE_LIBRARY_ARCHITECTURE)
|
||||||
|
set(_libdir "${_libdir}/${CMAKE_LIBRARY_ARCHITECTURE}")
|
||||||
|
if(IS_DIRECTORY "${_libdir}")
|
||||||
|
list(APPEND _sys_libdirs "${_libdir}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
foreach(_libdir IN LISTS LIBSSH2_LIBDIRS)
|
||||||
|
list(FIND _sys_libdirs "${_libdir}" _libdir_index)
|
||||||
|
if(_libdir_index LESS 0)
|
||||||
|
list(APPEND _ldflags "-L${_libdir}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
unset(_implicit_libs)
|
||||||
|
if(NOT MINGW AND NOT UNIX)
|
||||||
|
set(_implicit_libs ${CMAKE_C_IMPLICIT_LINK_LIBRARIES})
|
||||||
|
endif()
|
||||||
|
|
||||||
|
foreach(_lib IN LISTS _implicit_libs LIBSSH2_LIBS)
|
||||||
|
if(TARGET "${_lib}")
|
||||||
|
set(_libname "${_lib}")
|
||||||
|
get_target_property(_imported "${_libname}" IMPORTED)
|
||||||
|
if(NOT _imported)
|
||||||
|
# Reading the LOCATION property on non-imported target will error out.
|
||||||
|
# Assume the user will not need this information in the .pc file.
|
||||||
|
continue()
|
||||||
|
endif()
|
||||||
|
get_target_property(_lib "${_libname}" LOCATION)
|
||||||
|
if(NOT _lib)
|
||||||
|
message(WARNING "Bad lib in library list: ${_libname}")
|
||||||
|
continue()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
if(_lib MATCHES "^-")
|
||||||
|
list(APPEND _ldflags "${_lib}")
|
||||||
|
elseif(_lib MATCHES ".*/.*")
|
||||||
|
# This gets a bit more complex, because we want to specify the
|
||||||
|
# directory separately, and only once per directory
|
||||||
|
get_filename_component(_libdir ${_lib} DIRECTORY)
|
||||||
|
get_filename_component(_libname ${_lib} NAME_WE)
|
||||||
|
if(_libname MATCHES "^lib")
|
||||||
|
list(FIND _sys_libdirs "${_libdir}" _libdir_index)
|
||||||
|
if(_libdir_index LESS 0)
|
||||||
|
list(APPEND _ldflags "-L${_libdir}")
|
||||||
|
endif()
|
||||||
|
string(REGEX REPLACE "^lib" "" _libname "${_libname}")
|
||||||
|
list(APPEND LIBSSH2_PC_LIBS_PRIVATE "-l${_libname}")
|
||||||
|
else()
|
||||||
|
list(APPEND LIBSSH2_PC_LIBS_PRIVATE "${_lib}")
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
list(APPEND LIBSSH2_PC_LIBS_PRIVATE "-l${_lib}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
if(LIBSSH2_PC_REQUIRES_PRIVATE)
|
||||||
|
string(REPLACE ";" "," LIBSSH2_PC_REQUIRES_PRIVATE "${LIBSSH2_PC_REQUIRES_PRIVATE}")
|
||||||
|
endif()
|
||||||
|
if(LIBSSH2_PC_LIBS_PRIVATE)
|
||||||
|
list(REMOVE_DUPLICATES LIBSSH2_PC_LIBS_PRIVATE)
|
||||||
|
string(REPLACE ";" " " LIBSSH2_PC_LIBS_PRIVATE "${LIBSSH2_PC_LIBS_PRIVATE}")
|
||||||
|
endif()
|
||||||
|
if(_ldflags)
|
||||||
|
list(REMOVE_DUPLICATES _ldflags)
|
||||||
|
string(REPLACE ";" " " _ldflags "${_ldflags}")
|
||||||
|
set(LIBSSH2_PC_LIBS_PRIVATE "${_ldflags} ${LIBSSH2_PC_LIBS_PRIVATE}")
|
||||||
|
string(STRIP "${LIBSSH2_PC_LIBS_PRIVATE}" LIBSSH2_PC_LIBS_PRIVATE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Merge pkg-config private fields into public ones when static-only
|
||||||
|
if(BUILD_SHARED_LIBS)
|
||||||
|
set(LIBSSH2_PC_REQUIRES "")
|
||||||
|
set(LIBSSH2_PC_LIBS "")
|
||||||
|
else()
|
||||||
|
set(LIBSSH2_PC_REQUIRES "${LIBSSH2_PC_REQUIRES_PRIVATE}")
|
||||||
|
set(LIBSSH2_PC_LIBS "${LIBSSH2_PC_LIBS_PRIVATE}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(prefix "${CMAKE_INSTALL_PREFIX}")
|
||||||
|
set(exec_prefix "\${prefix}")
|
||||||
|
if(IS_ABSOLUTE ${CMAKE_INSTALL_INCLUDEDIR})
|
||||||
|
set(includedir "${CMAKE_INSTALL_INCLUDEDIR}")
|
||||||
|
else()
|
||||||
|
set(includedir "\${prefix}/${CMAKE_INSTALL_INCLUDEDIR}")
|
||||||
|
endif()
|
||||||
|
if(IS_ABSOLUTE ${CMAKE_INSTALL_LIBDIR})
|
||||||
|
set(libdir "${CMAKE_INSTALL_LIBDIR}")
|
||||||
|
else()
|
||||||
|
set(libdir "\${exec_prefix}/${CMAKE_INSTALL_LIBDIR}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Generate a pkg-config file for client projects not using CMake.
|
||||||
|
# Consumed variables:
|
||||||
|
# exec_prefix
|
||||||
|
# includedir
|
||||||
|
# LIBSSH2_PC_LIBS
|
||||||
|
# LIBSSH2_PC_LIBS_PRIVATE
|
||||||
|
# LIBSSH2_PC_REQUIRES
|
||||||
|
# LIBSSH2_PC_REQUIRES_PRIVATE
|
||||||
|
# LIBSSH2_VERSION
|
||||||
|
# libdir
|
||||||
|
# prefix
|
||||||
|
configure_file("${PROJECT_SOURCE_DIR}/libssh2.pc.in" "libssh2.pc" @ONLY)
|
||||||
|
install(
|
||||||
|
FILES "${CMAKE_CURRENT_BINARY_DIR}/libssh2.pc"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_LIBDIR}/pkgconfig")
|
||||||
|
|
||||||
|
#
|
||||||
|
|
||||||
|
include(CMakePackageConfigHelpers)
|
||||||
|
write_basic_package_version_file(
|
||||||
|
"${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config-version.cmake"
|
||||||
|
VERSION "${LIBSSH2_VERSION_MAJOR}.${LIBSSH2_VERSION_MINOR}.${LIBSSH2_VERSION_PATCH}"
|
||||||
|
COMPATIBILITY SameMajorVersion)
|
||||||
|
install(
|
||||||
|
FILES "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}-config-version.cmake"
|
||||||
|
DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/${PROJECT_NAME}")
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
# Copyright (C) The libssh2 project and its contributors.
|
||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
AUTOMAKE_OPTIONS = foreign nostdinc
|
||||||
|
|
||||||
|
# Get the CSOURCES, HHEADERS and EXTRA_DIST defines
|
||||||
|
include Makefile.inc
|
||||||
|
|
||||||
|
libssh2_la_SOURCES = $(CSOURCES) $(HHEADERS)
|
||||||
|
if HAVE_WINDRES
|
||||||
|
libssh2_la_SOURCES += libssh2.rc
|
||||||
|
endif
|
||||||
|
|
||||||
|
EXTRA_DIST += libssh2_config.h.in libssh2_config_cmake.h.in CMakeLists.txt
|
||||||
|
|
||||||
|
lib_LTLIBRARIES = libssh2.la
|
||||||
|
|
||||||
|
# srcdir/include for the shipped headers
|
||||||
|
# builddir/src for the generated config header when building out of the source
|
||||||
|
# tree
|
||||||
|
AM_CPPFLAGS = -I$(top_builddir)/src -I$(top_srcdir)/include
|
||||||
|
|
||||||
|
# This might hold -Werror
|
||||||
|
AM_CFLAGS = @LIBSSH2_CFLAG_EXTRAS@
|
||||||
|
|
||||||
|
VERSION=-version-info 1:1:0
|
||||||
|
|
||||||
|
# This flag accepts an argument of the form current[:revision[:age]]. So,
|
||||||
|
# passing -version-info 3:12:1 sets current to 3, revision to 12, and age to
|
||||||
|
# 1.
|
||||||
|
#
|
||||||
|
# If either revision or age are omitted, they default to 0. Also note that age
|
||||||
|
# must be less than or equal to the current interface number.
|
||||||
|
#
|
||||||
|
# Here are a set of rules to help you update your library version information:
|
||||||
|
#
|
||||||
|
# 1.Start with version information of 0:0:0 for each libtool library.
|
||||||
|
#
|
||||||
|
# 2.Update the version information only immediately before a public release of
|
||||||
|
# your software. More frequent updates are unnecessary, and only guarantee
|
||||||
|
# that the current interface number gets larger faster.
|
||||||
|
#
|
||||||
|
# 3.If the library source code has changed at all since the last update, then
|
||||||
|
# increment revision (c:r+1:a)
|
||||||
|
#
|
||||||
|
# 4.If any interfaces have been added, removed, or changed since the last
|
||||||
|
# update, increment current, and set revision to 0. (c+1:r=0:a)
|
||||||
|
#
|
||||||
|
# 5.If any interfaces have been added since the last public release, then
|
||||||
|
# increment age. (c:r:a+1)
|
||||||
|
#
|
||||||
|
# 6.If any interfaces have been removed since the last public release, then
|
||||||
|
# set age to 0. (c:r:a=0)
|
||||||
|
#
|
||||||
|
|
||||||
|
libssh2_la_LDFLAGS = $(VERSION) -no-undefined \
|
||||||
|
-export-symbols-regex '^libssh2_.*'
|
||||||
|
|
||||||
|
if HAVE_WINDRES
|
||||||
|
.rc.lo:
|
||||||
|
$(LIBTOOL) --tag=RC --mode=compile $(RC) -I$(top_srcdir)/include $(RCFLAGS) -i $< -o $@
|
||||||
|
endif
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
# Copyright (C) The libssh2 project and its contributors.
|
||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
CSOURCES = \
|
||||||
|
agent.c \
|
||||||
|
bcrypt_pbkdf.c \
|
||||||
|
channel.c \
|
||||||
|
comp.c \
|
||||||
|
chacha.c \
|
||||||
|
cipher-chachapoly.c \
|
||||||
|
crypt.c \
|
||||||
|
crypto.c \
|
||||||
|
global.c \
|
||||||
|
hostkey.c \
|
||||||
|
keepalive.c \
|
||||||
|
kex.c \
|
||||||
|
knownhost.c \
|
||||||
|
mac.c \
|
||||||
|
misc.c \
|
||||||
|
packet.c \
|
||||||
|
pem.c \
|
||||||
|
poly1305.c \
|
||||||
|
publickey.c \
|
||||||
|
scp.c \
|
||||||
|
session.c \
|
||||||
|
sftp.c \
|
||||||
|
transport.c \
|
||||||
|
userauth.c \
|
||||||
|
userauth_kbd_packet.c \
|
||||||
|
version.c
|
||||||
|
|
||||||
|
HHEADERS = \
|
||||||
|
chacha.h \
|
||||||
|
channel.h \
|
||||||
|
cipher-chachapoly.h \
|
||||||
|
comp.h \
|
||||||
|
crypto.h \
|
||||||
|
crypto_config.h \
|
||||||
|
libgcrypt.h \
|
||||||
|
libssh2_priv.h \
|
||||||
|
libssh2_setup.h \
|
||||||
|
mac.h \
|
||||||
|
mbedtls.h \
|
||||||
|
misc.h \
|
||||||
|
openssl.h \
|
||||||
|
os400qc3.h \
|
||||||
|
packet.h \
|
||||||
|
poly1305.h \
|
||||||
|
session.h \
|
||||||
|
sftp.h \
|
||||||
|
transport.h \
|
||||||
|
userauth.h \
|
||||||
|
userauth_kbd_packet.h \
|
||||||
|
wincng.h
|
||||||
|
|
||||||
|
EXTRA_DIST = \
|
||||||
|
agent_win.c \
|
||||||
|
blowfish.c \
|
||||||
|
libgcrypt.c \
|
||||||
|
mbedtls.c \
|
||||||
|
openssl.c \
|
||||||
|
os400qc3.c \
|
||||||
|
wincng.c
|
||||||
+1032
File diff suppressed because it is too large.
Load diff
+349
@@ -0,0 +1,349 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) Daiki Ueno
|
||||||
|
* Copyright (C) Daniel Stenberg
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause AND BSD-2-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifdef HAVE_WIN32_AGENTS /* Compile this via agent.c */
|
||||||
|
|
||||||
|
#include <stdlib.h> /* for getenv() */
|
||||||
|
|
||||||
|
/* Code to talk to OpenSSH was taken and modified from the Win32 port of
|
||||||
|
* Portable OpenSSH by the PowerShell team. Commit
|
||||||
|
* 8ab565c53f3619d6a1f5ac229e212cad8a52852c of
|
||||||
|
* https://github.com/PowerShell/openssh-portable.git was used as the base,
|
||||||
|
* specifically the following files:
|
||||||
|
*
|
||||||
|
* - contrib\win32\win32compat\fileio.c
|
||||||
|
* - Structure of agent_connect_openssh from ssh_get_authentication_socket
|
||||||
|
* - Structure of agent_transact_openssh from ssh_request_reply
|
||||||
|
* - contrib\win32\win32compat\wmain_common.c
|
||||||
|
* - Windows equivalent functions for common Unix functions, inlined into
|
||||||
|
* this implementation
|
||||||
|
* - fileio_connect replacing connect
|
||||||
|
* - fileio_read replacing read
|
||||||
|
* - fileio_write replacing write
|
||||||
|
* - fileio_close replacing close
|
||||||
|
*
|
||||||
|
* Author: Tatu Ylonen <ylo@cs.hut.fi>
|
||||||
|
* Copyright (C) 1995 Tatu Ylonen <ylo@cs.hut.fi>, Espoo, Finland
|
||||||
|
* All rights reserved
|
||||||
|
* Functions for connecting the local authentication agent.
|
||||||
|
*
|
||||||
|
* As far as I am concerned, the code I have written for this software
|
||||||
|
* can be used freely for any purpose. Any derived versions of this
|
||||||
|
* software must be clearly marked as such, and if the derived work is
|
||||||
|
* incompatible with the protocol description in the RFC file, it must be
|
||||||
|
* called by a name other than "ssh" or "Secure Shell".
|
||||||
|
*
|
||||||
|
* SSH2 implementation,
|
||||||
|
* Copyright (C) 2000 Markus Friedl. All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions
|
||||||
|
* are met:
|
||||||
|
* 1. Redistributions of source code must retain the above copyright
|
||||||
|
* notice, this list of conditions and the following disclaimer.
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
* notice, this list of conditions and the following disclaimer in the
|
||||||
|
* documentation and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
||||||
|
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||||
|
* IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||||
|
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||||
|
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||||
|
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2015 Microsoft Corp.
|
||||||
|
* All rights reserved
|
||||||
|
*
|
||||||
|
* Microsoft openssh win32 port
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions
|
||||||
|
* are met:
|
||||||
|
*
|
||||||
|
* 1. Redistributions of source code must retain the above copyright
|
||||||
|
* notice, this list of conditions and the following disclaimer.
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
* notice, this list of conditions and the following disclaimer in the
|
||||||
|
* documentation and/or other materials provided with the distribution.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
||||||
|
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||||
|
* IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||||
|
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||||
|
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||||
|
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define WIN32_OPENSSH_AGENT_SOCK "\\\\.\\pipe\\openssh-ssh-agent"
|
||||||
|
|
||||||
|
static int
|
||||||
|
agent_connect_openssh(LIBSSH2_AGENT *agent)
|
||||||
|
{
|
||||||
|
int ret = LIBSSH2_ERROR_NONE;
|
||||||
|
const char *path;
|
||||||
|
HANDLE pipe = INVALID_HANDLE_VALUE;
|
||||||
|
HANDLE event = NULL;
|
||||||
|
|
||||||
|
path = agent->identity_agent_path;
|
||||||
|
if(!path) {
|
||||||
|
path = getenv("SSH_AUTH_SOCK");
|
||||||
|
if(!path)
|
||||||
|
path = WIN32_OPENSSH_AGENT_SOCK;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(;;) {
|
||||||
|
pipe = CreateFileA(
|
||||||
|
path,
|
||||||
|
GENERIC_READ | GENERIC_WRITE,
|
||||||
|
0,
|
||||||
|
NULL,
|
||||||
|
OPEN_EXISTING,
|
||||||
|
/* Non-blocking mode for agent connections is not implemented at
|
||||||
|
* the point this was implemented. The code for Win32 OpenSSH
|
||||||
|
* should support non-blocking IO, but the code calling it doesn't
|
||||||
|
* support it as of yet.
|
||||||
|
* When non-blocking IO is implemented for the surrounding code,
|
||||||
|
* uncomment the following line to enable support within the Win32
|
||||||
|
* OpenSSH code.
|
||||||
|
*/
|
||||||
|
/* FILE_FLAG_OVERLAPPED | */
|
||||||
|
SECURITY_SQOS_PRESENT |
|
||||||
|
SECURITY_IDENTIFICATION,
|
||||||
|
NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
if(pipe != INVALID_HANDLE_VALUE)
|
||||||
|
break;
|
||||||
|
if(GetLastError() != ERROR_PIPE_BUSY)
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* Wait up to 1 second for a pipe instance to become available */
|
||||||
|
if(!WaitNamedPipeA(path, 1000))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(pipe == INVALID_HANDLE_VALUE) {
|
||||||
|
ret = _libssh2_error(agent->session, LIBSSH2_ERROR_AGENT_PROTOCOL,
|
||||||
|
"unable to connect to agent pipe");
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(SetHandleInformation(pipe, HANDLE_FLAG_INHERIT, 0) == FALSE) {
|
||||||
|
ret = _libssh2_error(agent->session, LIBSSH2_ERROR_AGENT_PROTOCOL,
|
||||||
|
"unable to set handle information of agent pipe");
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
event = CreateEventA(NULL, TRUE, FALSE, NULL);
|
||||||
|
if(!event) {
|
||||||
|
ret = _libssh2_error(agent->session, LIBSSH2_ERROR_AGENT_PROTOCOL,
|
||||||
|
"unable to create async I/O event");
|
||||||
|
goto cleanup;
|
||||||
|
}
|
||||||
|
|
||||||
|
agent->pipe = pipe;
|
||||||
|
pipe = INVALID_HANDLE_VALUE;
|
||||||
|
agent->overlapped.hEvent = event;
|
||||||
|
event = NULL;
|
||||||
|
agent->fd = 0; /* Mark as the connection has been established */
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
if(event)
|
||||||
|
CloseHandle(event);
|
||||||
|
if(pipe != INVALID_HANDLE_VALUE)
|
||||||
|
CloseHandle(pipe);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
#define RECV_SEND_ALL(func, agent, buffer, length, total) \
|
||||||
|
DWORD bytes_transfered; \
|
||||||
|
BOOL ret; \
|
||||||
|
DWORD err; \
|
||||||
|
int rc; \
|
||||||
|
\
|
||||||
|
while(*total < length) { \
|
||||||
|
if(!agent->pending_io) \
|
||||||
|
ret = func(agent->pipe, (char *)buffer + *total, \
|
||||||
|
(DWORD)(length - *total), &bytes_transfered, \
|
||||||
|
&agent->overlapped); \
|
||||||
|
else \
|
||||||
|
ret = GetOverlappedResult(agent->pipe, &agent->overlapped, \
|
||||||
|
&bytes_transfered, FALSE); \
|
||||||
|
\
|
||||||
|
*total += bytes_transfered; \
|
||||||
|
if(!ret) { \
|
||||||
|
err = GetLastError(); \
|
||||||
|
if((!agent->pending_io && ERROR_IO_PENDING == err) \
|
||||||
|
|| (agent->pending_io && ERROR_IO_INCOMPLETE == err)) { \
|
||||||
|
agent->pending_io = TRUE; \
|
||||||
|
return LIBSSH2_ERROR_EAGAIN; \
|
||||||
|
} \
|
||||||
|
\
|
||||||
|
return LIBSSH2_ERROR_SOCKET_NONE; \
|
||||||
|
} \
|
||||||
|
agent->pending_io = FALSE; \
|
||||||
|
} \
|
||||||
|
\
|
||||||
|
rc = (int)*total; \
|
||||||
|
*total = 0; \
|
||||||
|
return rc;
|
||||||
|
|
||||||
|
static int
|
||||||
|
win32_openssh_send_all(LIBSSH2_AGENT *agent, void *buffer, size_t length,
|
||||||
|
size_t *send_recv_total)
|
||||||
|
{
|
||||||
|
RECV_SEND_ALL(WriteFile, agent, buffer, length, send_recv_total)
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
win32_openssh_recv_all(LIBSSH2_AGENT *agent, void *buffer, size_t length,
|
||||||
|
size_t *send_recv_total)
|
||||||
|
{
|
||||||
|
RECV_SEND_ALL(ReadFile, agent, buffer, length, send_recv_total)
|
||||||
|
}
|
||||||
|
|
||||||
|
#undef RECV_SEND_ALL
|
||||||
|
|
||||||
|
static int
|
||||||
|
agent_transact_openssh(LIBSSH2_AGENT *agent, agent_transaction_ctx_t transctx)
|
||||||
|
{
|
||||||
|
unsigned char buf[4];
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
/* Send the length of the request */
|
||||||
|
if(transctx->state == agent_NB_state_request_created) {
|
||||||
|
_libssh2_htonu32(buf, (uint32_t)transctx->request_len);
|
||||||
|
rc = win32_openssh_send_all(agent, buf, sizeof(buf),
|
||||||
|
&transctx->send_recv_total);
|
||||||
|
if(rc == LIBSSH2_ERROR_EAGAIN)
|
||||||
|
return LIBSSH2_ERROR_EAGAIN;
|
||||||
|
else if(rc < 0)
|
||||||
|
return _libssh2_error(agent->session, LIBSSH2_ERROR_SOCKET_SEND,
|
||||||
|
"agent send failed");
|
||||||
|
transctx->state = agent_NB_state_request_length_sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Send the request body */
|
||||||
|
if(transctx->state == agent_NB_state_request_length_sent) {
|
||||||
|
rc = win32_openssh_send_all(agent, transctx->request,
|
||||||
|
transctx->request_len,
|
||||||
|
&transctx->send_recv_total);
|
||||||
|
if(rc == LIBSSH2_ERROR_EAGAIN)
|
||||||
|
return LIBSSH2_ERROR_EAGAIN;
|
||||||
|
else if(rc < 0)
|
||||||
|
return _libssh2_error(agent->session, LIBSSH2_ERROR_SOCKET_SEND,
|
||||||
|
"agent send failed");
|
||||||
|
transctx->state = agent_NB_state_request_sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Receive the length of the body */
|
||||||
|
if(transctx->state == agent_NB_state_request_sent) {
|
||||||
|
rc = win32_openssh_recv_all(agent, buf, sizeof(buf),
|
||||||
|
&transctx->send_recv_total);
|
||||||
|
if(rc == LIBSSH2_ERROR_EAGAIN)
|
||||||
|
return LIBSSH2_ERROR_EAGAIN;
|
||||||
|
else if(rc < 0)
|
||||||
|
return _libssh2_error(agent->session, LIBSSH2_ERROR_SOCKET_RECV,
|
||||||
|
"agent recv failed");
|
||||||
|
|
||||||
|
transctx->response_len = _libssh2_ntohu32(buf);
|
||||||
|
transctx->response = LIBSSH2_ALLOC(agent->session,
|
||||||
|
transctx->response_len);
|
||||||
|
if(!transctx->response)
|
||||||
|
return LIBSSH2_ERROR_ALLOC;
|
||||||
|
|
||||||
|
transctx->state = agent_NB_state_response_length_received;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Receive the response body */
|
||||||
|
if(transctx->state == agent_NB_state_response_length_received) {
|
||||||
|
rc = win32_openssh_recv_all(agent, transctx->response,
|
||||||
|
transctx->response_len,
|
||||||
|
&transctx->send_recv_total);
|
||||||
|
if(rc == LIBSSH2_ERROR_EAGAIN)
|
||||||
|
return LIBSSH2_ERROR_EAGAIN;
|
||||||
|
else if(rc < 0)
|
||||||
|
return _libssh2_error(agent->session, LIBSSH2_ERROR_SOCKET_RECV,
|
||||||
|
"agent recv failed");
|
||||||
|
transctx->state = agent_NB_state_response_received;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LIBSSH2_ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
agent_disconnect_openssh(LIBSSH2_AGENT *agent)
|
||||||
|
{
|
||||||
|
if(!CancelIo(agent->pipe))
|
||||||
|
return _libssh2_error(agent->session, LIBSSH2_ERROR_SOCKET_DISCONNECT,
|
||||||
|
"failed to cancel pending IO of agent pipe");
|
||||||
|
if(!CloseHandle(agent->overlapped.hEvent))
|
||||||
|
return _libssh2_error(agent->session, LIBSSH2_ERROR_SOCKET_DISCONNECT,
|
||||||
|
"failed to close handle to async I/O event");
|
||||||
|
agent->overlapped.hEvent = NULL;
|
||||||
|
/* let queued APCs (if any) drain */
|
||||||
|
SleepEx(0, TRUE);
|
||||||
|
if(!CloseHandle(agent->pipe))
|
||||||
|
return _libssh2_error(agent->session, LIBSSH2_ERROR_SOCKET_DISCONNECT,
|
||||||
|
"failed to close handle to agent pipe");
|
||||||
|
|
||||||
|
agent->pipe = INVALID_HANDLE_VALUE;
|
||||||
|
agent->fd = LIBSSH2_INVALID_SOCKET;
|
||||||
|
|
||||||
|
return LIBSSH2_ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
static struct agent_ops agent_ops_openssh = {
|
||||||
|
agent_connect_openssh,
|
||||||
|
agent_transact_openssh,
|
||||||
|
agent_disconnect_openssh
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif /* HAVE_WIN32_AGENTS */
|
||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
/* $OpenBSD: bcrypt_pbkdf.c,v 1.4 2013/07/29 00:55:53 tedu Exp $ */
|
||||||
|
/*
|
||||||
|
* Copyright (C) Ted Unangst <tedu@openbsd.org>
|
||||||
|
*
|
||||||
|
* Permission to use, copy, modify, and distribute this software for any
|
||||||
|
* purpose with or without fee is hereby granted, provided that the above
|
||||||
|
* copyright notice and this permission notice appear in all copies.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||||
|
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||||
|
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "libssh2_priv.h"
|
||||||
|
|
||||||
|
#ifndef HAVE_BCRYPT_PBKDF
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#define LIBSSH2_BCRYPT_PBKDF_C
|
||||||
|
#include "blowfish.c"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* pkcs #5 pbkdf2 implementation using the "bcrypt" hash
|
||||||
|
*
|
||||||
|
* The bcrypt hash function is derived from the bcrypt password hashing
|
||||||
|
* function with the following modifications:
|
||||||
|
* 1. The input password and salt are preprocessed with SHA512.
|
||||||
|
* 2. The output length is expanded to 256 bits.
|
||||||
|
* 3. Subsequently the magic string to be encrypted is lengthened and modified
|
||||||
|
* to "OxychromaticBlowfishSwatDynamite"
|
||||||
|
* 4. The hash function is defined to perform 64 rounds of initial state
|
||||||
|
* expansion. (More rounds are performed by iterating the hash.)
|
||||||
|
*
|
||||||
|
* Note that this implementation pulls the SHA512 operations into the caller
|
||||||
|
* as a performance optimization.
|
||||||
|
*
|
||||||
|
* One modification from official pbkdf2. Instead of outputting key material
|
||||||
|
* linearly, we mix it. pbkdf2 has a known weakness where if one uses it to
|
||||||
|
* generate (i.e.) 512 bits of key material for use as two 256 bit keys, an
|
||||||
|
* attacker can merely run once through the outer loop below, but the user
|
||||||
|
* always runs it twice. Shuffling output bytes requires computing the
|
||||||
|
* entirety of the key material to assemble any subkey. This is something a
|
||||||
|
* wise caller could do; we just do it for you.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define BCRYPT_BLOCKS 8
|
||||||
|
#define BCRYPT_HASHSIZE (BCRYPT_BLOCKS * 4)
|
||||||
|
|
||||||
|
static void
|
||||||
|
bcrypt_hash(uint8_t *sha2pass, uint8_t *sha2salt, uint8_t *out)
|
||||||
|
{
|
||||||
|
blf_ctx state;
|
||||||
|
uint8_t ciphertext[BCRYPT_HASHSIZE] = {
|
||||||
|
'O', 'x', 'y', 'c', 'h', 'r', 'o', 'm', 'a', 't', 'i', 'c',
|
||||||
|
'B', 'l', 'o', 'w', 'f', 'i', 's', 'h',
|
||||||
|
'S', 'w', 'a', 't',
|
||||||
|
'D', 'y', 'n', 'a', 'm', 'i', 't', 'e' };
|
||||||
|
uint32_t cdata[BCRYPT_BLOCKS];
|
||||||
|
int i;
|
||||||
|
uint16_t j;
|
||||||
|
uint16_t shalen = SHA512_DIGEST_LENGTH;
|
||||||
|
|
||||||
|
/* key expansion */
|
||||||
|
Blowfish_initstate(&state);
|
||||||
|
Blowfish_expandstate(&state, sha2salt, shalen, sha2pass, shalen);
|
||||||
|
for(i = 0; i < 64; i++) {
|
||||||
|
Blowfish_expand0state(&state, sha2salt, shalen);
|
||||||
|
Blowfish_expand0state(&state, sha2pass, shalen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* encryption */
|
||||||
|
j = 0;
|
||||||
|
for(i = 0; i < BCRYPT_BLOCKS; i++)
|
||||||
|
cdata[i] = Blowfish_stream2word(ciphertext, sizeof(ciphertext),
|
||||||
|
&j);
|
||||||
|
for(i = 0; i < 64; i++)
|
||||||
|
blf_enc(&state, cdata, BCRYPT_BLOCKS / 2);
|
||||||
|
|
||||||
|
/* copy out */
|
||||||
|
for(i = 0; i < BCRYPT_BLOCKS; i++) {
|
||||||
|
out[4 * i + 3] = (uint8_t)((cdata[i] >> 24) & 0xff);
|
||||||
|
out[4 * i + 2] = (cdata[i] >> 16) & 0xff;
|
||||||
|
out[4 * i + 1] = (cdata[i] >> 8) & 0xff;
|
||||||
|
out[4 * i + 0] = cdata[i] & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zap */
|
||||||
|
_libssh2_explicit_zero(ciphertext, sizeof(ciphertext));
|
||||||
|
_libssh2_explicit_zero(cdata, sizeof(cdata));
|
||||||
|
_libssh2_explicit_zero(&state, sizeof(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
bcrypt_pbkdf(const char *pass, size_t passlen, const uint8_t *salt,
|
||||||
|
size_t saltlen,
|
||||||
|
uint8_t *key, size_t keylen, unsigned int rounds)
|
||||||
|
{
|
||||||
|
uint8_t sha2pass[SHA512_DIGEST_LENGTH];
|
||||||
|
uint8_t sha2salt[SHA512_DIGEST_LENGTH];
|
||||||
|
uint8_t out[BCRYPT_HASHSIZE];
|
||||||
|
uint8_t tmpout[BCRYPT_HASHSIZE];
|
||||||
|
uint8_t *countsalt;
|
||||||
|
size_t i, j, amt, stride;
|
||||||
|
uint32_t count;
|
||||||
|
size_t origkeylen = keylen;
|
||||||
|
libssh2_sha512_ctx ctx;
|
||||||
|
|
||||||
|
/* nothing crazy */
|
||||||
|
if(rounds < 1)
|
||||||
|
return -1;
|
||||||
|
if(passlen == 0 || saltlen == 0 || keylen == 0 ||
|
||||||
|
keylen > sizeof(out) * sizeof(out) || saltlen > 1 << 20)
|
||||||
|
return -1;
|
||||||
|
countsalt = calloc(1, saltlen + 4);
|
||||||
|
if(!countsalt)
|
||||||
|
return -1;
|
||||||
|
stride = (keylen + sizeof(out) - 1) / sizeof(out);
|
||||||
|
amt = (keylen + stride - 1) / stride;
|
||||||
|
|
||||||
|
memcpy(countsalt, salt, saltlen);
|
||||||
|
|
||||||
|
/* collapse password */
|
||||||
|
if(!libssh2_sha512_init(&ctx) ||
|
||||||
|
!libssh2_sha512_update(ctx, pass, passlen) ||
|
||||||
|
!libssh2_sha512_final(ctx, sha2pass)) {
|
||||||
|
free(countsalt);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* generate key, sizeof(out) at a time */
|
||||||
|
for(count = 1; keylen > 0; count++) {
|
||||||
|
countsalt[saltlen + 0] = (uint8_t)((count >> 24) & 0xff);
|
||||||
|
countsalt[saltlen + 1] = (count >> 16) & 0xff;
|
||||||
|
countsalt[saltlen + 2] = (count >> 8) & 0xff;
|
||||||
|
countsalt[saltlen + 3] = count & 0xff;
|
||||||
|
|
||||||
|
/* first round, salt is salt */
|
||||||
|
if(!libssh2_sha512_init(&ctx) ||
|
||||||
|
!libssh2_sha512_update(ctx, countsalt, saltlen + 4) ||
|
||||||
|
!libssh2_sha512_final(ctx, sha2salt)) {
|
||||||
|
_libssh2_explicit_zero(out, sizeof(out));
|
||||||
|
free(countsalt);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bcrypt_hash(sha2pass, sha2salt, tmpout);
|
||||||
|
memcpy(out, tmpout, sizeof(out));
|
||||||
|
|
||||||
|
for(i = 1; i < rounds; i++) {
|
||||||
|
/* subsequent rounds, salt is previous output */
|
||||||
|
if(!libssh2_sha512_init(&ctx) ||
|
||||||
|
!libssh2_sha512_update(ctx, tmpout, sizeof(tmpout)) ||
|
||||||
|
!libssh2_sha512_final(ctx, sha2salt)) {
|
||||||
|
_libssh2_explicit_zero(out, sizeof(out));
|
||||||
|
free(countsalt);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bcrypt_hash(sha2pass, sha2salt, tmpout);
|
||||||
|
for(j = 0; j < sizeof(out); j++)
|
||||||
|
out[j] ^= tmpout[j];
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* pbkdf2 deviation: output the key material non-linearly.
|
||||||
|
*/
|
||||||
|
amt = LIBSSH2_MIN(amt, keylen);
|
||||||
|
for(i = 0; i < amt; i++) {
|
||||||
|
size_t dest = i * stride + (count - 1);
|
||||||
|
if(dest >= origkeylen) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
key[dest] = out[i];
|
||||||
|
}
|
||||||
|
keylen -= i;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zap */
|
||||||
|
_libssh2_explicit_zero(out, sizeof(out));
|
||||||
|
free(countsalt);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
#endif /* HAVE_BCRYPT_PBKDF */
|
||||||
|
|
||||||
|
/* Wrapper */
|
||||||
|
|
||||||
|
int _libssh2_bcrypt_pbkdf(const char *pass,
|
||||||
|
size_t passlen,
|
||||||
|
const uint8_t *salt,
|
||||||
|
size_t saltlen,
|
||||||
|
uint8_t *key,
|
||||||
|
size_t keylen,
|
||||||
|
unsigned int rounds)
|
||||||
|
{
|
||||||
|
return bcrypt_pbkdf(pass,
|
||||||
|
passlen,
|
||||||
|
salt,
|
||||||
|
saltlen,
|
||||||
|
key,
|
||||||
|
keylen,
|
||||||
|
rounds);
|
||||||
|
}
|
||||||
+751
@@ -0,0 +1,751 @@
|
|||||||
|
/* $OpenBSD: blowfish.c,v 1.18 2004/11/02 17:23:26 hshoexer Exp $ */
|
||||||
|
/*
|
||||||
|
* Blowfish for OpenBSD - a fast block cipher designed by Bruce Schneier
|
||||||
|
*
|
||||||
|
* Copyright (C) Niels Provos <provos@physnet.uni-hamburg.de>
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Implementation advice by David Mazieres <dm@lcs.mit.edu>.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms, with or without
|
||||||
|
* modification, are permitted provided that the following conditions
|
||||||
|
* are met:
|
||||||
|
* 1. Redistributions of source code must retain the above copyright
|
||||||
|
* notice, this list of conditions and the following disclaimer.
|
||||||
|
* 2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
* notice, this list of conditions and the following disclaimer in the
|
||||||
|
* documentation and/or other materials provided with the distribution.
|
||||||
|
* 3. The name of the author may not be used to endorse or promote products
|
||||||
|
* derived from this software without specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
|
||||||
|
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||||
|
* IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||||
|
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||||
|
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||||
|
* THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This code is derived from section 14.3 and the given source
|
||||||
|
* in section V of Applied Cryptography, second edition.
|
||||||
|
* Blowfish is an unpatented fast block cipher designed by
|
||||||
|
* Bruce Schneier.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#if defined(LIBSSH2_BCRYPT_PBKDF_C) || defined(_DEBUG_BLOWFISH)
|
||||||
|
|
||||||
|
#if !defined(HAVE_BCRYPT_PBKDF) && (!defined(HAVE_BLOWFISH_INITSTATE) || \
|
||||||
|
!defined(HAVE_BLOWFISH_EXPAND0STATE) || \
|
||||||
|
!defined(HAVE_BLF_ENC))
|
||||||
|
|
||||||
|
#ifdef _DEBUG_BLOWFISH
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <inttypes.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Schneier specifies a maximum key length of 56 bytes.
|
||||||
|
* This ensures that every key bit affects every cipher
|
||||||
|
* bit. However, the subkeys can hold up to 72 bytes.
|
||||||
|
* Warning: For normal blowfish encryption only 56 bytes
|
||||||
|
* of the key affect all cipherbits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define BLF_N 16 /* Number of Subkeys */
|
||||||
|
#define BLF_MAXKEYLEN ((BLF_N-2)*4) /* 448 bits */
|
||||||
|
#define BLF_MAXUTILIZED ((BLF_N + 2)*4) /* 576 bits */
|
||||||
|
|
||||||
|
/* Blowfish context */
|
||||||
|
typedef struct BlowfishContext {
|
||||||
|
uint32_t S[4][256]; /* S-Boxes */
|
||||||
|
uint32_t P[BLF_N + 2]; /* Subkeys */
|
||||||
|
} blf_ctx;
|
||||||
|
|
||||||
|
/* Raw access to customized Blowfish
|
||||||
|
* blf_key is just:
|
||||||
|
* Blowfish_initstate( state )
|
||||||
|
* Blowfish_expand0state( state, key, keylen )
|
||||||
|
*/
|
||||||
|
|
||||||
|
static void Blowfish_encipher(blf_ctx *, uint32_t *, uint32_t *);
|
||||||
|
#ifdef _DEBUG_BLOWFISH
|
||||||
|
static void Blowfish_decipher(blf_ctx *, uint32_t *, uint32_t *);
|
||||||
|
#endif
|
||||||
|
static void Blowfish_initstate(blf_ctx *);
|
||||||
|
static void Blowfish_expand0state(blf_ctx *, const uint8_t *, uint16_t);
|
||||||
|
static void Blowfish_expandstate
|
||||||
|
(blf_ctx *, const uint8_t *, uint16_t, const uint8_t *, uint16_t);
|
||||||
|
|
||||||
|
/* Standard Blowfish */
|
||||||
|
|
||||||
|
#ifdef _DEBUG_BLOWFISH
|
||||||
|
static void blf_key(blf_ctx *, const uint8_t *, uint16_t);
|
||||||
|
#endif
|
||||||
|
static void blf_enc(blf_ctx *, uint32_t *, uint16_t);
|
||||||
|
#ifdef _DEBUG_BLOWFISH
|
||||||
|
static void blf_dec(blf_ctx *, uint32_t *, uint16_t);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if 0
|
||||||
|
static void blf_ecb_encrypt(blf_ctx *, uint8_t *, uint32_t);
|
||||||
|
static void blf_ecb_decrypt(blf_ctx *, uint8_t *, uint32_t);
|
||||||
|
|
||||||
|
static void blf_cbc_encrypt(blf_ctx *, uint8_t *, uint8_t *, uint32_t);
|
||||||
|
static void blf_cbc_decrypt(blf_ctx *, uint8_t *, uint8_t *, uint32_t);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Converts uint8_t to uint32_t */
|
||||||
|
static uint32_t Blowfish_stream2word(const uint8_t *, uint16_t, uint16_t *);
|
||||||
|
|
||||||
|
/* Function for Feistel Networks */
|
||||||
|
|
||||||
|
#define F(s, x) ((((s)[ (((x) >> 24) & 0xFF)] \
|
||||||
|
+ (s)[0x100 + (((x) >> 16) & 0xFF)]) \
|
||||||
|
^ (s)[0x200 + (((x) >> 8) & 0xFF)]) \
|
||||||
|
+ (s)[0x300 + ( (x) & 0xFF)])
|
||||||
|
|
||||||
|
#define BLFRND(s,p,i,j,n) (i ^= F(s,j) ^ (p)[n])
|
||||||
|
|
||||||
|
static void
|
||||||
|
Blowfish_encipher(blf_ctx *c, uint32_t *xl, uint32_t *xr)
|
||||||
|
{
|
||||||
|
uint32_t Xl;
|
||||||
|
uint32_t Xr;
|
||||||
|
uint32_t *s = c->S[0];
|
||||||
|
uint32_t *p = c->P;
|
||||||
|
|
||||||
|
Xl = *xl;
|
||||||
|
Xr = *xr;
|
||||||
|
|
||||||
|
Xl ^= p[0];
|
||||||
|
BLFRND(s, p, Xr, Xl, 1); BLFRND(s, p, Xl, Xr, 2);
|
||||||
|
BLFRND(s, p, Xr, Xl, 3); BLFRND(s, p, Xl, Xr, 4);
|
||||||
|
BLFRND(s, p, Xr, Xl, 5); BLFRND(s, p, Xl, Xr, 6);
|
||||||
|
BLFRND(s, p, Xr, Xl, 7); BLFRND(s, p, Xl, Xr, 8);
|
||||||
|
BLFRND(s, p, Xr, Xl, 9); BLFRND(s, p, Xl, Xr, 10);
|
||||||
|
BLFRND(s, p, Xr, Xl, 11); BLFRND(s, p, Xl, Xr, 12);
|
||||||
|
BLFRND(s, p, Xr, Xl, 13); BLFRND(s, p, Xl, Xr, 14);
|
||||||
|
BLFRND(s, p, Xr, Xl, 15); BLFRND(s, p, Xl, Xr, 16);
|
||||||
|
|
||||||
|
*xl = Xr ^ p[17];
|
||||||
|
*xr = Xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef _DEBUG_BLOWFISH
|
||||||
|
static void
|
||||||
|
Blowfish_decipher(blf_ctx *c, uint32_t *xl, uint32_t *xr)
|
||||||
|
{
|
||||||
|
uint32_t Xl;
|
||||||
|
uint32_t Xr;
|
||||||
|
uint32_t *s = c->S[0];
|
||||||
|
uint32_t *p = c->P;
|
||||||
|
|
||||||
|
Xl = *xl;
|
||||||
|
Xr = *xr;
|
||||||
|
|
||||||
|
Xl ^= p[17];
|
||||||
|
BLFRND(s, p, Xr, Xl, 16); BLFRND(s, p, Xl, Xr, 15);
|
||||||
|
BLFRND(s, p, Xr, Xl, 14); BLFRND(s, p, Xl, Xr, 13);
|
||||||
|
BLFRND(s, p, Xr, Xl, 12); BLFRND(s, p, Xl, Xr, 11);
|
||||||
|
BLFRND(s, p, Xr, Xl, 10); BLFRND(s, p, Xl, Xr, 9);
|
||||||
|
BLFRND(s, p, Xr, Xl, 8); BLFRND(s, p, Xl, Xr, 7);
|
||||||
|
BLFRND(s, p, Xr, Xl, 6); BLFRND(s, p, Xl, Xr, 5);
|
||||||
|
BLFRND(s, p, Xr, Xl, 4); BLFRND(s, p, Xl, Xr, 3);
|
||||||
|
BLFRND(s, p, Xr, Xl, 2); BLFRND(s, p, Xl, Xr, 1);
|
||||||
|
|
||||||
|
*xl = Xr ^ p[0];
|
||||||
|
*xr = Xl;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static void
|
||||||
|
Blowfish_initstate(blf_ctx *c)
|
||||||
|
{
|
||||||
|
/* P-box and S-box tables initialized with digits of Pi */
|
||||||
|
|
||||||
|
static const blf_ctx initstate =
|
||||||
|
{ {
|
||||||
|
{
|
||||||
|
0xd1310ba6, 0x98dfb5ac, 0x2ffd72db, 0xd01adfb7,
|
||||||
|
0xb8e1afed, 0x6a267e96, 0xba7c9045, 0xf12c7f99,
|
||||||
|
0x24a19947, 0xb3916cf7, 0x0801f2e2, 0x858efc16,
|
||||||
|
0x636920d8, 0x71574e69, 0xa458fea3, 0xf4933d7e,
|
||||||
|
0x0d95748f, 0x728eb658, 0x718bcd58, 0x82154aee,
|
||||||
|
0x7b54a41d, 0xc25a59b5, 0x9c30d539, 0x2af26013,
|
||||||
|
0xc5d1b023, 0x286085f0, 0xca417918, 0xb8db38ef,
|
||||||
|
0x8e79dcb0, 0x603a180e, 0x6c9e0e8b, 0xb01e8a3e,
|
||||||
|
0xd71577c1, 0xbd314b27, 0x78af2fda, 0x55605c60,
|
||||||
|
0xe65525f3, 0xaa55ab94, 0x57489862, 0x63e81440,
|
||||||
|
0x55ca396a, 0x2aab10b6, 0xb4cc5c34, 0x1141e8ce,
|
||||||
|
0xa15486af, 0x7c72e993, 0xb3ee1411, 0x636fbc2a,
|
||||||
|
0x2ba9c55d, 0x741831f6, 0xce5c3e16, 0x9b87931e,
|
||||||
|
0xafd6ba33, 0x6c24cf5c, 0x7a325381, 0x28958677,
|
||||||
|
0x3b8f4898, 0x6b4bb9af, 0xc4bfe81b, 0x66282193,
|
||||||
|
0x61d809cc, 0xfb21a991, 0x487cac60, 0x5dec8032,
|
||||||
|
0xef845d5d, 0xe98575b1, 0xdc262302, 0xeb651b88,
|
||||||
|
0x23893e81, 0xd396acc5, 0x0f6d6ff3, 0x83f44239,
|
||||||
|
0x2e0b4482, 0xa4842004, 0x69c8f04a, 0x9e1f9b5e,
|
||||||
|
0x21c66842, 0xf6e96c9a, 0x670c9c61, 0xabd388f0,
|
||||||
|
0x6a51a0d2, 0xd8542f68, 0x960fa728, 0xab5133a3,
|
||||||
|
0x6eef0b6c, 0x137a3be4, 0xba3bf050, 0x7efb2a98,
|
||||||
|
0xa1f1651d, 0x39af0176, 0x66ca593e, 0x82430e88,
|
||||||
|
0x8cee8619, 0x456f9fb4, 0x7d84a5c3, 0x3b8b5ebe,
|
||||||
|
0xe06f75d8, 0x85c12073, 0x401a449f, 0x56c16aa6,
|
||||||
|
0x4ed3aa62, 0x363f7706, 0x1bfedf72, 0x429b023d,
|
||||||
|
0x37d0d724, 0xd00a1248, 0xdb0fead3, 0x49f1c09b,
|
||||||
|
0x075372c9, 0x80991b7b, 0x25d479d8, 0xf6e8def7,
|
||||||
|
0xe3fe501a, 0xb6794c3b, 0x976ce0bd, 0x04c006ba,
|
||||||
|
0xc1a94fb6, 0x409f60c4, 0x5e5c9ec2, 0x196a2463,
|
||||||
|
0x68fb6faf, 0x3e6c53b5, 0x1339b2eb, 0x3b52ec6f,
|
||||||
|
0x6dfc511f, 0x9b30952c, 0xcc814544, 0xaf5ebd09,
|
||||||
|
0xbee3d004, 0xde334afd, 0x660f2807, 0x192e4bb3,
|
||||||
|
0xc0cba857, 0x45c8740f, 0xd20b5f39, 0xb9d3fbdb,
|
||||||
|
0x5579c0bd, 0x1a60320a, 0xd6a100c6, 0x402c7279,
|
||||||
|
0x679f25fe, 0xfb1fa3cc, 0x8ea5e9f8, 0xdb3222f8,
|
||||||
|
0x3c7516df, 0xfd616b15, 0x2f501ec8, 0xad0552ab,
|
||||||
|
0x323db5fa, 0xfd238760, 0x53317b48, 0x3e00df82,
|
||||||
|
0x9e5c57bb, 0xca6f8ca0, 0x1a87562e, 0xdf1769db,
|
||||||
|
0xd542a8f6, 0x287effc3, 0xac6732c6, 0x8c4f5573,
|
||||||
|
0x695b27b0, 0xbbca58c8, 0xe1ffa35d, 0xb8f011a0,
|
||||||
|
0x10fa3d98, 0xfd2183b8, 0x4afcb56c, 0x2dd1d35b,
|
||||||
|
0x9a53e479, 0xb6f84565, 0xd28e49bc, 0x4bfb9790,
|
||||||
|
0xe1ddf2da, 0xa4cb7e33, 0x62fb1341, 0xcee4c6e8,
|
||||||
|
0xef20cada, 0x36774c01, 0xd07e9efe, 0x2bf11fb4,
|
||||||
|
0x95dbda4d, 0xae909198, 0xeaad8e71, 0x6b93d5a0,
|
||||||
|
0xd08ed1d0, 0xafc725e0, 0x8e3c5b2f, 0x8e7594b7,
|
||||||
|
0x8ff6e2fb, 0xf2122b64, 0x8888b812, 0x900df01c,
|
||||||
|
0x4fad5ea0, 0x688fc31c, 0xd1cff191, 0xb3a8c1ad,
|
||||||
|
0x2f2f2218, 0xbe0e1777, 0xea752dfe, 0x8b021fa1,
|
||||||
|
0xe5a0cc0f, 0xb56f74e8, 0x18acf3d6, 0xce89e299,
|
||||||
|
0xb4a84fe0, 0xfd13e0b7, 0x7cc43b81, 0xd2ada8d9,
|
||||||
|
0x165fa266, 0x80957705, 0x93cc7314, 0x211a1477,
|
||||||
|
0xe6ad2065, 0x77b5fa86, 0xc75442f5, 0xfb9d35cf,
|
||||||
|
0xebcdaf0c, 0x7b3e89a0, 0xd6411bd3, 0xae1e7e49,
|
||||||
|
0x00250e2d, 0x2071b35e, 0x226800bb, 0x57b8e0af,
|
||||||
|
0x2464369b, 0xf009b91e, 0x5563911d, 0x59dfa6aa,
|
||||||
|
0x78c14389, 0xd95a537f, 0x207d5ba2, 0x02e5b9c5,
|
||||||
|
0x83260376, 0x6295cfa9, 0x11c81968, 0x4e734a41,
|
||||||
|
0xb3472dca, 0x7b14a94a, 0x1b510052, 0x9a532915,
|
||||||
|
0xd60f573f, 0xbc9bc6e4, 0x2b60a476, 0x81e67400,
|
||||||
|
0x08ba6fb5, 0x571be91f, 0xf296ec6b, 0x2a0dd915,
|
||||||
|
0xb6636521, 0xe7b9f9b6, 0xff34052e, 0xc5855664,
|
||||||
|
0x53b02d5d, 0xa99f8fa1, 0x08ba4799, 0x6e85076a},
|
||||||
|
{
|
||||||
|
0x4b7a70e9, 0xb5b32944, 0xdb75092e, 0xc4192623,
|
||||||
|
0xad6ea6b0, 0x49a7df7d, 0x9cee60b8, 0x8fedb266,
|
||||||
|
0xecaa8c71, 0x699a17ff, 0x5664526c, 0xc2b19ee1,
|
||||||
|
0x193602a5, 0x75094c29, 0xa0591340, 0xe4183a3e,
|
||||||
|
0x3f54989a, 0x5b429d65, 0x6b8fe4d6, 0x99f73fd6,
|
||||||
|
0xa1d29c07, 0xefe830f5, 0x4d2d38e6, 0xf0255dc1,
|
||||||
|
0x4cdd2086, 0x8470eb26, 0x6382e9c6, 0x021ecc5e,
|
||||||
|
0x09686b3f, 0x3ebaefc9, 0x3c971814, 0x6b6a70a1,
|
||||||
|
0x687f3584, 0x52a0e286, 0xb79c5305, 0xaa500737,
|
||||||
|
0x3e07841c, 0x7fdeae5c, 0x8e7d44ec, 0x5716f2b8,
|
||||||
|
0xb03ada37, 0xf0500c0d, 0xf01c1f04, 0x0200b3ff,
|
||||||
|
0xae0cf51a, 0x3cb574b2, 0x25837a58, 0xdc0921bd,
|
||||||
|
0xd19113f9, 0x7ca92ff6, 0x94324773, 0x22f54701,
|
||||||
|
0x3ae5e581, 0x37c2dadc, 0xc8b57634, 0x9af3dda7,
|
||||||
|
0xa9446146, 0x0fd0030e, 0xecc8c73e, 0xa4751e41,
|
||||||
|
0xe238cd99, 0x3bea0e2f, 0x3280bba1, 0x183eb331,
|
||||||
|
0x4e548b38, 0x4f6db908, 0x6f420d03, 0xf60a04bf,
|
||||||
|
0x2cb81290, 0x24977c79, 0x5679b072, 0xbcaf89af,
|
||||||
|
0xde9a771f, 0xd9930810, 0xb38bae12, 0xdccf3f2e,
|
||||||
|
0x5512721f, 0x2e6b7124, 0x501adde6, 0x9f84cd87,
|
||||||
|
0x7a584718, 0x7408da17, 0xbc9f9abc, 0xe94b7d8c,
|
||||||
|
0xec7aec3a, 0xdb851dfa, 0x63094366, 0xc464c3d2,
|
||||||
|
0xef1c1847, 0x3215d908, 0xdd433b37, 0x24c2ba16,
|
||||||
|
0x12a14d43, 0x2a65c451, 0x50940002, 0x133ae4dd,
|
||||||
|
0x71dff89e, 0x10314e55, 0x81ac77d6, 0x5f11199b,
|
||||||
|
0x043556f1, 0xd7a3c76b, 0x3c11183b, 0x5924a509,
|
||||||
|
0xf28fe6ed, 0x97f1fbfa, 0x9ebabf2c, 0x1e153c6e,
|
||||||
|
0x86e34570, 0xeae96fb1, 0x860e5e0a, 0x5a3e2ab3,
|
||||||
|
0x771fe71c, 0x4e3d06fa, 0x2965dcb9, 0x99e71d0f,
|
||||||
|
0x803e89d6, 0x5266c825, 0x2e4cc978, 0x9c10b36a,
|
||||||
|
0xc6150eba, 0x94e2ea78, 0xa5fc3c53, 0x1e0a2df4,
|
||||||
|
0xf2f74ea7, 0x361d2b3d, 0x1939260f, 0x19c27960,
|
||||||
|
0x5223a708, 0xf71312b6, 0xebadfe6e, 0xeac31f66,
|
||||||
|
0xe3bc4595, 0xa67bc883, 0xb17f37d1, 0x018cff28,
|
||||||
|
0xc332ddef, 0xbe6c5aa5, 0x65582185, 0x68ab9802,
|
||||||
|
0xeecea50f, 0xdb2f953b, 0x2aef7dad, 0x5b6e2f84,
|
||||||
|
0x1521b628, 0x29076170, 0xecdd4775, 0x619f1510,
|
||||||
|
0x13cca830, 0xeb61bd96, 0x0334fe1e, 0xaa0363cf,
|
||||||
|
0xb5735c90, 0x4c70a239, 0xd59e9e0b, 0xcbaade14,
|
||||||
|
0xeecc86bc, 0x60622ca7, 0x9cab5cab, 0xb2f3846e,
|
||||||
|
0x648b1eaf, 0x19bdf0ca, 0xa02369b9, 0x655abb50,
|
||||||
|
0x40685a32, 0x3c2ab4b3, 0x319ee9d5, 0xc021b8f7,
|
||||||
|
0x9b540b19, 0x875fa099, 0x95f7997e, 0x623d7da8,
|
||||||
|
0xf837889a, 0x97e32d77, 0x11ed935f, 0x16681281,
|
||||||
|
0x0e358829, 0xc7e61fd6, 0x96dedfa1, 0x7858ba99,
|
||||||
|
0x57f584a5, 0x1b227263, 0x9b83c3ff, 0x1ac24696,
|
||||||
|
0xcdb30aeb, 0x532e3054, 0x8fd948e4, 0x6dbc3128,
|
||||||
|
0x58ebf2ef, 0x34c6ffea, 0xfe28ed61, 0xee7c3c73,
|
||||||
|
0x5d4a14d9, 0xe864b7e3, 0x42105d14, 0x203e13e0,
|
||||||
|
0x45eee2b6, 0xa3aaabea, 0xdb6c4f15, 0xfacb4fd0,
|
||||||
|
0xc742f442, 0xef6abbb5, 0x654f3b1d, 0x41cd2105,
|
||||||
|
0xd81e799e, 0x86854dc7, 0xe44b476a, 0x3d816250,
|
||||||
|
0xcf62a1f2, 0x5b8d2646, 0xfc8883a0, 0xc1c7b6a3,
|
||||||
|
0x7f1524c3, 0x69cb7492, 0x47848a0b, 0x5692b285,
|
||||||
|
0x095bbf00, 0xad19489d, 0x1462b174, 0x23820e00,
|
||||||
|
0x58428d2a, 0x0c55f5ea, 0x1dadf43e, 0x233f7061,
|
||||||
|
0x3372f092, 0x8d937e41, 0xd65fecf1, 0x6c223bdb,
|
||||||
|
0x7cde3759, 0xcbee7460, 0x4085f2a7, 0xce77326e,
|
||||||
|
0xa6078084, 0x19f8509e, 0xe8efd855, 0x61d99735,
|
||||||
|
0xa969a7aa, 0xc50c06c2, 0x5a04abfc, 0x800bcadc,
|
||||||
|
0x9e447a2e, 0xc3453484, 0xfdd56705, 0x0e1e9ec9,
|
||||||
|
0xdb73dbd3, 0x105588cd, 0x675fda79, 0xe3674340,
|
||||||
|
0xc5c43465, 0x713e38d8, 0x3d28f89e, 0xf16dff20,
|
||||||
|
0x153e21e7, 0x8fb03d4a, 0xe6e39f2b, 0xdb83adf7},
|
||||||
|
{
|
||||||
|
0xe93d5a68, 0x948140f7, 0xf64c261c, 0x94692934,
|
||||||
|
0x411520f7, 0x7602d4f7, 0xbcf46b2e, 0xd4a20068,
|
||||||
|
0xd4082471, 0x3320f46a, 0x43b7d4b7, 0x500061af,
|
||||||
|
0x1e39f62e, 0x97244546, 0x14214f74, 0xbf8b8840,
|
||||||
|
0x4d95fc1d, 0x96b591af, 0x70f4ddd3, 0x66a02f45,
|
||||||
|
0xbfbc09ec, 0x03bd9785, 0x7fac6dd0, 0x31cb8504,
|
||||||
|
0x96eb27b3, 0x55fd3941, 0xda2547e6, 0xabca0a9a,
|
||||||
|
0x28507825, 0x530429f4, 0x0a2c86da, 0xe9b66dfb,
|
||||||
|
0x68dc1462, 0xd7486900, 0x680ec0a4, 0x27a18dee,
|
||||||
|
0x4f3ffea2, 0xe887ad8c, 0xb58ce006, 0x7af4d6b6,
|
||||||
|
0xaace1e7c, 0xd3375fec, 0xce78a399, 0x406b2a42,
|
||||||
|
0x20fe9e35, 0xd9f385b9, 0xee39d7ab, 0x3b124e8b,
|
||||||
|
0x1dc9faf7, 0x4b6d1856, 0x26a36631, 0xeae397b2,
|
||||||
|
0x3a6efa74, 0xdd5b4332, 0x6841e7f7, 0xca7820fb,
|
||||||
|
0xfb0af54e, 0xd8feb397, 0x454056ac, 0xba489527,
|
||||||
|
0x55533a3a, 0x20838d87, 0xfe6ba9b7, 0xd096954b,
|
||||||
|
0x55a867bc, 0xa1159a58, 0xcca92963, 0x99e1db33,
|
||||||
|
0xa62a4a56, 0x3f3125f9, 0x5ef47e1c, 0x9029317c,
|
||||||
|
0xfdf8e802, 0x04272f70, 0x80bb155c, 0x05282ce3,
|
||||||
|
0x95c11548, 0xe4c66d22, 0x48c1133f, 0xc70f86dc,
|
||||||
|
0x07f9c9ee, 0x41041f0f, 0x404779a4, 0x5d886e17,
|
||||||
|
0x325f51eb, 0xd59bc0d1, 0xf2bcc18f, 0x41113564,
|
||||||
|
0x257b7834, 0x602a9c60, 0xdff8e8a3, 0x1f636c1b,
|
||||||
|
0x0e12b4c2, 0x02e1329e, 0xaf664fd1, 0xcad18115,
|
||||||
|
0x6b2395e0, 0x333e92e1, 0x3b240b62, 0xeebeb922,
|
||||||
|
0x85b2a20e, 0xe6ba0d99, 0xde720c8c, 0x2da2f728,
|
||||||
|
0xd0127845, 0x95b794fd, 0x647d0862, 0xe7ccf5f0,
|
||||||
|
0x5449a36f, 0x877d48fa, 0xc39dfd27, 0xf33e8d1e,
|
||||||
|
0x0a476341, 0x992eff74, 0x3a6f6eab, 0xf4f8fd37,
|
||||||
|
0xa812dc60, 0xa1ebddf8, 0x991be14c, 0xdb6e6b0d,
|
||||||
|
0xc67b5510, 0x6d672c37, 0x2765d43b, 0xdcd0e804,
|
||||||
|
0xf1290dc7, 0xcc00ffa3, 0xb5390f92, 0x690fed0b,
|
||||||
|
0x667b9ffb, 0xcedb7d9c, 0xa091cf0b, 0xd9155ea3,
|
||||||
|
0xbb132f88, 0x515bad24, 0x7b9479bf, 0x763bd6eb,
|
||||||
|
0x37392eb3, 0xcc115979, 0x8026e297, 0xf42e312d,
|
||||||
|
0x6842ada7, 0xc66a2b3b, 0x12754ccc, 0x782ef11c,
|
||||||
|
0x6a124237, 0xb79251e7, 0x06a1bbe6, 0x4bfb6350,
|
||||||
|
0x1a6b1018, 0x11caedfa, 0x3d25bdd8, 0xe2e1c3c9,
|
||||||
|
0x44421659, 0x0a121386, 0xd90cec6e, 0xd5abea2a,
|
||||||
|
0x64af674e, 0xda86a85f, 0xbebfe988, 0x64e4c3fe,
|
||||||
|
0x9dbc8057, 0xf0f7c086, 0x60787bf8, 0x6003604d,
|
||||||
|
0xd1fd8346, 0xf6381fb0, 0x7745ae04, 0xd736fccc,
|
||||||
|
0x83426b33, 0xf01eab71, 0xb0804187, 0x3c005e5f,
|
||||||
|
0x77a057be, 0xbde8ae24, 0x55464299, 0xbf582e61,
|
||||||
|
0x4e58f48f, 0xf2ddfda2, 0xf474ef38, 0x8789bdc2,
|
||||||
|
0x5366f9c3, 0xc8b38e74, 0xb475f255, 0x46fcd9b9,
|
||||||
|
0x7aeb2661, 0x8b1ddf84, 0x846a0e79, 0x915f95e2,
|
||||||
|
0x466e598e, 0x20b45770, 0x8cd55591, 0xc902de4c,
|
||||||
|
0xb90bace1, 0xbb8205d0, 0x11a86248, 0x7574a99e,
|
||||||
|
0xb77f19b6, 0xe0a9dc09, 0x662d09a1, 0xc4324633,
|
||||||
|
0xe85a1f02, 0x09f0be8c, 0x4a99a025, 0x1d6efe10,
|
||||||
|
0x1ab93d1d, 0x0ba5a4df, 0xa186f20f, 0x2868f169,
|
||||||
|
0xdcb7da83, 0x573906fe, 0xa1e2ce9b, 0x4fcd7f52,
|
||||||
|
0x50115e01, 0xa70683fa, 0xa002b5c4, 0x0de6d027,
|
||||||
|
0x9af88c27, 0x773f8641, 0xc3604c06, 0x61a806b5,
|
||||||
|
0xf0177a28, 0xc0f586e0, 0x006058aa, 0x30dc7d62,
|
||||||
|
0x11e69ed7, 0x2338ea63, 0x53c2dd94, 0xc2c21634,
|
||||||
|
0xbbcbee56, 0x90bcb6de, 0xebfc7da1, 0xce591d76,
|
||||||
|
0x6f05e409, 0x4b7c0188, 0x39720a3d, 0x7c927c24,
|
||||||
|
0x86e3725f, 0x724d9db9, 0x1ac15bb4, 0xd39eb8fc,
|
||||||
|
0xed545578, 0x08fca5b5, 0xd83d7cd3, 0x4dad0fc4,
|
||||||
|
0x1e50ef5e, 0xb161e6f8, 0xa28514d9, 0x6c51133c,
|
||||||
|
0x6fd5c7e7, 0x56e14ec4, 0x362abfce, 0xddc6c837,
|
||||||
|
0xd79a3234, 0x92638212, 0x670efa8e, 0x406000e0},
|
||||||
|
{
|
||||||
|
0x3a39ce37, 0xd3faf5cf, 0xabc27737, 0x5ac52d1b,
|
||||||
|
0x5cb0679e, 0x4fa33742, 0xd3822740, 0x99bc9bbe,
|
||||||
|
0xd5118e9d, 0xbf0f7315, 0xd62d1c7e, 0xc700c47b,
|
||||||
|
0xb78c1b6b, 0x21a19045, 0xb26eb1be, 0x6a366eb4,
|
||||||
|
0x5748ab2f, 0xbc946e79, 0xc6a376d2, 0x6549c2c8,
|
||||||
|
0x530ff8ee, 0x468dde7d, 0xd5730a1d, 0x4cd04dc6,
|
||||||
|
0x2939bbdb, 0xa9ba4650, 0xac9526e8, 0xbe5ee304,
|
||||||
|
0xa1fad5f0, 0x6a2d519a, 0x63ef8ce2, 0x9a86ee22,
|
||||||
|
0xc089c2b8, 0x43242ef6, 0xa51e03aa, 0x9cf2d0a4,
|
||||||
|
0x83c061ba, 0x9be96a4d, 0x8fe51550, 0xba645bd6,
|
||||||
|
0x2826a2f9, 0xa73a3ae1, 0x4ba99586, 0xef5562e9,
|
||||||
|
0xc72fefd3, 0xf752f7da, 0x3f046f69, 0x77fa0a59,
|
||||||
|
0x80e4a915, 0x87b08601, 0x9b09e6ad, 0x3b3ee593,
|
||||||
|
0xe990fd5a, 0x9e34d797, 0x2cf0b7d9, 0x022b8b51,
|
||||||
|
0x96d5ac3a, 0x017da67d, 0xd1cf3ed6, 0x7c7d2d28,
|
||||||
|
0x1f9f25cf, 0xadf2b89b, 0x5ad6b472, 0x5a88f54c,
|
||||||
|
0xe029ac71, 0xe019a5e6, 0x47b0acfd, 0xed93fa9b,
|
||||||
|
0xe8d3c48d, 0x283b57cc, 0xf8d56629, 0x79132e28,
|
||||||
|
0x785f0191, 0xed756055, 0xf7960e44, 0xe3d35e8c,
|
||||||
|
0x15056dd4, 0x88f46dba, 0x03a16125, 0x0564f0bd,
|
||||||
|
0xc3eb9e15, 0x3c9057a2, 0x97271aec, 0xa93a072a,
|
||||||
|
0x1b3f6d9b, 0x1e6321f5, 0xf59c66fb, 0x26dcf319,
|
||||||
|
0x7533d928, 0xb155fdf5, 0x03563482, 0x8aba3cbb,
|
||||||
|
0x28517711, 0xc20ad9f8, 0xabcc5167, 0xccad925f,
|
||||||
|
0x4de81751, 0x3830dc8e, 0x379d5862, 0x9320f991,
|
||||||
|
0xea7a90c2, 0xfb3e7bce, 0x5121ce64, 0x774fbe32,
|
||||||
|
0xa8b6e37e, 0xc3293d46, 0x48de5369, 0x6413e680,
|
||||||
|
0xa2ae0810, 0xdd6db224, 0x69852dfd, 0x09072166,
|
||||||
|
0xb39a460a, 0x6445c0dd, 0x586cdecf, 0x1c20c8ae,
|
||||||
|
0x5bbef7dd, 0x1b588d40, 0xccd2017f, 0x6bb4e3bb,
|
||||||
|
0xdda26a7e, 0x3a59ff45, 0x3e350a44, 0xbcb4cdd5,
|
||||||
|
0x72eacea8, 0xfa6484bb, 0x8d6612ae, 0xbf3c6f47,
|
||||||
|
0xd29be463, 0x542f5d9e, 0xaec2771b, 0xf64e6370,
|
||||||
|
0x740e0d8d, 0xe75b1357, 0xf8721671, 0xaf537d5d,
|
||||||
|
0x4040cb08, 0x4eb4e2cc, 0x34d2466a, 0x0115af84,
|
||||||
|
0xe1b00428, 0x95983a1d, 0x06b89fb4, 0xce6ea048,
|
||||||
|
0x6f3f3b82, 0x3520ab82, 0x011a1d4b, 0x277227f8,
|
||||||
|
0x611560b1, 0xe7933fdc, 0xbb3a792b, 0x344525bd,
|
||||||
|
0xa08839e1, 0x51ce794b, 0x2f32c9b7, 0xa01fbac9,
|
||||||
|
0xe01cc87e, 0xbcc7d1f6, 0xcf0111c3, 0xa1e8aac7,
|
||||||
|
0x1a908749, 0xd44fbd9a, 0xd0dadecb, 0xd50ada38,
|
||||||
|
0x0339c32a, 0xc6913667, 0x8df9317c, 0xe0b12b4f,
|
||||||
|
0xf79e59b7, 0x43f5bb3a, 0xf2d519ff, 0x27d9459c,
|
||||||
|
0xbf97222c, 0x15e6fc2a, 0x0f91fc71, 0x9b941525,
|
||||||
|
0xfae59361, 0xceb69ceb, 0xc2a86459, 0x12baa8d1,
|
||||||
|
0xb6c1075e, 0xe3056a0c, 0x10d25065, 0xcb03a442,
|
||||||
|
0xe0ec6e0e, 0x1698db3b, 0x4c98a0be, 0x3278e964,
|
||||||
|
0x9f1f9532, 0xe0d392df, 0xd3a0342b, 0x8971f21e,
|
||||||
|
0x1b0a7441, 0x4ba3348c, 0xc5be7120, 0xc37632d8,
|
||||||
|
0xdf359f8d, 0x9b992f2e, 0xe60b6f47, 0x0fe3f11d,
|
||||||
|
0xe54cda54, 0x1edad891, 0xce6279cf, 0xcd3e7e6f,
|
||||||
|
0x1618b166, 0xfd2c1d05, 0x848fd2c5, 0xf6fb2299,
|
||||||
|
0xf523f357, 0xa6327623, 0x93a83531, 0x56cccd02,
|
||||||
|
0xacf08162, 0x5a75ebb5, 0x6e163697, 0x88d273cc,
|
||||||
|
0xde966292, 0x81b949d0, 0x4c50901b, 0x71c65614,
|
||||||
|
0xe6c6c7bd, 0x327a140a, 0x45e1d006, 0xc3f27b9a,
|
||||||
|
0xc9aa53fd, 0x62a80f00, 0xbb25bfe2, 0x35bdd2f6,
|
||||||
|
0x71126905, 0xb2040222, 0xb6cbcf7c, 0xcd769c2b,
|
||||||
|
0x53113ec0, 0x1640e3d3, 0x38abbd60, 0x2547adf0,
|
||||||
|
0xba38209c, 0xf746ce76, 0x77afa1c5, 0x20756060,
|
||||||
|
0x85cbfe4e, 0x8ae88dd8, 0x7aaaf9b0, 0x4cf9aa7e,
|
||||||
|
0x1948c25c, 0x02fb8a8c, 0x01c36ae4, 0xd6ebe1f9,
|
||||||
|
0x90d4f869, 0xa65cdea0, 0x3f09252d, 0xc208e69f,
|
||||||
|
0xb74e6132, 0xce77e25b, 0x578fdfe3, 0x3ac372e6}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
0x243f6a88, 0x85a308d3, 0x13198a2e, 0x03707344,
|
||||||
|
0xa4093822, 0x299f31d0, 0x082efa98, 0xec4e6c89,
|
||||||
|
0x452821e6, 0x38d01377, 0xbe5466cf, 0x34e90c6c,
|
||||||
|
0xc0ac29b7, 0xc97c50dd, 0x3f84d5b5, 0xb5470917,
|
||||||
|
0x9216d5d9, 0x8979fb1b
|
||||||
|
} };
|
||||||
|
|
||||||
|
*c = initstate;
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint32_t
|
||||||
|
Blowfish_stream2word(const uint8_t *data, uint16_t databytes,
|
||||||
|
uint16_t *current)
|
||||||
|
{
|
||||||
|
uint8_t i;
|
||||||
|
uint16_t j;
|
||||||
|
uint32_t temp;
|
||||||
|
|
||||||
|
temp = 0x00000000;
|
||||||
|
j = *current;
|
||||||
|
|
||||||
|
for(i = 0; i < 4; i++, j++) {
|
||||||
|
if(j >= databytes)
|
||||||
|
j = 0;
|
||||||
|
temp = (temp << 8) | data[j];
|
||||||
|
}
|
||||||
|
|
||||||
|
*current = j;
|
||||||
|
return temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
Blowfish_expand0state(blf_ctx *c, const uint8_t *key, uint16_t keybytes)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
int k;
|
||||||
|
uint16_t j;
|
||||||
|
uint32_t temp;
|
||||||
|
uint32_t datal;
|
||||||
|
uint32_t datar;
|
||||||
|
|
||||||
|
j = 0;
|
||||||
|
for(i = 0; i < BLF_N + 2; i++) {
|
||||||
|
/* Extract 4 int8 to 1 int32 from keystream */
|
||||||
|
temp = Blowfish_stream2word(key, keybytes, &j);
|
||||||
|
c->P[i] = c->P[i] ^ temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
j = 0;
|
||||||
|
datal = 0x00000000;
|
||||||
|
datar = 0x00000000;
|
||||||
|
for(i = 0; i < BLF_N + 2; i += 2) {
|
||||||
|
Blowfish_encipher(c, &datal, &datar);
|
||||||
|
|
||||||
|
c->P[i] = datal;
|
||||||
|
c->P[i + 1] = datar;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(i = 0; i < 4; i++) {
|
||||||
|
for(k = 0; k < 256; k += 2) {
|
||||||
|
Blowfish_encipher(c, &datal, &datar);
|
||||||
|
|
||||||
|
c->S[i][k] = datal;
|
||||||
|
c->S[i][k + 1] = datar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
Blowfish_expandstate(blf_ctx *c, const uint8_t *data, uint16_t databytes,
|
||||||
|
const uint8_t *key, uint16_t keybytes)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
int k;
|
||||||
|
uint16_t j;
|
||||||
|
uint32_t temp;
|
||||||
|
uint32_t datal;
|
||||||
|
uint32_t datar;
|
||||||
|
|
||||||
|
j = 0;
|
||||||
|
for(i = 0; i < BLF_N + 2; i++) {
|
||||||
|
/* Extract 4 int8 to 1 int32 from keystream */
|
||||||
|
temp = Blowfish_stream2word(key, keybytes, &j);
|
||||||
|
c->P[i] = c->P[i] ^ temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
j = 0;
|
||||||
|
datal = 0x00000000;
|
||||||
|
datar = 0x00000000;
|
||||||
|
for(i = 0; i < BLF_N + 2; i += 2) {
|
||||||
|
datal ^= Blowfish_stream2word(data, databytes, &j);
|
||||||
|
datar ^= Blowfish_stream2word(data, databytes, &j);
|
||||||
|
Blowfish_encipher(c, &datal, &datar);
|
||||||
|
|
||||||
|
c->P[i] = datal;
|
||||||
|
c->P[i + 1] = datar;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(i = 0; i < 4; i++) {
|
||||||
|
for(k = 0; k < 256; k += 2) {
|
||||||
|
datal ^= Blowfish_stream2word(data, databytes, &j);
|
||||||
|
datar ^= Blowfish_stream2word(data, databytes, &j);
|
||||||
|
Blowfish_encipher(c, &datal, &datar);
|
||||||
|
|
||||||
|
c->S[i][k] = datal;
|
||||||
|
c->S[i][k + 1] = datar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef _DEBUG_BLOWFISH
|
||||||
|
static void
|
||||||
|
blf_key(blf_ctx *c, const uint8_t *k, uint16_t len)
|
||||||
|
{
|
||||||
|
/* Initialize S-boxes and subkeys with Pi */
|
||||||
|
Blowfish_initstate(c);
|
||||||
|
|
||||||
|
/* Transform S-boxes and subkeys with key */
|
||||||
|
Blowfish_expand0state(c, k, len);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static void
|
||||||
|
blf_enc(blf_ctx *c, uint32_t *data, uint16_t blocks)
|
||||||
|
{
|
||||||
|
uint32_t *d;
|
||||||
|
uint16_t i;
|
||||||
|
|
||||||
|
d = data;
|
||||||
|
for(i = 0; i < blocks; i++) {
|
||||||
|
Blowfish_encipher(c, d, d + 1);
|
||||||
|
d += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef _DEBUG_BLOWFISH
|
||||||
|
static void
|
||||||
|
blf_dec(blf_ctx *c, uint32_t *data, uint16_t blocks)
|
||||||
|
{
|
||||||
|
uint32_t *d;
|
||||||
|
uint16_t i;
|
||||||
|
|
||||||
|
d = data;
|
||||||
|
for(i = 0; i < blocks; i++) {
|
||||||
|
Blowfish_decipher(c, d, d + 1);
|
||||||
|
d += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if 0
|
||||||
|
static void
|
||||||
|
blf_ecb_encrypt(blf_ctx *c, uint8_t *data, uint32_t len)
|
||||||
|
{
|
||||||
|
uint32_t l, r;
|
||||||
|
uint32_t i;
|
||||||
|
|
||||||
|
for(i = 0; i < len; i += 8) {
|
||||||
|
l = data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3];
|
||||||
|
r = data[4] << 24 | data[5] << 16 | data[6] << 8 | data[7];
|
||||||
|
Blowfish_encipher(c, &l, &r);
|
||||||
|
data[0] = (uint8_t)(l >> 24 & 0xff);
|
||||||
|
data[1] = l >> 16 & 0xff;
|
||||||
|
data[2] = l >> 8 & 0xff;
|
||||||
|
data[3] = l & 0xff;
|
||||||
|
data[4] = (uint8_t)(r >> 24 & 0xff);
|
||||||
|
data[5] = r >> 16 & 0xff;
|
||||||
|
data[6] = r >> 8 & 0xff;
|
||||||
|
data[7] = r & 0xff;
|
||||||
|
data += 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
blf_ecb_decrypt(blf_ctx *c, uint8_t *data, uint32_t len)
|
||||||
|
{
|
||||||
|
uint32_t l, r;
|
||||||
|
uint32_t i;
|
||||||
|
|
||||||
|
for(i = 0; i < len; i += 8) {
|
||||||
|
l = data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3];
|
||||||
|
r = data[4] << 24 | data[5] << 16 | data[6] << 8 | data[7];
|
||||||
|
Blowfish_decipher(c, &l, &r);
|
||||||
|
data[0] = (uint8_t)(l >> 24 & 0xff);
|
||||||
|
data[1] = l >> 16 & 0xff;
|
||||||
|
data[2] = l >> 8 & 0xff;
|
||||||
|
data[3] = l & 0xff;
|
||||||
|
data[4] = (uint8_t)(r >> 24 & 0xff);
|
||||||
|
data[5] = r >> 16 & 0xff;
|
||||||
|
data[6] = r >> 8 & 0xff;
|
||||||
|
data[7] = r & 0xff;
|
||||||
|
data += 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
blf_cbc_encrypt(blf_ctx *c, uint8_t *iv, uint8_t *data, uint32_t len)
|
||||||
|
{
|
||||||
|
uint32_t l, r;
|
||||||
|
uint32_t i, j;
|
||||||
|
|
||||||
|
for(i = 0; i < len; i += 8) {
|
||||||
|
for(j = 0; j < 8; j++)
|
||||||
|
data[j] ^= iv[j];
|
||||||
|
l = data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3];
|
||||||
|
r = data[4] << 24 | data[5] << 16 | data[6] << 8 | data[7];
|
||||||
|
Blowfish_encipher(c, &l, &r);
|
||||||
|
data[0] = (uint8_t)(l >> 24 & 0xff);
|
||||||
|
data[1] = l >> 16 & 0xff;
|
||||||
|
data[2] = l >> 8 & 0xff;
|
||||||
|
data[3] = l & 0xff;
|
||||||
|
data[4] = (uint8_t)(r >> 24 & 0xff);
|
||||||
|
data[5] = r >> 16 & 0xff;
|
||||||
|
data[6] = r >> 8 & 0xff;
|
||||||
|
data[7] = r & 0xff;
|
||||||
|
iv = data;
|
||||||
|
data += 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
blf_cbc_decrypt(blf_ctx *c, uint8_t *iva, uint8_t *data, uint32_t len)
|
||||||
|
{
|
||||||
|
uint32_t l, r;
|
||||||
|
uint8_t *iv;
|
||||||
|
uint32_t i, j;
|
||||||
|
|
||||||
|
iv = data + len - 16;
|
||||||
|
data = data + len - 8;
|
||||||
|
for(i = len - 8; i >= 8; i -= 8) {
|
||||||
|
l = data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3];
|
||||||
|
r = data[4] << 24 | data[5] << 16 | data[6] << 8 | data[7];
|
||||||
|
Blowfish_decipher(c, &l, &r);
|
||||||
|
data[0] = (uint8_t)(l >> 24 & 0xff);
|
||||||
|
data[1] = l >> 16 & 0xff;
|
||||||
|
data[2] = l >> 8 & 0xff;
|
||||||
|
data[3] = l & 0xff;
|
||||||
|
data[4] = (uint8_t)(r >> 24 & 0xff);
|
||||||
|
data[5] = r >> 16 & 0xff;
|
||||||
|
data[6] = r >> 8 & 0xff;
|
||||||
|
data[7] = r & 0xff;
|
||||||
|
for(j = 0; j < 8; j++)
|
||||||
|
data[j] ^= iv[j];
|
||||||
|
iv -= 8;
|
||||||
|
data -= 8;
|
||||||
|
}
|
||||||
|
l = data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3];
|
||||||
|
r = data[4] << 24 | data[5] << 16 | data[6] << 8 | data[7];
|
||||||
|
Blowfish_decipher(c, &l, &r);
|
||||||
|
data[0] = (uint8_t)(l >> 24 & 0xff);
|
||||||
|
data[1] = l >> 16 & 0xff;
|
||||||
|
data[2] = l >> 8 & 0xff;
|
||||||
|
data[3] = l & 0xff;
|
||||||
|
data[4] = (uint8_t)(r >> 24 & 0xff);
|
||||||
|
data[5] = r >> 16 & 0xff;
|
||||||
|
data[6] = r >> 8 & 0xff;
|
||||||
|
data[7] = r & 0xff;
|
||||||
|
for(j = 0; j < 8; j++)
|
||||||
|
data[j] ^= iva[j];
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef _DEBUG_BLOWFISH
|
||||||
|
static void
|
||||||
|
report(uint32_t data[], uint16_t len)
|
||||||
|
{
|
||||||
|
int i;
|
||||||
|
for(i = 0; i < len; i += 2)
|
||||||
|
printf("Block %d: 0x%08lx 0x%08lx.\n",
|
||||||
|
i / 2, (unsigned long)data[i], (unsigned long)data[i + 1]);
|
||||||
|
}
|
||||||
|
int
|
||||||
|
main(void)
|
||||||
|
{
|
||||||
|
blf_ctx c;
|
||||||
|
char key[] = "AAAAA";
|
||||||
|
char key2[] = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
uint32_t data[10];
|
||||||
|
uint32_t data2[] =
|
||||||
|
{0x424c4f57l, 0x46495348l};
|
||||||
|
|
||||||
|
uint16_t i;
|
||||||
|
|
||||||
|
/* First test */
|
||||||
|
for(i = 0; i < 10; i++)
|
||||||
|
data[i] = i;
|
||||||
|
|
||||||
|
blf_key(&c, (uint8_t *) key, 5);
|
||||||
|
blf_enc(&c, data, 5);
|
||||||
|
blf_dec(&c, data, 1);
|
||||||
|
blf_dec(&c, data + 2, 4);
|
||||||
|
printf("Should read as 0 - 9.\n");
|
||||||
|
report(data, 10);
|
||||||
|
|
||||||
|
/* Second test */
|
||||||
|
blf_key(&c, (uint8_t *) key2, (uint16_t)strlen(key2));
|
||||||
|
blf_enc(&c, data2, 1);
|
||||||
|
printf("\nShould read as: 0x324ed0fe 0xf413a203.\n");
|
||||||
|
report(data2, 2);
|
||||||
|
blf_dec(&c, data2, 1);
|
||||||
|
printf("\nShould read as: 0x424c4f57 0x46495348.\n");
|
||||||
|
report(data2, 2);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* !defined(HAVE_BCRYPT_PBKDF) && \
|
||||||
|
(!defined(HAVE_BLOWFISH_INITSTATE) || \
|
||||||
|
!defined(HAVE_BLOWFISH_EXPAND0STATE) || \
|
||||||
|
'!defined(HAVE_BLF_ENC)) */
|
||||||
|
|
||||||
|
#endif /* defined(LIBSSH2_BCRYPT_PBKDF_C) || defined(_DEBUG_BLOWFISH) */
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
* chacha-merged.c version 20080118
|
||||||
|
* D. J. Bernstein
|
||||||
|
* Public domain.
|
||||||
|
* Copyright not intended 2024.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: SAX-PD-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "libssh2_priv.h"
|
||||||
|
|
||||||
|
#include "chacha.h"
|
||||||
|
|
||||||
|
/* $OpenBSD: chacha.c,v 1.1 2013/11/21 00:45:44 djm Exp $ */
|
||||||
|
|
||||||
|
typedef unsigned char u8;
|
||||||
|
typedef unsigned int u32;
|
||||||
|
|
||||||
|
typedef struct chacha_ctx chacha_ctx;
|
||||||
|
|
||||||
|
#define U8C(v) (v##U)
|
||||||
|
#define U32C(v) (v##U)
|
||||||
|
|
||||||
|
#define U8V(v) ((u8)(v) & U8C(0xFF))
|
||||||
|
#define U32V(v) ((u32)(v) & U32C(0xFFFFFFFF))
|
||||||
|
|
||||||
|
#define ROTL32(v, n) \
|
||||||
|
(U32V((v) << (n)) | ((v) >> (32 - (n))))
|
||||||
|
|
||||||
|
#define U8TO32_LITTLE(p) \
|
||||||
|
(((u32)((p)[0]) ) | \
|
||||||
|
((u32)((p)[1]) << 8) | \
|
||||||
|
((u32)((p)[2]) << 16) | \
|
||||||
|
((u32)((p)[3]) << 24))
|
||||||
|
|
||||||
|
#define U32TO8_LITTLE(p, v) \
|
||||||
|
do { \
|
||||||
|
(p)[0] = U8V((v) ); \
|
||||||
|
(p)[1] = U8V((v) >> 8); \
|
||||||
|
(p)[2] = U8V((v) >> 16); \
|
||||||
|
(p)[3] = U8V((v) >> 24); \
|
||||||
|
} while (0)
|
||||||
|
|
||||||
|
#define ROTATE(v,c) (ROTL32(v,c))
|
||||||
|
#define XOR(v,w) ((v) ^ (w))
|
||||||
|
#define PLUS(v,w) (U32V((v) + (w)))
|
||||||
|
#define PLUSONE(v) (PLUS((v),1))
|
||||||
|
|
||||||
|
#define QUARTERROUND(a,b,c,d) \
|
||||||
|
a = PLUS(a,b); d = ROTATE(XOR(d,a),16); \
|
||||||
|
c = PLUS(c,d); b = ROTATE(XOR(b,c),12); \
|
||||||
|
a = PLUS(a,b); d = ROTATE(XOR(d,a), 8); \
|
||||||
|
c = PLUS(c,d); b = ROTATE(XOR(b,c), 7);
|
||||||
|
|
||||||
|
static const char sigma[17] = "expand 32-byte k";
|
||||||
|
static const char tau[17] = "expand 16-byte k";
|
||||||
|
|
||||||
|
void
|
||||||
|
chacha_keysetup(chacha_ctx *x, const u8 *k, u32 kbits)
|
||||||
|
{
|
||||||
|
const char *constants;
|
||||||
|
|
||||||
|
x->input[4] = U8TO32_LITTLE(k + 0);
|
||||||
|
x->input[5] = U8TO32_LITTLE(k + 4);
|
||||||
|
x->input[6] = U8TO32_LITTLE(k + 8);
|
||||||
|
x->input[7] = U8TO32_LITTLE(k + 12);
|
||||||
|
if(kbits == 256) { /* recommended */
|
||||||
|
k += 16;
|
||||||
|
constants = sigma;
|
||||||
|
}
|
||||||
|
else { /* kbits == 128 */
|
||||||
|
constants = tau;
|
||||||
|
}
|
||||||
|
x->input[8] = U8TO32_LITTLE(k + 0);
|
||||||
|
x->input[9] = U8TO32_LITTLE(k + 4);
|
||||||
|
x->input[10] = U8TO32_LITTLE(k + 8);
|
||||||
|
x->input[11] = U8TO32_LITTLE(k + 12);
|
||||||
|
x->input[0] = U8TO32_LITTLE(constants + 0);
|
||||||
|
x->input[1] = U8TO32_LITTLE(constants + 4);
|
||||||
|
x->input[2] = U8TO32_LITTLE(constants + 8);
|
||||||
|
x->input[3] = U8TO32_LITTLE(constants + 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
chacha_ivsetup(chacha_ctx *x, const u8 *iv, const u8 *counter)
|
||||||
|
{
|
||||||
|
x->input[12] = counter == NULL ? 0 : U8TO32_LITTLE(counter + 0);
|
||||||
|
x->input[13] = counter == NULL ? 0 : U8TO32_LITTLE(counter + 4);
|
||||||
|
x->input[14] = U8TO32_LITTLE(iv + 0);
|
||||||
|
x->input[15] = U8TO32_LITTLE(iv + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
chacha_encrypt_bytes(chacha_ctx *x, const u8 *m, u8 *c, u32 bytes)
|
||||||
|
{
|
||||||
|
u32 x0, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11, x12, x13, x14, x15;
|
||||||
|
u32 j0, j1, j2, j3, j4, j5, j6, j7, j8, j9, j10, j11, j12, j13, j14, j15;
|
||||||
|
u8 *ctarget = NULL;
|
||||||
|
u8 tmp[64];
|
||||||
|
u_int i;
|
||||||
|
|
||||||
|
if(!bytes)
|
||||||
|
return;
|
||||||
|
|
||||||
|
j0 = x->input[0];
|
||||||
|
j1 = x->input[1];
|
||||||
|
j2 = x->input[2];
|
||||||
|
j3 = x->input[3];
|
||||||
|
j4 = x->input[4];
|
||||||
|
j5 = x->input[5];
|
||||||
|
j6 = x->input[6];
|
||||||
|
j7 = x->input[7];
|
||||||
|
j8 = x->input[8];
|
||||||
|
j9 = x->input[9];
|
||||||
|
j10 = x->input[10];
|
||||||
|
j11 = x->input[11];
|
||||||
|
j12 = x->input[12];
|
||||||
|
j13 = x->input[13];
|
||||||
|
j14 = x->input[14];
|
||||||
|
j15 = x->input[15];
|
||||||
|
|
||||||
|
for(;;) {
|
||||||
|
if(bytes < 64) {
|
||||||
|
for(i = 0; i < bytes;++i) tmp[i] = m[i];
|
||||||
|
m = tmp;
|
||||||
|
ctarget = c;
|
||||||
|
c = tmp;
|
||||||
|
}
|
||||||
|
x0 = j0;
|
||||||
|
x1 = j1;
|
||||||
|
x2 = j2;
|
||||||
|
x3 = j3;
|
||||||
|
x4 = j4;
|
||||||
|
x5 = j5;
|
||||||
|
x6 = j6;
|
||||||
|
x7 = j7;
|
||||||
|
x8 = j8;
|
||||||
|
x9 = j9;
|
||||||
|
x10 = j10;
|
||||||
|
x11 = j11;
|
||||||
|
x12 = j12;
|
||||||
|
x13 = j13;
|
||||||
|
x14 = j14;
|
||||||
|
x15 = j15;
|
||||||
|
for(i = 20; i > 0; i -= 2) {
|
||||||
|
QUARTERROUND(x0, x4, x8, x12)
|
||||||
|
QUARTERROUND(x1, x5, x9, x13)
|
||||||
|
QUARTERROUND(x2, x6, x10, x14)
|
||||||
|
QUARTERROUND(x3, x7, x11, x15)
|
||||||
|
QUARTERROUND(x0, x5, x10, x15)
|
||||||
|
QUARTERROUND(x1, x6, x11, x12)
|
||||||
|
QUARTERROUND(x2, x7, x8, x13)
|
||||||
|
QUARTERROUND(x3, x4, x9, x14)
|
||||||
|
}
|
||||||
|
x0 = PLUS(x0, j0);
|
||||||
|
x1 = PLUS(x1, j1);
|
||||||
|
x2 = PLUS(x2, j2);
|
||||||
|
x3 = PLUS(x3, j3);
|
||||||
|
x4 = PLUS(x4, j4);
|
||||||
|
x5 = PLUS(x5, j5);
|
||||||
|
x6 = PLUS(x6, j6);
|
||||||
|
x7 = PLUS(x7, j7);
|
||||||
|
x8 = PLUS(x8, j8);
|
||||||
|
x9 = PLUS(x9, j9);
|
||||||
|
x10 = PLUS(x10, j10);
|
||||||
|
x11 = PLUS(x11, j11);
|
||||||
|
x12 = PLUS(x12, j12);
|
||||||
|
x13 = PLUS(x13, j13);
|
||||||
|
x14 = PLUS(x14, j14);
|
||||||
|
x15 = PLUS(x15, j15);
|
||||||
|
|
||||||
|
x0 = XOR(x0, U8TO32_LITTLE(m + 0));
|
||||||
|
x1 = XOR(x1, U8TO32_LITTLE(m + 4));
|
||||||
|
x2 = XOR(x2, U8TO32_LITTLE(m + 8));
|
||||||
|
x3 = XOR(x3, U8TO32_LITTLE(m + 12));
|
||||||
|
x4 = XOR(x4, U8TO32_LITTLE(m + 16));
|
||||||
|
x5 = XOR(x5, U8TO32_LITTLE(m + 20));
|
||||||
|
x6 = XOR(x6, U8TO32_LITTLE(m + 24));
|
||||||
|
x7 = XOR(x7, U8TO32_LITTLE(m + 28));
|
||||||
|
x8 = XOR(x8, U8TO32_LITTLE(m + 32));
|
||||||
|
x9 = XOR(x9, U8TO32_LITTLE(m + 36));
|
||||||
|
x10 = XOR(x10, U8TO32_LITTLE(m + 40));
|
||||||
|
x11 = XOR(x11, U8TO32_LITTLE(m + 44));
|
||||||
|
x12 = XOR(x12, U8TO32_LITTLE(m + 48));
|
||||||
|
x13 = XOR(x13, U8TO32_LITTLE(m + 52));
|
||||||
|
x14 = XOR(x14, U8TO32_LITTLE(m + 56));
|
||||||
|
x15 = XOR(x15, U8TO32_LITTLE(m + 60));
|
||||||
|
|
||||||
|
j12 = PLUSONE(j12);
|
||||||
|
if(!j12) {
|
||||||
|
j13 = PLUSONE(j13);
|
||||||
|
/* stopping at 2^70 bytes per nonce is user's responsibility */
|
||||||
|
}
|
||||||
|
|
||||||
|
U32TO8_LITTLE(c + 0, x0);
|
||||||
|
U32TO8_LITTLE(c + 4, x1);
|
||||||
|
U32TO8_LITTLE(c + 8, x2);
|
||||||
|
U32TO8_LITTLE(c + 12, x3);
|
||||||
|
U32TO8_LITTLE(c + 16, x4);
|
||||||
|
U32TO8_LITTLE(c + 20, x5);
|
||||||
|
U32TO8_LITTLE(c + 24, x6);
|
||||||
|
U32TO8_LITTLE(c + 28, x7);
|
||||||
|
U32TO8_LITTLE(c + 32, x8);
|
||||||
|
U32TO8_LITTLE(c + 36, x9);
|
||||||
|
U32TO8_LITTLE(c + 40, x10);
|
||||||
|
U32TO8_LITTLE(c + 44, x11);
|
||||||
|
U32TO8_LITTLE(c + 48, x12);
|
||||||
|
U32TO8_LITTLE(c + 52, x13);
|
||||||
|
U32TO8_LITTLE(c + 56, x14);
|
||||||
|
U32TO8_LITTLE(c + 60, x15);
|
||||||
|
|
||||||
|
if(bytes <= 64) {
|
||||||
|
if(bytes < 64) {
|
||||||
|
for(i = 0; i < bytes;++i) ctarget[i] = c[i];
|
||||||
|
}
|
||||||
|
x->input[12] = j12;
|
||||||
|
x->input[13] = j13;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bytes -= 64;
|
||||||
|
c += 64;
|
||||||
|
m += 64;
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
/* $OpenBSD: chacha.h,v 1.4 2016/08/27 04:04:56 guenther Exp $ */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* chacha-merged.c version 20080118
|
||||||
|
* D. J. Bernstein
|
||||||
|
* Public domain.
|
||||||
|
* Copyright not intended 2024.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: SAX-PD-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef CHACHA_H
|
||||||
|
#define CHACHA_H
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
struct chacha_ctx {
|
||||||
|
u_int input[16];
|
||||||
|
};
|
||||||
|
|
||||||
|
#define CHACHA_MINKEYLEN 16
|
||||||
|
#define CHACHA_NONCELEN 8
|
||||||
|
#define CHACHA_CTRLEN 8
|
||||||
|
#define CHACHA_STATELEN (CHACHA_NONCELEN+CHACHA_CTRLEN)
|
||||||
|
#define CHACHA_BLOCKLEN 64
|
||||||
|
|
||||||
|
void chacha_keysetup(struct chacha_ctx *x, const u_char *k, u_int kbits);
|
||||||
|
void chacha_ivsetup(struct chacha_ctx *x, const u_char *iv, const u_char *ctr);
|
||||||
|
void chacha_encrypt_bytes(struct chacha_ctx *x, const u_char *m,
|
||||||
|
u_char *c, u_int bytes);
|
||||||
|
|
||||||
|
#endif /* CHACHA_H */
|
||||||
|
|
||||||
+3075
File diff suppressed because it is too large.
Load diff
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#ifndef LIBSSH2_CHANNEL_H
|
||||||
|
#define LIBSSH2_CHANNEL_H
|
||||||
|
/* Copyright (C) Daniel Stenberg
|
||||||
|
*
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* _libssh2_channel_receive_window_adjust
|
||||||
|
*
|
||||||
|
* Adjust the receive window for a channel by adjustment bytes. If the amount
|
||||||
|
* to be adjusted is less than LIBSSH2_CHANNEL_MINADJUST and force is 0 the
|
||||||
|
* adjustment amount will be queued for a later packet.
|
||||||
|
*
|
||||||
|
* Always non-blocking.
|
||||||
|
*/
|
||||||
|
int _libssh2_channel_receive_window_adjust(LIBSSH2_CHANNEL * channel,
|
||||||
|
uint32_t adjustment,
|
||||||
|
unsigned char force,
|
||||||
|
unsigned int *store);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* _libssh2_channel_flush
|
||||||
|
*
|
||||||
|
* Flush data from one (or all) stream
|
||||||
|
* Returns number of bytes flushed, or negative on failure
|
||||||
|
*/
|
||||||
|
int _libssh2_channel_flush(LIBSSH2_CHANNEL *channel, int streamid);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* _libssh2_channel_free
|
||||||
|
*
|
||||||
|
* Make sure a channel is closed, then remove the channel from the session
|
||||||
|
* and free its resource(s)
|
||||||
|
*
|
||||||
|
* Returns 0 on success, negative on failure
|
||||||
|
*/
|
||||||
|
int _libssh2_channel_free(LIBSSH2_CHANNEL *channel);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_channel_extended_data(LIBSSH2_CHANNEL *channel, int ignore_mode);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* _libssh2_channel_write
|
||||||
|
*
|
||||||
|
* Send data to a channel
|
||||||
|
*/
|
||||||
|
ssize_t
|
||||||
|
_libssh2_channel_write(LIBSSH2_CHANNEL *channel, int stream_id,
|
||||||
|
const unsigned char *buf, size_t buflen);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* _libssh2_channel_open
|
||||||
|
*
|
||||||
|
* Establish a generic session channel
|
||||||
|
*/
|
||||||
|
LIBSSH2_CHANNEL *
|
||||||
|
_libssh2_channel_open(LIBSSH2_SESSION * session, const char *channel_type,
|
||||||
|
uint32_t channel_type_len,
|
||||||
|
uint32_t window_size,
|
||||||
|
uint32_t packet_size,
|
||||||
|
const unsigned char *message, size_t message_len);
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* _libssh2_channel_process_startup
|
||||||
|
*
|
||||||
|
* Primitive for libssh2_channel_(shell|exec|subsystem)
|
||||||
|
*/
|
||||||
|
int
|
||||||
|
_libssh2_channel_process_startup(LIBSSH2_CHANNEL *channel,
|
||||||
|
const char *request, size_t request_len,
|
||||||
|
const char *message, size_t message_len);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* _libssh2_channel_read
|
||||||
|
*
|
||||||
|
* Read data from a channel
|
||||||
|
*
|
||||||
|
* It is important to not return 0 until the currently read channel is
|
||||||
|
* complete. If we read stuff from the wire but it was no payload data to fill
|
||||||
|
* in the buffer with, we MUST make sure to return PACKET_EAGAIN.
|
||||||
|
*/
|
||||||
|
ssize_t _libssh2_channel_read(LIBSSH2_CHANNEL *channel, int stream_id,
|
||||||
|
char *buf, size_t buflen);
|
||||||
|
|
||||||
|
uint32_t _libssh2_channel_nextid(LIBSSH2_SESSION * session);
|
||||||
|
|
||||||
|
LIBSSH2_CHANNEL *_libssh2_channel_locate(LIBSSH2_SESSION * session,
|
||||||
|
uint32_t channel_id);
|
||||||
|
|
||||||
|
size_t _libssh2_channel_packet_data_len(LIBSSH2_CHANNEL * channel,
|
||||||
|
int stream_id);
|
||||||
|
|
||||||
|
int _libssh2_channel_close(LIBSSH2_CHANNEL * channel);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* _libssh2_channel_forward_cancel
|
||||||
|
*
|
||||||
|
* Stop listening on a remote port and free the listener
|
||||||
|
* Toss out any pending (un-accept()ed) connections
|
||||||
|
*
|
||||||
|
* Return 0 on success, LIBSSH2_ERROR_EAGAIN if would block, -1 on error
|
||||||
|
*/
|
||||||
|
int _libssh2_channel_forward_cancel(LIBSSH2_LISTENER *listener);
|
||||||
|
|
||||||
|
#endif /* LIBSSH2_CHANNEL_H */
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2013 Damien Miller <djm@mindrot.org>
|
||||||
|
*
|
||||||
|
* Adapted by Will Cosgrove <will@panic.com> for libssh2
|
||||||
|
*
|
||||||
|
* Permission to use, copy, modify, and distribute this software for any
|
||||||
|
* purpose with or without fee is hereby granted, provided that the above
|
||||||
|
* copyright notice and this permission notice appear in all copies.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||||
|
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||||
|
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* $OpenBSD: cipher-chachapoly.c,v 1.8 2016/08/03 05:41:57 djm Exp $ */
|
||||||
|
|
||||||
|
#include "libssh2_priv.h"
|
||||||
|
#include "misc.h"
|
||||||
|
#include "cipher-chachapoly.h"
|
||||||
|
|
||||||
|
int
|
||||||
|
chachapoly_timingsafe_bcmp(const void *b1, const void *b2, size_t n);
|
||||||
|
|
||||||
|
int
|
||||||
|
chachapoly_init(struct chachapoly_ctx *ctx, const u_char *key, u_int keylen)
|
||||||
|
{
|
||||||
|
if(keylen != (32 + 32)) /* 2 x 256 bit keys */
|
||||||
|
return LIBSSH2_ERROR_INVAL;
|
||||||
|
chacha_keysetup(&ctx->main_ctx, key, 256);
|
||||||
|
chacha_keysetup(&ctx->header_ctx, key + 32, 256);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* chachapoly_crypt() operates as following:
|
||||||
|
* En/decrypt with header key 'aadlen' bytes from 'src', storing result
|
||||||
|
* to 'dest'. The ciphertext here is treated as additional authenticated
|
||||||
|
* data for MAC calculation.
|
||||||
|
* En/decrypt 'len' bytes at offset 'aadlen' from 'src' to 'dest'. Use
|
||||||
|
* POLY1305_TAGLEN bytes at offset 'len'+'aadlen' as the authentication
|
||||||
|
* tag. This tag is written on encryption and verified on decryption.
|
||||||
|
*/
|
||||||
|
int
|
||||||
|
chachapoly_crypt(struct chachapoly_ctx *ctx, u_int seqnr, u_char *dest,
|
||||||
|
const u_char *src, u_int len, u_int aadlen, int do_encrypt)
|
||||||
|
{
|
||||||
|
u_char seqbuf[8];
|
||||||
|
const u_char one[8] = { 1, 0, 0, 0, 0, 0, 0, 0 }; /* NB little-endian */
|
||||||
|
u_char expected_tag[POLY1305_TAGLEN], poly_key[POLY1305_KEYLEN];
|
||||||
|
int r = LIBSSH2_ERROR_INVAL;
|
||||||
|
unsigned char *ptr = NULL;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Run ChaCha20 once to generate the Poly1305 key. The IV is the
|
||||||
|
* packet sequence number.
|
||||||
|
*/
|
||||||
|
memset(poly_key, 0, sizeof(poly_key));
|
||||||
|
ptr = &seqbuf[0];
|
||||||
|
_libssh2_store_u64(&ptr, seqnr);
|
||||||
|
chacha_ivsetup(&ctx->main_ctx, seqbuf, NULL);
|
||||||
|
chacha_encrypt_bytes(&ctx->main_ctx,
|
||||||
|
poly_key, poly_key, sizeof(poly_key));
|
||||||
|
|
||||||
|
/* If decrypting, check tag before anything else */
|
||||||
|
if(!do_encrypt) {
|
||||||
|
const u_char *tag = src + aadlen + len;
|
||||||
|
|
||||||
|
poly1305_auth(expected_tag, src, aadlen + len, poly_key);
|
||||||
|
if(chachapoly_timingsafe_bcmp(expected_tag, tag, POLY1305_TAGLEN)
|
||||||
|
!= 0) {
|
||||||
|
r = LIBSSH2_ERROR_DECRYPT;
|
||||||
|
goto out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crypt additional data */
|
||||||
|
if(aadlen) {
|
||||||
|
chacha_ivsetup(&ctx->header_ctx, seqbuf, NULL);
|
||||||
|
chacha_encrypt_bytes(&ctx->header_ctx, src, dest, aadlen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Set Chacha's block counter to 1 */
|
||||||
|
chacha_ivsetup(&ctx->main_ctx, seqbuf, one);
|
||||||
|
chacha_encrypt_bytes(&ctx->main_ctx, src + aadlen,
|
||||||
|
dest + aadlen, len);
|
||||||
|
|
||||||
|
/* If encrypting, calculate and append tag */
|
||||||
|
if(do_encrypt) {
|
||||||
|
poly1305_auth(dest + aadlen + len, dest, aadlen + len,
|
||||||
|
poly_key);
|
||||||
|
}
|
||||||
|
r = 0;
|
||||||
|
out:
|
||||||
|
memset(expected_tag, 0, sizeof(expected_tag));
|
||||||
|
memset(seqbuf, 0, sizeof(seqbuf));
|
||||||
|
memset(poly_key, 0, sizeof(poly_key));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decrypt and extract the encrypted packet length */
|
||||||
|
int
|
||||||
|
chachapoly_get_length(struct chachapoly_ctx *ctx, unsigned int *plenp,
|
||||||
|
unsigned int seqnr, const unsigned char *cp,
|
||||||
|
unsigned int len)
|
||||||
|
{
|
||||||
|
u_char buf[4], seqbuf[8];
|
||||||
|
unsigned char *ptr = NULL;
|
||||||
|
|
||||||
|
if(len < 4)
|
||||||
|
return -1;
|
||||||
|
ptr = &seqbuf[0];
|
||||||
|
_libssh2_store_u64(&ptr, seqnr);
|
||||||
|
chacha_ivsetup(&ctx->header_ctx, seqbuf, NULL);
|
||||||
|
chacha_encrypt_bytes(&ctx->header_ctx, cp, buf, 4);
|
||||||
|
*plenp = _libssh2_ntohu32(buf);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
chachapoly_timingsafe_bcmp(const void *b1, const void *b2, size_t n)
|
||||||
|
{
|
||||||
|
const unsigned char *p1 = b1, *p2 = b2;
|
||||||
|
int ret = 0;
|
||||||
|
|
||||||
|
for(; n > 0; n--)
|
||||||
|
ret |= *p1++ ^ *p2++;
|
||||||
|
return (ret != 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/* $OpenBSD: cipher-chachapoly.h,v 1.4 2014/06/24 01:13:21 djm Exp $ */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) Damien Miller 2013 <djm@mindrot.org>
|
||||||
|
*
|
||||||
|
* Permission to use, copy, modify, and distribute this software for any
|
||||||
|
* purpose with or without fee is hereby granted, provided that the above
|
||||||
|
* copyright notice and this permission notice appear in all copies.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||||
|
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||||
|
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
*/
|
||||||
|
#ifndef CHACHA_POLY_AEAD_H
|
||||||
|
#define CHACHA_POLY_AEAD_H
|
||||||
|
|
||||||
|
#include "chacha.h"
|
||||||
|
#include "poly1305.h"
|
||||||
|
|
||||||
|
#define CHACHA_KEYLEN 32 /* Only 256 bit keys used here */
|
||||||
|
|
||||||
|
struct chachapoly_ctx {
|
||||||
|
struct chacha_ctx main_ctx, header_ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
int chachapoly_init(struct chachapoly_ctx *cpctx,
|
||||||
|
const u_char *key, u_int keylen);
|
||||||
|
int chachapoly_crypt(struct chachapoly_ctx *cpctx, u_int seqnr,
|
||||||
|
u_char *dest, const u_char *src, u_int len, u_int aadlen,
|
||||||
|
int do_encrypt);
|
||||||
|
int chachapoly_get_length(struct chachapoly_ctx *cpctx,
|
||||||
|
u_int *plenp, u_int seqnr, const u_char *cp,
|
||||||
|
u_int len);
|
||||||
|
|
||||||
|
#endif /* CHACHA_POLY_AEAD_H */
|
||||||
+381
@@ -0,0 +1,381 @@
|
|||||||
|
/* Copyright (C) Sara Golemon <sarag@libssh2.org>
|
||||||
|
* Copyright (C) Daniel Stenberg <daniel@haxx.se>
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "libssh2_priv.h"
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_HAVE_ZLIB
|
||||||
|
#include <zlib.h>
|
||||||
|
#undef compress /* dodge name clash with ZLIB macro */
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "comp.h"
|
||||||
|
|
||||||
|
/* ********
|
||||||
|
* none *
|
||||||
|
******** */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* comp_method_none_comp
|
||||||
|
*
|
||||||
|
* Minimalist compression: Absolutely none
|
||||||
|
*/
|
||||||
|
static int
|
||||||
|
comp_method_none_comp(LIBSSH2_SESSION *session,
|
||||||
|
unsigned char *dest,
|
||||||
|
size_t *dest_len,
|
||||||
|
const unsigned char *src,
|
||||||
|
size_t src_len,
|
||||||
|
void **abstract)
|
||||||
|
{
|
||||||
|
(void)session;
|
||||||
|
(void)abstract;
|
||||||
|
(void)dest;
|
||||||
|
(void)dest_len;
|
||||||
|
(void)src;
|
||||||
|
(void)src_len;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* comp_method_none_decomp
|
||||||
|
*
|
||||||
|
* Minimalist decompression: Absolutely none
|
||||||
|
*/
|
||||||
|
static int
|
||||||
|
comp_method_none_decomp(LIBSSH2_SESSION * session,
|
||||||
|
unsigned char **dest,
|
||||||
|
size_t *dest_len,
|
||||||
|
size_t payload_limit,
|
||||||
|
const unsigned char *src,
|
||||||
|
size_t src_len, void **abstract)
|
||||||
|
{
|
||||||
|
(void)session;
|
||||||
|
(void)payload_limit;
|
||||||
|
(void)abstract;
|
||||||
|
*dest = (unsigned char *) src;
|
||||||
|
*dest_len = src_len;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
static const LIBSSH2_COMP_METHOD comp_method_none = {
|
||||||
|
"none",
|
||||||
|
0, /* not really compressing */
|
||||||
|
0, /* isn't used in userauth, go figure */
|
||||||
|
NULL,
|
||||||
|
comp_method_none_comp,
|
||||||
|
comp_method_none_decomp,
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_HAVE_ZLIB
|
||||||
|
/* ********
|
||||||
|
* zlib *
|
||||||
|
******** */
|
||||||
|
|
||||||
|
/* Memory management wrappers
|
||||||
|
* Yes, I realize we're doing a callback to a callback,
|
||||||
|
* Deal...
|
||||||
|
*/
|
||||||
|
|
||||||
|
static voidpf
|
||||||
|
comp_method_zlib_alloc(voidpf opaque, uInt items, uInt size)
|
||||||
|
{
|
||||||
|
LIBSSH2_SESSION *session = (LIBSSH2_SESSION *) opaque;
|
||||||
|
|
||||||
|
return (voidpf) LIBSSH2_ALLOC(session, items * size);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
comp_method_zlib_free(voidpf opaque, voidpf address)
|
||||||
|
{
|
||||||
|
LIBSSH2_SESSION *session = (LIBSSH2_SESSION *) opaque;
|
||||||
|
|
||||||
|
LIBSSH2_FREE(session, address);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* libssh2_comp_method_zlib_init
|
||||||
|
* All your bandwidth are belong to us (so save some)
|
||||||
|
*/
|
||||||
|
static int
|
||||||
|
comp_method_zlib_init(LIBSSH2_SESSION * session, int compr,
|
||||||
|
void **abstract)
|
||||||
|
{
|
||||||
|
z_stream *strm;
|
||||||
|
int status;
|
||||||
|
|
||||||
|
strm = LIBSSH2_CALLOC(session, sizeof(z_stream));
|
||||||
|
if(!strm) {
|
||||||
|
return _libssh2_error(session, LIBSSH2_ERROR_ALLOC,
|
||||||
|
"Unable to allocate memory for "
|
||||||
|
"zlib compression/decompression");
|
||||||
|
}
|
||||||
|
|
||||||
|
strm->opaque = (voidpf) session;
|
||||||
|
strm->zalloc = (alloc_func) comp_method_zlib_alloc;
|
||||||
|
strm->zfree = (free_func) comp_method_zlib_free;
|
||||||
|
if(compr) {
|
||||||
|
/* deflate */
|
||||||
|
status = deflateInit(strm, Z_DEFAULT_COMPRESSION);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
/* inflate */
|
||||||
|
status = inflateInit(strm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(status != Z_OK) {
|
||||||
|
LIBSSH2_FREE(session, strm);
|
||||||
|
_libssh2_debug((session, LIBSSH2_TRACE_TRANS,
|
||||||
|
"unhandled zlib error %d", status));
|
||||||
|
return LIBSSH2_ERROR_COMPRESS;
|
||||||
|
}
|
||||||
|
*abstract = strm;
|
||||||
|
|
||||||
|
return LIBSSH2_ERROR_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* libssh2_comp_method_zlib_comp
|
||||||
|
*
|
||||||
|
* Compresses source to destination. Without allocation.
|
||||||
|
*/
|
||||||
|
static int
|
||||||
|
comp_method_zlib_comp(LIBSSH2_SESSION *session,
|
||||||
|
unsigned char *dest,
|
||||||
|
|
||||||
|
/* dest_len is a pointer to allow this function to
|
||||||
|
update it with the final actual size used */
|
||||||
|
size_t *dest_len,
|
||||||
|
const unsigned char *src,
|
||||||
|
size_t src_len,
|
||||||
|
void **abstract)
|
||||||
|
{
|
||||||
|
z_stream *strm = *abstract;
|
||||||
|
uInt out_maxlen = (uInt)*dest_len;
|
||||||
|
int status;
|
||||||
|
|
||||||
|
strm->next_in = (unsigned char *) src;
|
||||||
|
strm->avail_in = (uInt)src_len;
|
||||||
|
strm->next_out = dest;
|
||||||
|
strm->avail_out = out_maxlen;
|
||||||
|
|
||||||
|
status = deflate(strm, Z_PARTIAL_FLUSH);
|
||||||
|
|
||||||
|
if((status == Z_OK) && (strm->avail_out > 0)) {
|
||||||
|
*dest_len = out_maxlen - strm->avail_out;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_libssh2_debug((session, LIBSSH2_TRACE_TRANS,
|
||||||
|
"unhandled zlib compression error %d, avail_out %u",
|
||||||
|
status, strm->avail_out));
|
||||||
|
return _libssh2_error(session, LIBSSH2_ERROR_ZLIB, "compression failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* libssh2_comp_method_zlib_decomp
|
||||||
|
*
|
||||||
|
* Decompresses source to destination. Allocates the output memory.
|
||||||
|
*/
|
||||||
|
static int
|
||||||
|
comp_method_zlib_decomp(LIBSSH2_SESSION * session,
|
||||||
|
unsigned char **dest,
|
||||||
|
size_t *dest_len,
|
||||||
|
size_t payload_limit,
|
||||||
|
const unsigned char *src,
|
||||||
|
size_t src_len, void **abstract)
|
||||||
|
{
|
||||||
|
z_stream *strm = *abstract;
|
||||||
|
/* A short-term alloc of a full data chunk is better than a series of
|
||||||
|
reallocs */
|
||||||
|
char *out;
|
||||||
|
size_t out_maxlen;
|
||||||
|
|
||||||
|
if(src_len <= SIZE_MAX / 4)
|
||||||
|
out_maxlen = (uInt)src_len * 4;
|
||||||
|
else
|
||||||
|
out_maxlen = payload_limit;
|
||||||
|
|
||||||
|
/* If strm is null, then we have not yet been initialized. */
|
||||||
|
if(!strm)
|
||||||
|
return _libssh2_error(session, LIBSSH2_ERROR_COMPRESS,
|
||||||
|
"decompression uninitialized");
|
||||||
|
|
||||||
|
/* In practice they never come smaller than this */
|
||||||
|
if(out_maxlen < 25)
|
||||||
|
out_maxlen = 25;
|
||||||
|
|
||||||
|
if(out_maxlen > payload_limit)
|
||||||
|
out_maxlen = payload_limit;
|
||||||
|
|
||||||
|
strm->next_in = (unsigned char *) src;
|
||||||
|
strm->avail_in = (uInt)src_len;
|
||||||
|
strm->next_out = (unsigned char *) LIBSSH2_ALLOC(session,
|
||||||
|
(uInt)out_maxlen);
|
||||||
|
out = (char *) strm->next_out;
|
||||||
|
strm->avail_out = (uInt)out_maxlen;
|
||||||
|
if(!strm->next_out)
|
||||||
|
return _libssh2_error(session, LIBSSH2_ERROR_ALLOC,
|
||||||
|
"Unable to allocate decompression buffer");
|
||||||
|
|
||||||
|
/* Loop until it's all inflated or hit error */
|
||||||
|
for(;;) {
|
||||||
|
int status;
|
||||||
|
size_t out_ofs;
|
||||||
|
char *newout;
|
||||||
|
|
||||||
|
status = inflate(strm, Z_PARTIAL_FLUSH);
|
||||||
|
|
||||||
|
if(status == Z_OK) {
|
||||||
|
if(strm->avail_out > 0)
|
||||||
|
/* status is OK and the output buffer has not been exhausted
|
||||||
|
so we're done */
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if(status == Z_BUF_ERROR) {
|
||||||
|
/* the input data has been exhausted so we are done */
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
/* error state */
|
||||||
|
LIBSSH2_FREE(session, out);
|
||||||
|
_libssh2_debug((session, LIBSSH2_TRACE_TRANS,
|
||||||
|
"unhandled zlib error %d", status));
|
||||||
|
return _libssh2_error(session, LIBSSH2_ERROR_ZLIB,
|
||||||
|
"decompression failure");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(out_maxlen > payload_limit || out_maxlen > SIZE_MAX / 2) {
|
||||||
|
LIBSSH2_FREE(session, out);
|
||||||
|
return _libssh2_error(session, LIBSSH2_ERROR_ZLIB,
|
||||||
|
"Excessive growth in decompression phase");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* If we get here we need to grow the output buffer and try again */
|
||||||
|
out_ofs = out_maxlen - strm->avail_out;
|
||||||
|
out_maxlen *= 2;
|
||||||
|
newout = LIBSSH2_REALLOC(session, out, out_maxlen);
|
||||||
|
if(!newout) {
|
||||||
|
LIBSSH2_FREE(session, out);
|
||||||
|
return _libssh2_error(session, LIBSSH2_ERROR_ALLOC,
|
||||||
|
"Unable to expand decompression buffer");
|
||||||
|
}
|
||||||
|
out = newout;
|
||||||
|
strm->next_out = (unsigned char *) out + out_ofs;
|
||||||
|
strm->avail_out = (uInt)(out_maxlen - out_ofs);
|
||||||
|
}
|
||||||
|
|
||||||
|
*dest = (unsigned char *) out;
|
||||||
|
*dest_len = out_maxlen - strm->avail_out;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* libssh2_comp_method_zlib_dtor
|
||||||
|
* All done, no more compression for you
|
||||||
|
*/
|
||||||
|
static int
|
||||||
|
comp_method_zlib_dtor(LIBSSH2_SESSION *session, int compr, void **abstract)
|
||||||
|
{
|
||||||
|
z_stream *strm = *abstract;
|
||||||
|
|
||||||
|
if(strm) {
|
||||||
|
if(compr)
|
||||||
|
deflateEnd(strm);
|
||||||
|
else
|
||||||
|
inflateEnd(strm);
|
||||||
|
LIBSSH2_FREE(session, strm);
|
||||||
|
}
|
||||||
|
|
||||||
|
*abstract = NULL;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const LIBSSH2_COMP_METHOD comp_method_zlib = {
|
||||||
|
"zlib",
|
||||||
|
1, /* yes, this compresses */
|
||||||
|
1, /* do compression during userauth */
|
||||||
|
comp_method_zlib_init,
|
||||||
|
comp_method_zlib_comp,
|
||||||
|
comp_method_zlib_decomp,
|
||||||
|
comp_method_zlib_dtor,
|
||||||
|
};
|
||||||
|
|
||||||
|
static const LIBSSH2_COMP_METHOD comp_method_zlib_openssh = {
|
||||||
|
"zlib@openssh.com",
|
||||||
|
1, /* yes, this compresses */
|
||||||
|
0, /* don't use compression during userauth */
|
||||||
|
comp_method_zlib_init,
|
||||||
|
comp_method_zlib_comp,
|
||||||
|
comp_method_zlib_decomp,
|
||||||
|
comp_method_zlib_dtor,
|
||||||
|
};
|
||||||
|
#endif /* LIBSSH2_HAVE_ZLIB */
|
||||||
|
|
||||||
|
/* If compression is enabled by the API, then this array is used which then
|
||||||
|
may allow compression if zlib is available at build time */
|
||||||
|
static const LIBSSH2_COMP_METHOD *comp_methods[] = {
|
||||||
|
#ifdef LIBSSH2_HAVE_ZLIB
|
||||||
|
&comp_method_zlib,
|
||||||
|
&comp_method_zlib_openssh,
|
||||||
|
#endif /* LIBSSH2_HAVE_ZLIB */
|
||||||
|
&comp_method_none,
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
|
||||||
|
/* If compression is disabled by the API, then this array is used */
|
||||||
|
static const LIBSSH2_COMP_METHOD *no_comp_methods[] = {
|
||||||
|
&comp_method_none,
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIBSSH2_COMP_METHOD **
|
||||||
|
_libssh2_comp_methods(LIBSSH2_SESSION *session)
|
||||||
|
{
|
||||||
|
if(session->flag.compress)
|
||||||
|
return comp_methods;
|
||||||
|
else
|
||||||
|
return no_comp_methods;
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#ifndef LIBSSH2_COMP_H
|
||||||
|
#define LIBSSH2_COMP_H
|
||||||
|
/* Copyright (C) Daniel Stenberg
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "libssh2_priv.h"
|
||||||
|
|
||||||
|
const LIBSSH2_COMP_METHOD **_libssh2_comp_methods(LIBSSH2_SESSION *session);
|
||||||
|
|
||||||
|
#endif /* LIBSSH2_COMP_H */
|
||||||
+549
@@ -0,0 +1,549 @@
|
|||||||
|
/* Copyright (C) Simon Josefsson <simon@josefsson.org>
|
||||||
|
* Copyright (C) Sara Golemon <sarag@libssh2.org>
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "libssh2_priv.h"
|
||||||
|
#include "cipher-chachapoly.h"
|
||||||
|
|
||||||
|
#include <assert.h>
|
||||||
|
|
||||||
|
#if defined(LIBSSH2DEBUG) && defined(LIBSSH2_CRYPT_NONE_INSECURE)
|
||||||
|
/* crypt_none_crypt
|
||||||
|
* Minimalist cipher: no encryption. DO NOT USE.
|
||||||
|
*
|
||||||
|
* The SSH2 Transport allows for unencrypted data transmission using
|
||||||
|
* the "none" cipher. Because this is such a huge security hole, it is
|
||||||
|
* typically disabled on SSH2 implementations and is disabled in libssh2
|
||||||
|
* by default as well.
|
||||||
|
*
|
||||||
|
* Enabling this option will allow for "none" as a negotiable method,
|
||||||
|
* however it still requires that the method be advertised by the remote
|
||||||
|
* end and that no more-preferable methods are available.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
static int
|
||||||
|
crypt_none_crypt(LIBSSH2_SESSION * session,
|
||||||
|
unsigned int seqno,
|
||||||
|
unsigned char *buf,
|
||||||
|
size_t buf_len,
|
||||||
|
void **abstract,
|
||||||
|
int firstlast)
|
||||||
|
{
|
||||||
|
/* Do nothing to the data! */
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_none = {
|
||||||
|
"none",
|
||||||
|
"DEK-Info: NONE",
|
||||||
|
8, /* blocksize (SSH2 defines minimum blocksize as 8) */
|
||||||
|
0, /* iv_len */
|
||||||
|
0, /* secret_len */
|
||||||
|
0, /* flags */
|
||||||
|
NULL,
|
||||||
|
crypt_none_crypt,
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
#endif /* defined(LIBSSH2DEBUG) && defined(LIBSSH2_CRYPT_NONE_INSECURE) */
|
||||||
|
|
||||||
|
struct crypt_ctx
|
||||||
|
{
|
||||||
|
int encrypt;
|
||||||
|
_libssh2_cipher_type(algo);
|
||||||
|
_libssh2_cipher_ctx h;
|
||||||
|
struct chachapoly_ctx chachapoly_ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
static int
|
||||||
|
crypt_init(LIBSSH2_SESSION * session,
|
||||||
|
const LIBSSH2_CRYPT_METHOD * method,
|
||||||
|
unsigned char *iv, int *free_iv,
|
||||||
|
unsigned char *secret, int *free_secret,
|
||||||
|
int encrypt, void **abstract)
|
||||||
|
{
|
||||||
|
struct crypt_ctx *ctx = LIBSSH2_ALLOC(session,
|
||||||
|
sizeof(struct crypt_ctx));
|
||||||
|
if(!ctx)
|
||||||
|
return LIBSSH2_ERROR_ALLOC;
|
||||||
|
|
||||||
|
ctx->encrypt = encrypt;
|
||||||
|
ctx->algo = method->algo;
|
||||||
|
if(_libssh2_cipher_init(&ctx->h, ctx->algo, iv, secret, encrypt)) {
|
||||||
|
LIBSSH2_FREE(session, ctx);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
*abstract = ctx;
|
||||||
|
*free_iv = 1;
|
||||||
|
*free_secret = 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
crypt_encrypt(LIBSSH2_SESSION * session,
|
||||||
|
unsigned int seqno,
|
||||||
|
unsigned char *buf,
|
||||||
|
size_t buf_len,
|
||||||
|
void **abstract,
|
||||||
|
int firstlast)
|
||||||
|
{
|
||||||
|
struct crypt_ctx *cctx = *(struct crypt_ctx **) abstract;
|
||||||
|
(void) session;
|
||||||
|
(void) seqno;
|
||||||
|
return _libssh2_cipher_crypt(&cctx->h, cctx->algo, cctx->encrypt, buf,
|
||||||
|
buf_len, firstlast);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
crypt_dtor(LIBSSH2_SESSION * session, void **abstract)
|
||||||
|
{
|
||||||
|
struct crypt_ctx **cctx = (struct crypt_ctx **) abstract;
|
||||||
|
if(cctx && *cctx) {
|
||||||
|
_libssh2_cipher_dtor(&(*cctx)->h);
|
||||||
|
LIBSSH2_FREE(session, *cctx);
|
||||||
|
*abstract = NULL;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if LIBSSH2_AES_GCM
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_aes256_gcm = {
|
||||||
|
"aes256-gcm@openssh.com",
|
||||||
|
"",
|
||||||
|
16, /* blocksize */
|
||||||
|
12, /* initial value length */
|
||||||
|
32, /* secret length -- 32*8 == 256bit */
|
||||||
|
16, /* length of the authentication tag */
|
||||||
|
LIBSSH2_CRYPT_FLAG_INTEGRATED_MAC | LIBSSH2_CRYPT_FLAG_PKTLEN_AAD,
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_aes256gcm
|
||||||
|
};
|
||||||
|
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_aes128_gcm = {
|
||||||
|
"aes128-gcm@openssh.com",
|
||||||
|
"",
|
||||||
|
16, /* blocksize */
|
||||||
|
12, /* initial value length */
|
||||||
|
16, /* secret length -- 16*8 == 128bit */
|
||||||
|
16, /* length of the authentication tag */
|
||||||
|
LIBSSH2_CRYPT_FLAG_INTEGRATED_MAC | LIBSSH2_CRYPT_FLAG_PKTLEN_AAD,
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_aes128gcm
|
||||||
|
};
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if LIBSSH2_AES_CTR
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_aes128_ctr = {
|
||||||
|
"aes128-ctr",
|
||||||
|
"",
|
||||||
|
16, /* blocksize */
|
||||||
|
16, /* initial value length */
|
||||||
|
16, /* secret length -- 16*8 == 128bit */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_aes128ctr
|
||||||
|
};
|
||||||
|
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_aes192_ctr = {
|
||||||
|
"aes192-ctr",
|
||||||
|
"",
|
||||||
|
16, /* blocksize */
|
||||||
|
16, /* initial value length */
|
||||||
|
24, /* secret length -- 24*8 == 192bit */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_aes192ctr
|
||||||
|
};
|
||||||
|
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_aes256_ctr = {
|
||||||
|
"aes256-ctr",
|
||||||
|
"",
|
||||||
|
16, /* blocksize */
|
||||||
|
16, /* initial value length */
|
||||||
|
32, /* secret length -- 32*8 == 256bit */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_aes256ctr
|
||||||
|
};
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if LIBSSH2_AES_CBC
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_aes128_cbc = {
|
||||||
|
"aes128-cbc",
|
||||||
|
"DEK-Info: AES-128-CBC",
|
||||||
|
16, /* blocksize */
|
||||||
|
16, /* initial value length */
|
||||||
|
16, /* secret length -- 16*8 == 128bit */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_aes128
|
||||||
|
};
|
||||||
|
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_aes192_cbc = {
|
||||||
|
"aes192-cbc",
|
||||||
|
"DEK-Info: AES-192-CBC",
|
||||||
|
16, /* blocksize */
|
||||||
|
16, /* initial value length */
|
||||||
|
24, /* secret length -- 24*8 == 192bit */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_aes192
|
||||||
|
};
|
||||||
|
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_aes256_cbc = {
|
||||||
|
"aes256-cbc",
|
||||||
|
"DEK-Info: AES-256-CBC",
|
||||||
|
16, /* blocksize */
|
||||||
|
16, /* initial value length */
|
||||||
|
32, /* secret length -- 32*8 == 256bit */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_aes256
|
||||||
|
};
|
||||||
|
|
||||||
|
/* rijndael-cbc@lysator.liu.se == aes256-cbc */
|
||||||
|
static const LIBSSH2_CRYPT_METHOD
|
||||||
|
libssh2_crypt_method_rijndael_cbc_lysator_liu_se = {
|
||||||
|
"rijndael-cbc@lysator.liu.se",
|
||||||
|
"DEK-Info: AES-256-CBC",
|
||||||
|
16, /* blocksize */
|
||||||
|
16, /* initial value length */
|
||||||
|
32, /* secret length -- 32*8 == 256bit */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_aes256
|
||||||
|
};
|
||||||
|
#endif /* LIBSSH2_AES_CBC */
|
||||||
|
|
||||||
|
#if LIBSSH2_BLOWFISH
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_blowfish_cbc = {
|
||||||
|
"blowfish-cbc",
|
||||||
|
"",
|
||||||
|
8, /* blocksize */
|
||||||
|
8, /* initial value length */
|
||||||
|
16, /* secret length */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_blowfish
|
||||||
|
};
|
||||||
|
#endif /* LIBSSH2_BLOWFISH */
|
||||||
|
|
||||||
|
#if LIBSSH2_RC4
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_arcfour = {
|
||||||
|
"arcfour",
|
||||||
|
"DEK-Info: RC4",
|
||||||
|
8, /* blocksize */
|
||||||
|
8, /* initial value length */
|
||||||
|
16, /* secret length */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_arcfour
|
||||||
|
};
|
||||||
|
|
||||||
|
static int
|
||||||
|
crypt_init_arcfour128(LIBSSH2_SESSION * session,
|
||||||
|
const LIBSSH2_CRYPT_METHOD * method,
|
||||||
|
unsigned char *iv, int *free_iv,
|
||||||
|
unsigned char *secret, int *free_secret,
|
||||||
|
int encrypt, void **abstract)
|
||||||
|
{
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
rc = crypt_init(session, method, iv, free_iv, secret, free_secret,
|
||||||
|
encrypt, abstract);
|
||||||
|
if(rc == 0) {
|
||||||
|
struct crypt_ctx *cctx = *(struct crypt_ctx **) abstract;
|
||||||
|
unsigned char block[8];
|
||||||
|
size_t discard = 1536;
|
||||||
|
for(; discard; discard -= 8)
|
||||||
|
_libssh2_cipher_crypt(&cctx->h, cctx->algo, cctx->encrypt, block,
|
||||||
|
method->blocksize, MIDDLE_BLOCK);
|
||||||
|
/* Not all middle, but here it doesn't matter */
|
||||||
|
}
|
||||||
|
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_arcfour128 = {
|
||||||
|
"arcfour128",
|
||||||
|
"",
|
||||||
|
8, /* blocksize */
|
||||||
|
8, /* initial value length */
|
||||||
|
16, /* secret length */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init_arcfour128,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_arcfour
|
||||||
|
};
|
||||||
|
#endif /* LIBSSH2_RC4 */
|
||||||
|
|
||||||
|
#if LIBSSH2_CAST
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_cast128_cbc = {
|
||||||
|
"cast128-cbc",
|
||||||
|
"",
|
||||||
|
8, /* blocksize */
|
||||||
|
8, /* initial value length */
|
||||||
|
16, /* secret length */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_cast5
|
||||||
|
};
|
||||||
|
#endif /* LIBSSH2_CAST */
|
||||||
|
|
||||||
|
#if LIBSSH2_3DES
|
||||||
|
static const LIBSSH2_CRYPT_METHOD libssh2_crypt_method_3des_cbc = {
|
||||||
|
"3des-cbc",
|
||||||
|
"DEK-Info: DES-EDE3-CBC",
|
||||||
|
8, /* blocksize */
|
||||||
|
8, /* initial value length */
|
||||||
|
24, /* secret length */
|
||||||
|
0, /* length of the authentication tag */
|
||||||
|
0, /* flags */
|
||||||
|
&crypt_init,
|
||||||
|
NULL,
|
||||||
|
&crypt_encrypt,
|
||||||
|
&crypt_dtor,
|
||||||
|
_libssh2_cipher_3des
|
||||||
|
};
|
||||||
|
#endif
|
||||||
|
|
||||||
|
static int
|
||||||
|
crypt_init_chacha20_poly(LIBSSH2_SESSION * session,
|
||||||
|
const LIBSSH2_CRYPT_METHOD * method,
|
||||||
|
unsigned char *iv, int *free_iv,
|
||||||
|
unsigned char *secret, int *free_secret,
|
||||||
|
int encrypt, void **abstract)
|
||||||
|
{
|
||||||
|
struct crypt_ctx *ctx = LIBSSH2_ALLOC(session,
|
||||||
|
sizeof(struct crypt_ctx));
|
||||||
|
|
||||||
|
(void)iv;
|
||||||
|
|
||||||
|
if(!ctx)
|
||||||
|
return LIBSSH2_ERROR_ALLOC;
|
||||||
|
|
||||||
|
ctx->encrypt = encrypt;
|
||||||
|
ctx->algo = method->algo;
|
||||||
|
|
||||||
|
if(chachapoly_init(&ctx->chachapoly_ctx, secret, method->secret_len)) {
|
||||||
|
LIBSSH2_FREE(session, ctx);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
*abstract = ctx;
|
||||||
|
*free_iv = 1;
|
||||||
|
*free_secret = 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static int
|
||||||
|
crypt_encrypt_chacha20_poly_buffer(LIBSSH2_SESSION * session,
|
||||||
|
unsigned int seqno,
|
||||||
|
unsigned char *buf,
|
||||||
|
size_t buf_len,
|
||||||
|
void **abstract,
|
||||||
|
int firstlast)
|
||||||
|
{
|
||||||
|
int ret = 1;
|
||||||
|
struct crypt_ctx *ctx = *(struct crypt_ctx **) abstract;
|
||||||
|
|
||||||
|
(void)session;
|
||||||
|
(void)firstlast;
|
||||||
|
|
||||||
|
if(ctx) {
|
||||||
|
if(ctx->encrypt) {
|
||||||
|
/* requires out_buf to be large enough to hold encrypted output
|
||||||
|
plus auth tag (auth len)
|
||||||
|
|
||||||
|
buf is a full packet so we need to subtract packet length from
|
||||||
|
length
|
||||||
|
*/
|
||||||
|
ret = chachapoly_crypt(&ctx->chachapoly_ctx, seqno, buf, buf,
|
||||||
|
((u_int)buf_len) - 4, 4, ctx->encrypt);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
/* buf is full packet including size and auth tag but buf_len
|
||||||
|
doesn't include size */
|
||||||
|
ret = chachapoly_crypt(&ctx->chachapoly_ctx, seqno, buf, buf,
|
||||||
|
((u_int)buf_len), 4, ctx->encrypt);
|
||||||
|
|
||||||
|
/* the api expects the size field to already be removed
|
||||||
|
from the decrypted packet so we'll help it out */
|
||||||
|
if(ret == 0) {
|
||||||
|
memmove(buf, buf + 4, buf_len - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (ret == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
crypt_get_length_chacha20_poly(LIBSSH2_SESSION * session, unsigned int seqno,
|
||||||
|
unsigned char *data, size_t data_size,
|
||||||
|
unsigned int *len, void **abstract)
|
||||||
|
{
|
||||||
|
struct crypt_ctx *ctx = *(struct crypt_ctx **) abstract;
|
||||||
|
|
||||||
|
(void)session;
|
||||||
|
|
||||||
|
return chachapoly_get_length(&ctx->chachapoly_ctx, len, seqno, data,
|
||||||
|
(u_int)data_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
crypt_dtor_chacha20_poly(LIBSSH2_SESSION * session, void **abstract)
|
||||||
|
{
|
||||||
|
struct crypt_ctx **cctx = (struct crypt_ctx **) abstract;
|
||||||
|
if(cctx && *cctx) {
|
||||||
|
LIBSSH2_FREE(session, *cctx);
|
||||||
|
*abstract = NULL;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const LIBSSH2_CRYPT_METHOD
|
||||||
|
libssh2_crypt_method_chacha20_poly1305_openssh = {
|
||||||
|
"chacha20-poly1305@openssh.com",
|
||||||
|
"",
|
||||||
|
8, /* blocksize */
|
||||||
|
0, /* initial value length */
|
||||||
|
64, /* secret length */
|
||||||
|
16, /* length of the auth_tag */
|
||||||
|
LIBSSH2_CRYPT_FLAG_REQUIRES_FULL_PACKET, /* flags */
|
||||||
|
&crypt_init_chacha20_poly,
|
||||||
|
&crypt_get_length_chacha20_poly,
|
||||||
|
&crypt_encrypt_chacha20_poly_buffer,
|
||||||
|
&crypt_dtor_chacha20_poly,
|
||||||
|
_libssh2_cipher_chacha20 /* not actually used */
|
||||||
|
};
|
||||||
|
|
||||||
|
/* These are the crypt methods that are available to be negotiated. Methods
|
||||||
|
towards the start are chosen in preference to ones further down the list. */
|
||||||
|
static const LIBSSH2_CRYPT_METHOD *_libssh2_crypt_methods[] = {
|
||||||
|
&libssh2_crypt_method_chacha20_poly1305_openssh,
|
||||||
|
#if LIBSSH2_AES_GCM
|
||||||
|
&libssh2_crypt_method_aes256_gcm,
|
||||||
|
&libssh2_crypt_method_aes128_gcm,
|
||||||
|
#endif /* LIBSSH2_AES_GCM */
|
||||||
|
#if LIBSSH2_AES_CTR
|
||||||
|
&libssh2_crypt_method_aes256_ctr,
|
||||||
|
&libssh2_crypt_method_aes192_ctr,
|
||||||
|
&libssh2_crypt_method_aes128_ctr,
|
||||||
|
#endif /* LIBSSH2_AES_CTR */
|
||||||
|
#if LIBSSH2_AES_CBC
|
||||||
|
&libssh2_crypt_method_aes256_cbc,
|
||||||
|
&libssh2_crypt_method_rijndael_cbc_lysator_liu_se, /* == aes256-cbc */
|
||||||
|
&libssh2_crypt_method_aes192_cbc,
|
||||||
|
&libssh2_crypt_method_aes128_cbc,
|
||||||
|
#endif /* LIBSSH2_AES_CBC */
|
||||||
|
#if LIBSSH2_BLOWFISH
|
||||||
|
&libssh2_crypt_method_blowfish_cbc,
|
||||||
|
#endif /* LIBSSH2_BLOWFISH */
|
||||||
|
#if LIBSSH2_RC4
|
||||||
|
&libssh2_crypt_method_arcfour128,
|
||||||
|
&libssh2_crypt_method_arcfour,
|
||||||
|
#endif /* LIBSSH2_RC4 */
|
||||||
|
#if LIBSSH2_CAST
|
||||||
|
&libssh2_crypt_method_cast128_cbc,
|
||||||
|
#endif /* LIBSSH2_CAST */
|
||||||
|
#if LIBSSH2_3DES
|
||||||
|
&libssh2_crypt_method_3des_cbc,
|
||||||
|
#endif /* LIBSSH2_DES */
|
||||||
|
#if defined(LIBSSH2DEBUG) && defined(LIBSSH2_CRYPT_NONE_INSECURE)
|
||||||
|
&libssh2_crypt_method_none,
|
||||||
|
#endif
|
||||||
|
NULL
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Expose to kex.c */
|
||||||
|
const LIBSSH2_CRYPT_METHOD **
|
||||||
|
libssh2_crypt_methods(void)
|
||||||
|
{
|
||||||
|
return _libssh2_crypt_methods;
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
/* Copyright (C) Viktor Szakats
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define LIBSSH2_CRYPTO_C
|
||||||
|
#include "libssh2_priv.h"
|
||||||
|
|
||||||
|
#if defined(LIBSSH2_OPENSSL) || defined(LIBSSH2_WOLFSSL)
|
||||||
|
#include "openssl.c"
|
||||||
|
#elif defined(LIBSSH2_LIBGCRYPT)
|
||||||
|
#include "libgcrypt.c"
|
||||||
|
#elif defined(LIBSSH2_MBEDTLS)
|
||||||
|
#include "mbedtls.c"
|
||||||
|
#elif defined(LIBSSH2_OS400QC3)
|
||||||
|
#include "os400qc3.c"
|
||||||
|
#elif defined(LIBSSH2_WINCNG)
|
||||||
|
#include "wincng.c"
|
||||||
|
#endif
|
||||||
+357
@@ -0,0 +1,357 @@
|
|||||||
|
#ifndef LIBSSH2_CRYPTO_H
|
||||||
|
#define LIBSSH2_CRYPTO_H
|
||||||
|
/* Copyright (C) Simon Josefsson
|
||||||
|
* Copyright (C) The Written Word, Inc.
|
||||||
|
* Copyright (C) Daniel Stenberg
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#if defined(LIBSSH2_OPENSSL) || defined(LIBSSH2_WOLFSSL)
|
||||||
|
#include "openssl.h"
|
||||||
|
#elif defined(LIBSSH2_LIBGCRYPT)
|
||||||
|
#include "libgcrypt.h"
|
||||||
|
#elif defined(LIBSSH2_MBEDTLS)
|
||||||
|
#include "mbedtls.h"
|
||||||
|
#elif defined(LIBSSH2_OS400QC3)
|
||||||
|
#include "os400qc3.h"
|
||||||
|
#elif defined(LIBSSH2_WINCNG)
|
||||||
|
#include "wincng.h"
|
||||||
|
#else
|
||||||
|
#error "no cryptography backend selected"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* return: success = 1, error = 0 */
|
||||||
|
int _libssh2_hmac_ctx_init(libssh2_hmac_ctx *ctx);
|
||||||
|
#if LIBSSH2_MD5
|
||||||
|
int _libssh2_hmac_md5_init(libssh2_hmac_ctx *ctx,
|
||||||
|
void *key, size_t keylen);
|
||||||
|
#endif
|
||||||
|
#if LIBSSH2_HMAC_RIPEMD
|
||||||
|
int _libssh2_hmac_ripemd160_init(libssh2_hmac_ctx *ctx,
|
||||||
|
void *key, size_t keylen);
|
||||||
|
#endif
|
||||||
|
int _libssh2_hmac_sha1_init(libssh2_hmac_ctx *ctx,
|
||||||
|
void *key, size_t keylen);
|
||||||
|
int _libssh2_hmac_sha256_init(libssh2_hmac_ctx *ctx,
|
||||||
|
void *key, size_t keylen);
|
||||||
|
int _libssh2_hmac_sha512_init(libssh2_hmac_ctx *ctx,
|
||||||
|
void *key, size_t keylen);
|
||||||
|
int _libssh2_hmac_update(libssh2_hmac_ctx *ctx,
|
||||||
|
const void *data, size_t datalen);
|
||||||
|
int _libssh2_hmac_final(libssh2_hmac_ctx *ctx, void *data);
|
||||||
|
void _libssh2_hmac_cleanup(libssh2_hmac_ctx *ctx);
|
||||||
|
|
||||||
|
#define LIBSSH2_ED25519_KEY_LEN 32
|
||||||
|
#define LIBSSH2_ED25519_PRIVATE_KEY_LEN 64
|
||||||
|
#define LIBSSH2_ED25519_SIG_LEN 64
|
||||||
|
|
||||||
|
#if LIBSSH2_RSA
|
||||||
|
int _libssh2_rsa_new(libssh2_rsa_ctx ** rsa,
|
||||||
|
const unsigned char *edata,
|
||||||
|
unsigned long elen,
|
||||||
|
const unsigned char *ndata,
|
||||||
|
unsigned long nlen,
|
||||||
|
const unsigned char *ddata,
|
||||||
|
unsigned long dlen,
|
||||||
|
const unsigned char *pdata,
|
||||||
|
unsigned long plen,
|
||||||
|
const unsigned char *qdata,
|
||||||
|
unsigned long qlen,
|
||||||
|
const unsigned char *e1data,
|
||||||
|
unsigned long e1len,
|
||||||
|
const unsigned char *e2data,
|
||||||
|
unsigned long e2len,
|
||||||
|
const unsigned char *coeffdata, unsigned long coefflen);
|
||||||
|
int _libssh2_rsa_new_private(libssh2_rsa_ctx ** rsa,
|
||||||
|
LIBSSH2_SESSION * session,
|
||||||
|
const char *filename,
|
||||||
|
unsigned const char *passphrase);
|
||||||
|
#if LIBSSH2_RSA_SHA1
|
||||||
|
int _libssh2_rsa_sha1_sign(LIBSSH2_SESSION * session,
|
||||||
|
libssh2_rsa_ctx * rsactx,
|
||||||
|
const unsigned char *hash,
|
||||||
|
size_t hash_len,
|
||||||
|
unsigned char **signature,
|
||||||
|
size_t *signature_len);
|
||||||
|
int _libssh2_rsa_sha1_verify(libssh2_rsa_ctx * rsa,
|
||||||
|
const unsigned char *sig,
|
||||||
|
size_t sig_len,
|
||||||
|
const unsigned char *m, size_t m_len);
|
||||||
|
#endif
|
||||||
|
#if LIBSSH2_RSA_SHA2
|
||||||
|
int _libssh2_rsa_sha2_sign(LIBSSH2_SESSION * session,
|
||||||
|
libssh2_rsa_ctx * rsactx,
|
||||||
|
const unsigned char *hash,
|
||||||
|
size_t hash_len,
|
||||||
|
unsigned char **signature,
|
||||||
|
size_t *signature_len);
|
||||||
|
int _libssh2_rsa_sha2_verify(libssh2_rsa_ctx * rsa,
|
||||||
|
size_t hash_len,
|
||||||
|
const unsigned char *sig,
|
||||||
|
size_t sig_len,
|
||||||
|
const unsigned char *m, size_t m_len);
|
||||||
|
#endif
|
||||||
|
int _libssh2_rsa_new_private_frommemory(libssh2_rsa_ctx ** rsa,
|
||||||
|
LIBSSH2_SESSION * session,
|
||||||
|
const char *filedata,
|
||||||
|
size_t filedata_len,
|
||||||
|
unsigned const char *passphrase);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if LIBSSH2_DSA
|
||||||
|
int _libssh2_dsa_new(libssh2_dsa_ctx ** dsa,
|
||||||
|
const unsigned char *pdata,
|
||||||
|
unsigned long plen,
|
||||||
|
const unsigned char *qdata,
|
||||||
|
unsigned long qlen,
|
||||||
|
const unsigned char *gdata,
|
||||||
|
unsigned long glen,
|
||||||
|
const unsigned char *ydata,
|
||||||
|
unsigned long ylen,
|
||||||
|
const unsigned char *x, unsigned long x_len);
|
||||||
|
int _libssh2_dsa_new_private(libssh2_dsa_ctx ** dsa,
|
||||||
|
LIBSSH2_SESSION * session,
|
||||||
|
const char *filename,
|
||||||
|
unsigned const char *passphrase);
|
||||||
|
int _libssh2_dsa_sha1_verify(libssh2_dsa_ctx * dsactx,
|
||||||
|
const unsigned char *sig,
|
||||||
|
const unsigned char *m, size_t m_len);
|
||||||
|
int _libssh2_dsa_sha1_sign(libssh2_dsa_ctx * dsactx,
|
||||||
|
const unsigned char *hash,
|
||||||
|
size_t hash_len, unsigned char *sig);
|
||||||
|
int _libssh2_dsa_new_private_frommemory(libssh2_dsa_ctx ** dsa,
|
||||||
|
LIBSSH2_SESSION * session,
|
||||||
|
const char *filedata,
|
||||||
|
size_t filedata_len,
|
||||||
|
unsigned const char *passphrase);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if LIBSSH2_ECDSA
|
||||||
|
int
|
||||||
|
_libssh2_ecdsa_curve_name_with_octal_new(libssh2_ecdsa_ctx ** ecdsactx,
|
||||||
|
const unsigned char *k,
|
||||||
|
size_t k_len,
|
||||||
|
libssh2_curve_type type);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ecdsa_new_private(libssh2_ecdsa_ctx ** ec_ctx,
|
||||||
|
LIBSSH2_SESSION * session,
|
||||||
|
const char *filename,
|
||||||
|
unsigned const char *passphrase);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ecdsa_new_private_sk(libssh2_ecdsa_ctx ** ec_ctx,
|
||||||
|
unsigned char *flags,
|
||||||
|
const char **application,
|
||||||
|
const unsigned char **key_handle,
|
||||||
|
size_t *handle_len,
|
||||||
|
LIBSSH2_SESSION * session,
|
||||||
|
const char *filename,
|
||||||
|
unsigned const char *passphrase);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ecdsa_verify(libssh2_ecdsa_ctx * ctx,
|
||||||
|
const unsigned char *r, size_t r_len,
|
||||||
|
const unsigned char *s, size_t s_len,
|
||||||
|
const unsigned char *m, size_t m_len);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ecdsa_create_key(LIBSSH2_SESSION *session,
|
||||||
|
_libssh2_ec_key **out_private_key,
|
||||||
|
unsigned char **out_public_key_octal,
|
||||||
|
size_t *out_public_key_octal_len,
|
||||||
|
libssh2_curve_type curve_type);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ecdh_gen_k(_libssh2_bn **k, _libssh2_ec_key *private_key,
|
||||||
|
const unsigned char *server_public_key,
|
||||||
|
size_t server_public_key_len);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ecdsa_sign(LIBSSH2_SESSION *session, libssh2_ecdsa_ctx *ec_ctx,
|
||||||
|
const unsigned char *hash, size_t hash_len,
|
||||||
|
unsigned char **signature, size_t *signature_len);
|
||||||
|
|
||||||
|
int _libssh2_ecdsa_new_private_frommemory(libssh2_ecdsa_ctx ** ec_ctx,
|
||||||
|
LIBSSH2_SESSION * session,
|
||||||
|
const char *filedata,
|
||||||
|
size_t filedata_len,
|
||||||
|
unsigned const char *passphrase);
|
||||||
|
|
||||||
|
int _libssh2_ecdsa_new_private_frommemory_sk(libssh2_ecdsa_ctx ** ec_ctx,
|
||||||
|
unsigned char *flags,
|
||||||
|
const char **application,
|
||||||
|
const unsigned char **key_handle,
|
||||||
|
size_t *handle_len,
|
||||||
|
LIBSSH2_SESSION * session,
|
||||||
|
const char *filedata,
|
||||||
|
size_t filedata_len,
|
||||||
|
unsigned const char *passphrase);
|
||||||
|
|
||||||
|
libssh2_curve_type
|
||||||
|
_libssh2_ecdsa_get_curve_type(libssh2_ecdsa_ctx *ec_ctx);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ecdsa_curve_type_from_name(const char *name,
|
||||||
|
libssh2_curve_type *out_type);
|
||||||
|
|
||||||
|
#endif /* LIBSSH2_ECDSA */
|
||||||
|
|
||||||
|
#if LIBSSH2_ED25519
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_curve25519_new(LIBSSH2_SESSION *session, uint8_t **out_public_key,
|
||||||
|
uint8_t **out_private_key);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_curve25519_gen_k(_libssh2_bn **k,
|
||||||
|
uint8_t private_key[LIBSSH2_ED25519_KEY_LEN],
|
||||||
|
uint8_t server_public_key[LIBSSH2_ED25519_KEY_LEN]);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ed25519_verify(libssh2_ed25519_ctx *ctx, const uint8_t *s,
|
||||||
|
size_t s_len, const uint8_t *m, size_t m_len);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ed25519_new_private(libssh2_ed25519_ctx **ed_ctx,
|
||||||
|
LIBSSH2_SESSION *session,
|
||||||
|
const char *filename, const uint8_t *passphrase);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ed25519_new_private_sk(libssh2_ed25519_ctx **ed_ctx,
|
||||||
|
unsigned char *flags,
|
||||||
|
const char **application,
|
||||||
|
const unsigned char **key_handle,
|
||||||
|
size_t *handle_len,
|
||||||
|
LIBSSH2_SESSION *session,
|
||||||
|
const char *filename,
|
||||||
|
const uint8_t *passphrase);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ed25519_new_public(libssh2_ed25519_ctx **ed_ctx,
|
||||||
|
LIBSSH2_SESSION *session,
|
||||||
|
const unsigned char *raw_pub_key,
|
||||||
|
const size_t key_len);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ed25519_sign(libssh2_ed25519_ctx *ctx, LIBSSH2_SESSION *session,
|
||||||
|
uint8_t **out_sig, size_t *out_sig_len,
|
||||||
|
const uint8_t *message, size_t message_len);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ed25519_new_private_frommemory(libssh2_ed25519_ctx **ed_ctx,
|
||||||
|
LIBSSH2_SESSION *session,
|
||||||
|
const char *filedata,
|
||||||
|
size_t filedata_len,
|
||||||
|
unsigned const char *passphrase);
|
||||||
|
|
||||||
|
int
|
||||||
|
_libssh2_ed25519_new_private_frommemory_sk(libssh2_ed25519_ctx **ed_ctx,
|
||||||
|
unsigned char *flags,
|
||||||
|
const char **application,
|
||||||
|
const unsigned char **key_handle,
|
||||||
|
size_t *handle_len,
|
||||||
|
LIBSSH2_SESSION *session,
|
||||||
|
const char *filedata,
|
||||||
|
size_t filedata_len,
|
||||||
|
unsigned const char *passphrase);
|
||||||
|
|
||||||
|
#endif /* LIBSSH2_ED25519 */
|
||||||
|
|
||||||
|
|
||||||
|
int _libssh2_cipher_init(_libssh2_cipher_ctx * h,
|
||||||
|
_libssh2_cipher_type(algo),
|
||||||
|
unsigned char *iv,
|
||||||
|
unsigned char *secret, int encrypt);
|
||||||
|
|
||||||
|
int _libssh2_cipher_crypt(_libssh2_cipher_ctx * ctx,
|
||||||
|
_libssh2_cipher_type(algo),
|
||||||
|
int encrypt, unsigned char *block, size_t blocksize,
|
||||||
|
int firstlast);
|
||||||
|
|
||||||
|
int _libssh2_pub_priv_keyfile(LIBSSH2_SESSION *session,
|
||||||
|
unsigned char **method,
|
||||||
|
size_t *method_len,
|
||||||
|
unsigned char **pubkeydata,
|
||||||
|
size_t *pubkeydata_len,
|
||||||
|
const char *privatekey,
|
||||||
|
const char *passphrase);
|
||||||
|
|
||||||
|
int _libssh2_pub_priv_keyfilememory(LIBSSH2_SESSION *session,
|
||||||
|
unsigned char **method,
|
||||||
|
size_t *method_len,
|
||||||
|
unsigned char **pubkeydata,
|
||||||
|
size_t *pubkeydata_len,
|
||||||
|
const char *privatekeydata,
|
||||||
|
size_t privatekeydata_len,
|
||||||
|
const char *passphrase);
|
||||||
|
|
||||||
|
|
||||||
|
int _libssh2_sk_pub_keyfilememory(LIBSSH2_SESSION *session,
|
||||||
|
unsigned char **method,
|
||||||
|
size_t *method_len,
|
||||||
|
unsigned char **pubkeydata,
|
||||||
|
size_t *pubkeydata_len,
|
||||||
|
int *algorithm,
|
||||||
|
unsigned char *flags,
|
||||||
|
const char **application,
|
||||||
|
const unsigned char **key_handle,
|
||||||
|
size_t *handle_len,
|
||||||
|
const char *privatekeydata,
|
||||||
|
size_t privatekeydata_len,
|
||||||
|
const char *passphrase);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @function _libssh2_supported_key_sign_algorithms
|
||||||
|
* @abstract Returns supported algorithms used for upgrading public
|
||||||
|
* key signing RFC 8332
|
||||||
|
* @discussion Based on the incoming key_method value, this function
|
||||||
|
* will return supported algorithms that can upgrade the key method
|
||||||
|
* @related _libssh2_key_sign_algorithm()
|
||||||
|
* @param key_method current key method, usually the default key sig method
|
||||||
|
* @param key_method_len length of the key method buffer
|
||||||
|
* @result comma separated list of supported upgrade options per RFC 8332, if
|
||||||
|
* there is no upgrade option return NULL
|
||||||
|
*/
|
||||||
|
|
||||||
|
const char *
|
||||||
|
_libssh2_supported_key_sign_algorithms(LIBSSH2_SESSION *session,
|
||||||
|
unsigned char *key_method,
|
||||||
|
size_t key_method_len);
|
||||||
|
|
||||||
|
#endif /* LIBSSH2_CRYPTO_H */
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
/* Copyright (C) Viktor Szakats
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#define LIBSSH2_MD5_PEM LIBSSH2_MD5
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_MD5
|
||||||
|
#undef LIBSSH2_MD5
|
||||||
|
#define LIBSSH2_MD5 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_MD5_PEM
|
||||||
|
#undef LIBSSH2_MD5_PEM
|
||||||
|
#define LIBSSH2_MD5_PEM 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_HMAC_RIPEMD
|
||||||
|
#undef LIBSSH2_HMAC_RIPEMD
|
||||||
|
#define LIBSSH2_HMAC_RIPEMD 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if !defined(LIBSSH2_DSA_ENABLE)
|
||||||
|
#undef LIBSSH2_DSA
|
||||||
|
#define LIBSSH2_DSA 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_RSA
|
||||||
|
#undef LIBSSH2_RSA
|
||||||
|
#define LIBSSH2_RSA 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_RSA_SHA1
|
||||||
|
#undef LIBSSH2_RSA_SHA1
|
||||||
|
#define LIBSSH2_RSA_SHA1 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_ECDSA
|
||||||
|
#undef LIBSSH2_ECDSA
|
||||||
|
#define LIBSSH2_ECDSA 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_ED25519
|
||||||
|
#undef LIBSSH2_ED25519
|
||||||
|
#define LIBSSH2_ED25519 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_AES_CTR
|
||||||
|
#undef LIBSSH2_AES_CTR
|
||||||
|
#define LIBSSH2_AES_CTR 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_AES_CBC
|
||||||
|
#undef LIBSSH2_AES_CBC
|
||||||
|
#define LIBSSH2_AES_CBC 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_BLOWFISH
|
||||||
|
#undef LIBSSH2_BLOWFISH
|
||||||
|
#define LIBSSH2_BLOWFISH 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_RC4
|
||||||
|
#undef LIBSSH2_RC4
|
||||||
|
#define LIBSSH2_RC4 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_CAST
|
||||||
|
#undef LIBSSH2_CAST
|
||||||
|
#define LIBSSH2_CAST 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef LIBSSH2_NO_3DES
|
||||||
|
#undef LIBSSH2_3DES
|
||||||
|
#define LIBSSH2_3DES 0
|
||||||
|
#endif
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
/* Copyright (C) Lars Nordin <Lars.Nordin@SDlabs.se>
|
||||||
|
* Copyright (C) Simon Josefsson <simon@josefsson.org>
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* Redistribution and use in source and binary forms,
|
||||||
|
* with or without modification, are permitted provided
|
||||||
|
* that the following conditions are met:
|
||||||
|
*
|
||||||
|
* Redistributions of source code must retain the above
|
||||||
|
* copyright notice, this list of conditions and the
|
||||||
|
* following disclaimer.
|
||||||
|
*
|
||||||
|
* Redistributions in binary form must reproduce the above
|
||||||
|
* copyright notice, this list of conditions and the following
|
||||||
|
* disclaimer in the documentation and/or other materials
|
||||||
|
* provided with the distribution.
|
||||||
|
*
|
||||||
|
* Neither the name of the copyright holder nor the names
|
||||||
|
* of any other contributors may be used to endorse or
|
||||||
|
* promote products derived from this software without
|
||||||
|
* specific prior written permission.
|
||||||
|
*
|
||||||
|
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||||
|
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||||
|
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
||||||
|
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||||
|
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||||
|
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||||
|
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||||||
|
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||||
|
* OF SUCH DAMAGE.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "libssh2_priv.h"
|
||||||
|
|
||||||
|
static int _libssh2_initialized = 0;
|
||||||
|
static int _libssh2_init_flags = 0;
|
||||||
|
|
||||||
|
LIBSSH2_API int
|
||||||
|
libssh2_init(int flags)
|
||||||
|
{
|
||||||
|
if(_libssh2_initialized == 0 && !(flags & LIBSSH2_INIT_NO_CRYPTO)) {
|
||||||
|
libssh2_crypto_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
_libssh2_initialized++;
|
||||||
|
_libssh2_init_flags |= flags;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
LIBSSH2_API void
|
||||||
|
libssh2_exit(void)
|
||||||
|
{
|
||||||
|
if(_libssh2_initialized == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_libssh2_initialized--;
|
||||||
|
|
||||||
|
if(_libssh2_initialized == 0 &&
|
||||||
|
!(_libssh2_init_flags & LIBSSH2_INIT_NO_CRYPTO)) {
|
||||||
|
libssh2_crypto_exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
_libssh2_init_if_needed(void)
|
||||||
|
{
|
||||||
|
if(_libssh2_initialized == 0)
|
||||||
|
(void)libssh2_init(0);
|
||||||
|
}
|
||||||
+1492
File diff suppressed because it is too large.
Load diff
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user