diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3b43959 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +target +data +logs +config.toml +cert.pem +key.pem +network_control.db +*.log diff --git a/.gitignore b/.gitignore index ea8c4bf..75726b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ /target +/data/* +!/data/.gitkeep +/config.toml +/cert.pem +/key.pem +/network_control.db +/logs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ea3ecb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +ARG RUST_VERSION=1.93.1 + +FROM rust:${RUST_VERSION}-bookworm AS builder +WORKDIR /build + +RUN apt-get update \ + && apt-get install -y --no-install-recommends protobuf-compiler pkg-config libsqlite3-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY rust-toolchain.toml Cargo.toml Cargo.lock build.rs ./ +COPY proto ./proto +COPY static ./static +COPY src ./src + +RUN cargo build --release --locked --bin vnts2 + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libsqlite3-0 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app/data + +COPY --from=builder /build/target/release/vnts2 /usr/local/bin/vnts2 + +VOLUME ["/app/data"] + +EXPOSE 29871/tcp +EXPOSE 29872/tcp +EXPOSE 29872/udp +EXPOSE 29873/udp + +CMD ["vnts2"] diff --git a/README.md b/README.md index 50f70c9..62bc28b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,277 @@ -# vnts +# vnts2 -[vnt](https://github.com/vnt-dev/vnt)的服务端 +Server-side implementation for vnt-compatible networking, with support for: +- TCP over TLS +- WebSocket Secure (WSS) +- QUIC +- Optional web management UI/API +- Optional persistence with SQLite +- Optional peer-to-peer server federation -## 说明 +## Configuration -1. 支持quic、tcp(tls)和wss协议,会自动生成自签名证书,也可以手动替换 -2. 无参数启动后,会输出配置文件,可以修改配置文件 -3. --conf-example 参数查看配置文件示例 +The server reads `config.toml` from its current working directory by default. + +- If `config.toml` does not exist, the server will generate one automatically using built-in defaults. +- In Docker, the working directory is `/app/data`, so the effective config path is `/app/data/config.toml`. +- You can also pass a custom config path with `--conf /path/to/config.toml`. + +The repository includes a sample file at `data/config.example.toml`. + +### Configuration fields + +`tcp_bind` + +- TCP listener address for control traffic over TLS. +- Example: `0.0.0.0:29872` +- Remove this field to disable the TCP listener. + +`quic_bind` + +- QUIC listener address for control traffic. +- Example: `0.0.0.0:29872` +- Remove this field to disable QUIC. + +`ws_bind` + +- WSS listener address. +- Example: `0.0.0.0:29872` +- Remove this field to disable WSS. + +`network` + +- Default virtual network CIDR used by the server. +- Example: `10.26.0.0/24` + +`custom_nets` + +- Additional named virtual networks. +- TOML table format: + +```toml +[custom_nets] +office = "10.27.0.0/24" +lab = "10.28.0.0/24" +``` + +`white_list` + +- List of allowed network codes. +- Empty list means no whitelist restriction. + +`lease_duration` + +- Device IP lease duration in seconds. +- Example: `86400` for 24 hours. + +`web_bind` + +- Bind address for the web management UI and HTTP API. +- Example: `0.0.0.0:29871` +- Remove this field to disable the web UI/API. + +`username` + +- Username for the web management login. + +`password` + +- Password for the web management login. + +`persistence` + +- Enables persistence in SQLite. +- When enabled, the server stores networks, devices, and peer-server records in `network_control.db`. + +`cert` + +- Path to a PEM certificate file. +- If both `cert` and `key` are omitted, the server will generate `cert.pem` automatically. + +`key` + +- Path to a PEM private key file. +- If both `cert` and `key` are omitted, the server will generate `key.pem` automatically. + +`server_quic_bind` + +- Optional QUIC bind address for server-to-server federation. +- Example: `0.0.0.0:29873` + +`peer_servers` + +- List of upstream or sibling server addresses for federation. +- Example: + +```toml +peer_servers = ["server1.example.com:29873", "192.168.1.10:29873"] +``` + +`server_token` + +- Shared token used for inter-server authentication. +- Set this when `server_quic_bind` or `peer_servers` is enabled. + +### Runtime-generated files + +The server writes several files relative to its working directory: + +- `config.toml` +- `network_control.db` +- `cert.pem` +- `key.pem` +- `logs/` +- `logs/log4rs.yaml` + +If you use Docker, all of these should be stored in a mounted directory so they survive container recreation. + +### Notes + +- If `tcp_bind` and `ws_bind` use the same address, the server will multiplex TLS TCP and WSS on that single port. +- If `persistence = false`, runtime state is not stored in SQLite. +- Certificate paths may be absolute or relative. Relative paths are resolved from the process working directory. + +## Docker Deployment + +The repository already includes: + +- `Dockerfile` +- `docker-compose.yml` +- `rust-toolchain.toml` + +### Why the Rust version is pinned + +This project uses Rust 2024 edition syntax. To avoid syntax and toolchain mismatches across environments, the build is pinned to Rust `1.93.1` in both: + +- `rust-toolchain.toml` +- Docker build arg `RUST_VERSION` + +This is newer than the minimum required stable version and avoids edition-related compatibility problems. + +### Persisted data layout + +In Docker, the container runs with: + +- working directory: `/app/data` + +The compose file mounts: + +- host `./data` +- to container `/app/data` + +That means the following files will persist on the host: + +- `./data/config.toml` +- `./data/network_control.db` +- `./data/cert.pem` +- `./data/key.pem` +- `./data/logs/...` + +### Quick start with Docker Compose + +1. Copy the sample config: + +```bash +cp data/config.example.toml data/config.toml +``` + +2. Edit `data/config.toml` as needed. + +3. Build and start the service: + +```bash +docker compose up -d --build +``` + +4. Check logs: + +```bash +docker compose logs -f +``` + +5. Stop the service: + +```bash +docker compose down +``` + +### Default exposed ports + +- `29871/tcp`: web UI / HTTP API +- `29872/tcp`: TLS TCP control traffic +- `29872/udp`: QUIC control traffic +- `29873/udp`: optional peer-server QUIC federation + +If you do not use the web UI or peer federation, you may remove the corresponding published ports in `docker-compose.yml`. + +### Deploy from scratch + +If you want the server to generate its own default config and certificates: + +1. Create the data directory: + +```bash +mkdir -p data +``` + +2. Start the container: + +```bash +docker compose up -d --build +``` + +3. After the first start, inspect the generated files in `./data`. + +This is convenient for initial setup, but for controlled deployments it is better to create `data/config.toml` explicitly from `data/config.example.toml`. + +### Updating the deployment + +When the source code changes: + +```bash +docker compose up -d --build +``` + +Because all persistent state is stored in `./data`, recreating the container does not remove the database, config, certificates, or logs. + +### Standalone Docker commands + +Build: + +```bash +docker build -t vnts2:local . +``` + +Run: + +```bash +docker run -d \ + --name vnts2 \ + -p 29871:29871/tcp \ + -p 29872:29872/tcp \ + -p 29872:29872/udp \ + -p 29873:29873/udp \ + -v "$(pwd)/data:/app/data" \ + --restart unless-stopped \ + vnts2:local +``` + +### Troubleshooting + +If the container starts but no service is reachable: + +- Check whether the listener is enabled in `config.toml`. +- Check whether the port mapping matches the bind addresses in the config. +- Check `docker compose logs -f`. + +If the database is not persistent: + +- Confirm `persistence = true`. +- Confirm `./data` is mounted to `/app/data`. +- Confirm the server is actually using the expected working directory. + +If TLS files are missing: + +- The server only auto-generates `cert.pem` and `key.pem` when custom `cert` and `key` are not provided. +- Generated files are written into the working directory, which is `/app/data` in Docker. diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..653281f --- /dev/null +++ b/README.zh.md @@ -0,0 +1,277 @@ +# vnts2 + +vnt 兼容服务端,支持以下能力: + +- TCP over TLS +- WebSocket Secure (WSS) +- QUIC +- 可选 Web 管理界面与 HTTP API +- 可选 SQLite 持久化 +- 可选服务端互联 + +## 配置说明 + +程序默认会从当前工作目录读取 `config.toml`。 + +- 如果 `config.toml` 不存在,程序会按内置默认值自动生成。 +- 在 Docker 中,工作目录是 `/app/data`,所以默认配置文件路径是 `/app/data/config.toml`。 +- 也可以通过 `--conf /path/to/config.toml` 指定自定义配置文件路径。 + +仓库中已提供示例配置文件:`data/config.example.toml`。 + +### 配置项说明 + +`tcp_bind` + +- TCP 控制通道监听地址,使用 TLS。 +- 示例:`0.0.0.0:29872` +- 删除该字段即可关闭 TCP 监听。 + +`quic_bind` + +- QUIC 控制通道监听地址。 +- 示例:`0.0.0.0:29872` +- 删除该字段即可关闭 QUIC。 + +`ws_bind` + +- WSS 监听地址。 +- 示例:`0.0.0.0:29872` +- 删除该字段即可关闭 WSS。 + +`network` + +- 默认虚拟网段 CIDR。 +- 示例:`10.26.0.0/24` + +`custom_nets` + +- 额外的命名虚拟网段。 +- TOML 写法示例: + +```toml +[custom_nets] +office = "10.27.0.0/24" +lab = "10.28.0.0/24" +``` + +`white_list` + +- 允许使用的网络编号列表。 +- 空数组表示不启用白名单限制。 + +`lease_duration` + +- 设备 IP 租约时长,单位为秒。 +- 示例:`86400`,即 24 小时。 + +`web_bind` + +- Web 管理界面和 HTTP API 的监听地址。 +- 示例:`0.0.0.0:29871` +- 删除该字段即可关闭 Web 管理功能。 + +`username` + +- Web 管理登录用户名。 + +`password` + +- Web 管理登录密码。 + +`persistence` + +- 是否启用 SQLite 持久化。 +- 开启后,网络、设备和互联服务端记录会保存到 `network_control.db`。 + +`cert` + +- PEM 证书文件路径。 +- 如果 `cert` 和 `key` 都不配置,程序会自动生成 `cert.pem`。 + +`key` + +- PEM 私钥文件路径。 +- 如果 `cert` 和 `key` 都不配置,程序会自动生成 `key.pem`。 + +`server_quic_bind` + +- 可选的服务端互联 QUIC 监听地址。 +- 示例:`0.0.0.0:29873` + +`peer_servers` + +- 需要互联的上游或同级服务端地址列表。 +- 示例: + +```toml +peer_servers = ["server1.example.com:29873", "192.168.1.10:29873"] +``` + +`server_token` + +- 服务端之间鉴权用的共享令牌。 +- 开启 `server_quic_bind` 或配置 `peer_servers` 时建议同时设置。 + +### 运行时生成文件 + +程序会在当前工作目录下写入以下文件: + +- `config.toml` +- `network_control.db` +- `cert.pem` +- `key.pem` +- `logs/` +- `logs/log4rs.yaml` + +如果使用 Docker,建议把这些文件统一放到挂载目录中,避免容器重建后丢失。 + +### 额外说明 + +- 如果 `tcp_bind` 和 `ws_bind` 配置为同一个地址,程序会在同一端口上复用 TLS TCP 和 WSS。 +- 如果 `persistence = false`,运行状态不会写入 SQLite。 +- `cert` 和 `key` 支持绝对路径与相对路径。相对路径基于进程工作目录解析。 + +## Docker 部署说明 + +仓库中已包含以下文件: + +- `Dockerfile` +- `docker-compose.yml` +- `rust-toolchain.toml` + +### 为什么固定 Rust 版本 + +当前项目使用 Rust 2024 edition 语法。为了避免不同环境的 toolchain 版本差异导致语法或构建不兼容,仓库中已把构建版本固定为 `1.93.1`,位置如下: + +- `rust-toolchain.toml` +- `docker-compose.yml` 中的 `RUST_VERSION` + +这个版本高于项目实际最低要求,主要目的是避免 2024 edition 和相关语法特性带来的兼容问题。 + +### 持久化目录布局 + +Docker 中容器运行时的工作目录为: + +- `/app/data` + +`docker-compose.yml` 中已经把宿主机目录挂载到容器内: + +- 宿主机:`./data` +- 容器内:`/app/data` + +因此下面这些文件会直接持久化到宿主机: + +- `./data/config.toml` +- `./data/network_control.db` +- `./data/cert.pem` +- `./data/key.pem` +- `./data/logs/...` + +### 使用 Docker Compose 快速部署 + +1. 复制示例配置: + +```bash +cp data/config.example.toml data/config.toml +``` + +2. 按需修改 `data/config.toml`。 + +3. 构建并启动服务: + +```bash +docker compose up -d --build +``` + +4. 查看日志: + +```bash +docker compose logs -f +``` + +5. 停止服务: + +```bash +docker compose down +``` + +### 默认暴露端口 + +- `29871/tcp`:Web 管理界面 / HTTP API +- `29872/tcp`:TLS TCP 控制通道 +- `29872/udp`:QUIC 控制通道 +- `29873/udp`:可选的服务端互联 QUIC 端口 + +如果不使用 Web 管理或服务端互联,可以在 `docker-compose.yml` 中删除对应端口映射。 + +### 从空目录启动 + +如果希望让程序第一次启动时自动生成默认配置和证书,可以这样做: + +1. 创建数据目录: + +```bash +mkdir -p data +``` + +2. 启动容器: + +```bash +docker compose up -d --build +``` + +3. 首次启动完成后,检查 `./data` 下自动生成的文件。 + +这种方式适合初始化测试;正式部署时,建议先基于 `data/config.example.toml` 手动生成 `data/config.toml`。 + +### 更新部署 + +代码更新后,重新执行: + +```bash +docker compose up -d --build +``` + +因为所有持久化数据都保存在 `./data`,容器重建后不会丢失数据库、配置、证书和日志。 + +### 单独使用 Docker 命令 + +构建镜像: + +```bash +docker build -t vnts2:local . +``` + +运行容器: + +```bash +docker run -d \ + --name vnts2 \ + -p 29871:29871/tcp \ + -p 29872:29872/tcp \ + -p 29872:29872/udp \ + -p 29873:29873/udp \ + -v "$(pwd)/data:/app/data" \ + --restart unless-stopped \ + vnts2:local +``` + +### 排查建议 + +如果容器启动了但服务无法访问: + +- 检查 `config.toml` 中对应监听项是否启用。 +- 检查端口映射是否和配置中的监听地址一致。 +- 检查 `docker compose logs -f` 输出。 + +如果数据库没有持久化: + +- 确认 `persistence = true`。 +- 确认 `./data` 已挂载到 `/app/data`。 +- 确认程序实际工作目录就是预期的 `/app/data`。 + +如果没有生成 TLS 文件: + +- 只有在未显式配置 `cert` 和 `key` 时,程序才会自动生成 `cert.pem` 和 `key.pem`。 +- 自动生成的证书和私钥会写入工作目录,也就是 Docker 中的 `/app/data`。 diff --git a/build.rs b/build.rs index 7e046be..531c6c3 100644 --- a/build.rs +++ b/build.rs @@ -2,9 +2,14 @@ fn main() { let mut config = prost_build::Config::new(); config.protoc_arg("--experimental_allow_proto3_optional"); - config.compile_protos( - &["proto/control_message.proto", "proto/rpc.proto", "proto/server_message.proto"], - &["proto"], - ) - .unwrap(); + config + .compile_protos( + &[ + "proto/control_message.proto", + "proto/rpc.proto", + "proto/server_message.proto", + ], + &["proto"], + ) + .unwrap(); } diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/data/config.example.toml b/data/config.example.toml new file mode 100644 index 0000000..16e3e0f --- /dev/null +++ b/data/config.example.toml @@ -0,0 +1,40 @@ +# Listener addresses. Remove a line to disable that protocol. +tcp_bind = "0.0.0.0:29872" +quic_bind = "0.0.0.0:29872" +ws_bind = "0.0.0.0:29872" + +# Optional allow-list. Currently not enforced for registration. +white_list = [] + +# Lease duration in seconds. +lease_duration = 86400 + +# Web admin UI/API login. +web_bind = "0.0.0.0:29871" +username = "admin" +password = "Change-This-Web-Password" + +# SQLite persistence. +persistence = true + +# Leave these unset to auto-generate cert.pem and key.pem. +# cert = "cert.pem" +# key = "key.pem" + +# Optional peer-server settings. +# server_quic_bind = "0.0.0.0:29873" +# peer_servers = ["server1.example.com:29873", "192.168.1.100:29873"] +# server_token = "your-secret-token" + +# Bootstrap networks. When persistence is enabled and the database is empty, +# these entries are imported into the DB on first start. +[custom_nets] +default = "172.16.57.0/24" +# office = "172.16.58.0/24" +# dev = "172.16.59.0/24" + +# Bootstrap secrets for the first import. +[network_secrets] +default = "Change-This-Default-Secret-To-A-Strong-One-123!" +# office = "Change-This-Office-Secret-To-A-Strong-One-456!" +# dev = "Change-This-Dev-Secret-To-A-Strong-One-789!" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7c07b94 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + vnts: + build: + context: . + args: + RUST_VERSION: 1.93.1 + image: vnts2:local + container_name: vnts2 + restart: unless-stopped + command: ["vnts2", "--conf", "/app/data/config.toml"] + volumes: + # The app writes config.toml, network_control.db, cert.pem, key.pem and logs + # relative to its working directory. Persist all of them in ./data. + - ./data:/app/data + ports: + - "29871:29871/tcp" + - "29872:29872/tcp" + - "29872:29872/udp" + # Optional peer server port used by server_quic_bind. + - "29873:29873/udp" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..f2c0da8 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.93.1" +profile = "minimal" diff --git a/src/http/web_server.rs b/src/http/web_server.rs index 0302d81..d318954 100644 --- a/src/http/web_server.rs +++ b/src/http/web_server.rs @@ -1,13 +1,13 @@ use crate::ControlService; use crate::server::control_server::service::{DeviceInfoVO, NetworkInfoVO}; use axum::{ + Json, Router, body::Body, extract::{Path, Query, State}, - http::{header, HeaderMap, Request, StatusCode, Uri}, + http::{HeaderMap, Request, StatusCode, Uri, header}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{delete, get, post, put}, - Json, Router, }; use jsonwebtoken::{DecodingKey, EncodingKey, Validation}; use mime_guess::from_path; @@ -205,6 +205,7 @@ struct CreateNetworkRequest { network_code: String, gateway: String, netmask: u8, + secret: String, lease_duration: Option, } @@ -223,13 +224,17 @@ async fn create_network( return ApiResponse::<()>::err("无效的掩码").into_response(); } - let lease_duration = body - .lease_duration - .map(std::time::Duration::from_secs); + let lease_duration = body.lease_duration.map(std::time::Duration::from_secs); match state .control_service - .add_network(body.network_code, gateway, body.netmask, lease_duration) + .add_network( + body.network_code, + gateway, + body.netmask, + lease_duration, + body.secret, + ) .await { Ok(()) => ApiResponse::<()>::ok_msg("创建成功").into_response(), @@ -241,6 +246,7 @@ async fn create_network( struct UpdateNetworkRequest { gateway: String, netmask: u8, + secret: String, lease_duration: u64, } @@ -264,7 +270,13 @@ async fn update_network( match state .control_service - .update_network(&network_code, gateway, body.netmask, lease_duration) + .update_network( + &network_code, + gateway, + body.netmask, + lease_duration, + body.secret, + ) .await { Ok(()) => ApiResponse::<()>::ok_msg("更新成功").into_response(), @@ -392,7 +404,7 @@ async fn static_handler(uri: Uri) -> impl IntoResponse { [(header::CONTENT_TYPE, mime.as_ref())], Body::from(content.data), ) - .into_response(); + .into_response(); } (StatusCode::NOT_FOUND, "404 Not Found").into_response() diff --git a/src/main.rs b/src/main.rs index 2125218..626e60b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use crate::server::TurnConfig; use crate::server::control_server::service::ControlService; use crate::server::peer_server::PeerServerManager; -use crate::utils::config::ConfigFile; +use crate::utils::config::{ConfigFile, LoadedConfig}; use clap::Parser; use std::path::PathBuf; use std::sync::Arc; @@ -32,13 +32,25 @@ async fn main() { } utils::log::log_init("vnts2"); log::info!("version: {:?}", env!("CARGO_PKG_VERSION")); - let conf = match ConfigFile::load_from(args.conf) { + let loaded = match ConfigFile::load_with_meta(args.conf) { Ok(conf) => conf, Err(e) => { log::error!("{e:?}"); panic!("{e:?}") } }; + let LoadedConfig { + path: config_path, + config: conf, + created_default, + } = loaded; + log::info!("Loaded config from {}", config_path.display()); + if created_default { + log::warn!( + "Config file did not exist. A default config was created at {}", + config_path.display() + ); + } if conf.persistence { if let Err(e) = server::control_server::db::init_db_pool().await { log::error!("{:?}", e); @@ -65,12 +77,44 @@ async fn main() { }; let web_bind = conf.web_bind; - let username = conf.username.unwrap_or("admin".to_string()); - let password = conf.password.unwrap_or("admin".to_string()); + let username = conf.username.unwrap_or_else(|| { + log::warn!( + "username is not set in {}. Falling back to default username 'admin'", + config_path.display() + ); + "admin".to_string() + }); + let password = conf.password.unwrap_or_else(|| { + log::warn!( + "password is not set in {}. Falling back to default password", + config_path.display() + ); + "admin".to_string() + }); + if let Some(bind_addr) = web_bind { + let using_default_credentials = username == "admin" && password == "admin"; + log::info!( + "Web auth loaded for {} with username '{}'", + bind_addr, + username + ); + if using_default_credentials { + log::warn!( + "Web auth is still using default credentials admin/admin from {}", + config_path.display() + ); + } + } + + log::info!( + "Loaded {} bootstrap networks and {} bootstrap secrets from config", + conf.custom_nets.len(), + conf.network_secrets.len() + ); let control_service = ControlService::new( - conf.network, conf.custom_nets, + conf.network_secrets, Duration::from_secs(conf.lease_duration), ) .await; @@ -101,21 +145,29 @@ struct PeerConf { } async fn init_peer_manager(conf: &PeerConf, control_service: &ControlService) { - let server_token = conf.server_token.clone().unwrap_or_else(|| "default_token".to_string()); + let server_token = conf + .server_token + .clone() + .unwrap_or_else(|| "default_token".to_string()); let network_state_provider = control_service.get_network_state_provider().clone(); let peer_manager = Arc::new(PeerServerManager::new(server_token, network_state_provider)); control_service.set_peer_manager(peer_manager.clone()); if let Some(server_quic_bind) = conf.server_quic_bind { - let (certs, key) = match crate::utils::cert::get_cert_and_key(conf.cert.clone(), conf.key.clone()) { - Ok((certs, key)) => (certs, key), - Err(e) => { - log::error!("Failed to load cert/key for peer server: {:?}", e); - panic!("{:?}", e) - } - }; - if let Err(e) = peer_manager.clone().start_server(server_quic_bind, certs, key).await { + let (certs, key) = + match crate::utils::cert::get_cert_and_key(conf.cert.clone(), conf.key.clone()) { + Ok((certs, key)) => (certs, key), + Err(e) => { + log::error!("Failed to load cert/key for peer server: {:?}", e); + panic!("{:?}", e) + } + }; + if let Err(e) = peer_manager + .clone() + .start_server(server_quic_bind, certs, key) + .await + { log::error!("Failed to start peer server: {:?}", e); } else { log::info!("Peer server started on {}", server_quic_bind); diff --git a/src/protocol/control_message.rs b/src/protocol/control_message.rs index 5c5646f..24b93e2 100644 --- a/src/protocol/control_message.rs +++ b/src/protocol/control_message.rs @@ -108,7 +108,6 @@ pub struct RegResponseMsg { pub server_version: String, } impl RegResponseMsg { - pub fn to(self) -> proto::RegResponseMsg { proto::RegResponseMsg { ip: self.ip.into(), @@ -201,7 +200,6 @@ pub enum ResponseMessage { ConfirmReg(ConfirmRegResponseMsg), } impl ResponseMessage { - pub fn encode(self) -> BytesMut { let response_payload = match self { ResponseMessage::Reg(reg) => ResponsePayload::Reg(reg.to()), diff --git a/src/protocol/server_message.rs b/src/protocol/server_message.rs index 795a9f5..ab9e3b2 100644 --- a/src/protocol/server_message.rs +++ b/src/protocol/server_message.rs @@ -2,5 +2,5 @@ mod proto { include!(concat!(env!("OUT_DIR"), "/protocol.server_message.rs")); } -pub use proto::*; pub use proto::server_message::Payload; +pub use proto::*; diff --git a/src/server/control_server/db.rs b/src/server/control_server/db.rs index ec84ca8..a72b537 100644 --- a/src/server/control_server/db.rs +++ b/src/server/control_server/db.rs @@ -10,6 +10,10 @@ use std::path::Path; static DB_POOL: OnceCell = OnceCell::new(); const DB_FILE: &str = "network_control.db"; +pub fn db_pool_initialized() -> bool { + DB_POOL.get().is_some() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum NetworkSource { Config = 0, @@ -66,6 +70,7 @@ pub struct NetworkRecord { pub network_code: String, pub gateway: String, pub netmask: u8, + pub secret: String, pub lease_duration: i64, pub source: NetworkSource, pub created_at: i64, @@ -74,7 +79,7 @@ pub struct NetworkRecord { impl NetworkRecord { pub fn to_ipv4_net(&self) -> Option { let gateway: Ipv4Addr = self.gateway.parse().ok()?; - let network_ip = Ipv4Addr::from(u32::from(gateway) - 1); + let network_ip = Ipv4Addr::from(u32::from(gateway).checked_sub(1)?); Ipv4Net::new(network_ip, self.netmask).ok() } } @@ -116,6 +121,7 @@ pub async fn init_db_pool() -> anyhow::Result<()> { network_code TEXT PRIMARY KEY, gateway TEXT NOT NULL, netmask INTEGER NOT NULL, + secret TEXT NOT NULL DEFAULT '', lease_duration INTEGER NOT NULL, source INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL @@ -129,6 +135,9 @@ pub async fn init_db_pool() -> anyhow::Result<()> { let _ = sqlx::query("ALTER TABLE networks ADD COLUMN source INTEGER NOT NULL DEFAULT 0") .execute(&pool) .await; + let _ = sqlx::query("ALTER TABLE networks ADD COLUMN secret TEXT NOT NULL DEFAULT ''") + .execute(&pool) + .await; sqlx::query( "CREATE TABLE IF NOT EXISTS devices ( @@ -170,12 +179,13 @@ pub async fn save_network(record: &NetworkRecord) -> anyhow::Result<()> { }; sqlx::query( - r#"INSERT OR REPLACE INTO networks (network_code, gateway, netmask, lease_duration, source, created_at) - VALUES (?, ?, ?, ?, ?, ?)"#, + r#"INSERT OR REPLACE INTO networks (network_code, gateway, netmask, secret, lease_duration, source, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)"#, ) .bind(&record.network_code) .bind(&record.gateway) .bind(record.netmask as i32) + .bind(&record.secret) .bind(record.lease_duration) .bind(record.source as i32) .bind(record.created_at) @@ -192,12 +202,13 @@ pub async fn save_network_if_not_exists(record: &NetworkRecord) -> anyhow::Resul }; let result = sqlx::query( - r#"INSERT OR IGNORE INTO networks (network_code, gateway, netmask, lease_duration, source, created_at) - VALUES (?, ?, ?, ?, ?, ?)"#, + r#"INSERT OR IGNORE INTO networks (network_code, gateway, netmask, secret, lease_duration, source, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)"#, ) .bind(&record.network_code) .bind(&record.gateway) .bind(record.netmask as i32) + .bind(&record.secret) .bind(record.lease_duration) .bind(record.source as i32) .bind(record.created_at) @@ -212,6 +223,7 @@ pub async fn update_network( network_code: &str, gateway: &str, netmask: u8, + secret: &str, lease_duration: i64, ) -> anyhow::Result { let Some(pool) = DB_POOL.get() else { @@ -219,10 +231,11 @@ pub async fn update_network( }; let result = sqlx::query( - r#"UPDATE networks SET gateway = ?, netmask = ?, lease_duration = ? WHERE network_code = ?"#, + r#"UPDATE networks SET gateway = ?, netmask = ?, secret = ?, lease_duration = ? WHERE network_code = ?"#, ) .bind(gateway) .bind(netmask as i32) + .bind(secret) .bind(lease_duration) .bind(network_code) .execute(pool) @@ -253,7 +266,7 @@ pub async fn get_network(network_code: &str) -> anyhow::Result anyhow::Result anyhow::Result> { }; let records: Vec = sqlx::query( - r#"SELECT network_code, gateway, netmask, lease_duration, source, created_at FROM networks ORDER BY created_at"#, + r#"SELECT network_code, gateway, netmask, secret, lease_duration, source, created_at FROM networks ORDER BY created_at"#, ) .fetch(pool) .try_filter_map(|row| async move { @@ -293,6 +307,7 @@ pub async fn load_all_networks() -> anyhow::Result> { network_code: row.try_get("network_code")?, gateway: row.try_get("gateway")?, netmask: netmask as u8, + secret: row.try_get("secret")?, lease_duration: row.try_get("lease_duration")?, source: NetworkSource::from_i32(source), created_at: row.try_get("created_at")?, @@ -368,7 +383,10 @@ pub async fn release_device_ip(network_code: &str, device_id: &str) -> anyhow::R } #[allow(dead_code)] -pub async fn get_device(network_code: &str, device_id: &str) -> anyhow::Result> { +pub async fn get_device( + network_code: &str, + device_id: &str, +) -> anyhow::Result> { let Some(pool) = DB_POOL.get() else { return Ok(None); }; diff --git a/src/server/control_server/handler.rs b/src/server/control_server/handler.rs index 8fafcbd..c32d68e 100644 --- a/src/server/control_server/handler.rs +++ b/src/server/control_server/handler.rs @@ -1,6 +1,6 @@ use crate::protocol::control_message::{ - ClientSimpleInfoList, ConfirmRegResponseMsg, ErrorResponseMsg, RegResponseMsg, RequestMessage, ResponseMessage, - SelectiveBroadcast, + ClientSimpleInfoList, ConfirmRegResponseMsg, ErrorResponseMsg, RegResponseMsg, RequestMessage, + ResponseMessage, SelectiveBroadcast, }; use crate::protocol::ip_packet_protocol::{HEAD_LENGTH, MsgType, NetPacket}; use crate::protocol::rpc_message::rpc_message_request::RpcReqPayload; @@ -100,7 +100,11 @@ impl ControlHandler { bail!("Session is not in pending confirmation state"); } - if let Err(e) = session.network_state.confirm_registration(&session.network_code, &session.device_id).await { + if let Err(e) = session + .network_state + .confirm_registration(&session.network_code, &session.device_id) + .await + { log::error!("Failed to save confirmed device: {:?}", e); let msg_response = ErrorResponseMsg { code: 500, @@ -242,7 +246,9 @@ impl ControlHandler { let network_code = session.network_code.clone(); let data = buf.freeze(); - let forwarded = peer_manager.forward_with_best_route(&network_code, dest, data.clone()).await; + let forwarded = peer_manager + .forward_with_best_route(&network_code, dest, data.clone()) + .await; if !forwarded { if let Some(sender) = session.network_state.sender_map().get(&dest) { diff --git a/src/server/control_server/service.rs b/src/server/control_server/service.rs index 96a78b1..8b205dc 100644 --- a/src/server/control_server/service.rs +++ b/src/server/control_server/service.rs @@ -1,7 +1,12 @@ use crate::protocol::control_message::{RegRequestMsg, RegistrationMode}; use crate::server::control_server::db; use crate::server::control_server::db::{NetworkRecord, NetworkSource}; -use crate::server::network_state_provider::{i64_to_system_time, NetworkState, NetworkStateProvider}; +use crate::server::network_state_provider::{ + NetworkState, NetworkStateProvider, i64_to_system_time, +}; +use crate::utils::config::{ + DEFAULT_NETWORK_CODE, validate_network_code_value, validate_network_secret_value, +}; use anyhow::{Context, bail}; use bytes::Bytes; use dashmap::DashMap; @@ -31,11 +36,16 @@ pub struct NetworkConfig { pub source: NetworkSource, } +#[derive(Clone)] +struct ManagedNetwork { + config: NetworkConfig, + secret: String, +} + #[derive(Clone)] pub struct ControlService { - default_net: Ipv4Net, default_lease_duration: Duration, - db_nets: Arc>>, + db_nets: Arc>>, network_state_provider: NetworkStateProvider, network_init_locks: Arc>>>, peer_manager: Arc>>>, @@ -43,17 +53,15 @@ pub struct ControlService { impl ControlService { pub async fn new( - default_net: Ipv4Net, custom_nets: HashMap, + network_secrets: HashMap, lease_duration: Duration, ) -> Self { let network_states = Arc::new(DashMap::new()); - - Self::save_config_networks_to_db(&default_net, &custom_nets, lease_duration).await; - let db_nets = Self::load_networks_from_db().await; + let db_nets = + Self::load_or_initialize_networks(custom_nets, network_secrets, lease_duration).await; let service = Self { - default_net, default_lease_duration: lease_duration, db_nets: Arc::new(RwLock::new(db_nets)), network_state_provider: NetworkStateProvider::new(network_states), @@ -70,9 +78,65 @@ impl ControlService { service } - async fn save_config_networks_to_db( - _default_net: &Ipv4Net, + async fn load_or_initialize_networks( + custom_nets: HashMap, + network_secrets: HashMap, + lease_duration: Duration, + ) -> HashMap { + if !db::db_pool_initialized() { + return Self::build_networks_from_config(custom_nets, network_secrets, lease_duration); + } + + let mut records = Self::load_network_records_from_db().await; + if records.is_empty() { + Self::seed_config_networks_to_db(&custom_nets, &network_secrets, lease_duration).await; + records = Self::load_network_records_from_db().await; + } else if Self::backfill_missing_secrets_from_config(&records, &network_secrets).await { + records = Self::load_network_records_from_db().await; + } + + if records.is_empty() { + log::warn!("No networks found in DB after initialization, falling back to config"); + return Self::build_networks_from_config(custom_nets, network_secrets, lease_duration); + } + + Self::managed_networks_from_records(records) + } + + fn build_networks_from_config( + custom_nets: HashMap, + network_secrets: HashMap, + lease_duration: Duration, + ) -> HashMap { + custom_nets + .into_iter() + .filter_map(|(code, net)| { + let Some(secret) = network_secrets.get(&code).cloned() else { + log::error!( + "Missing secret for network '{}' while building config fallback", + code + ); + return None; + }; + + Some(( + code, + ManagedNetwork { + config: NetworkConfig { + net, + lease_duration, + source: NetworkSource::Config, + }, + secret, + }, + )) + }) + .collect() + } + + async fn seed_config_networks_to_db( custom_nets: &HashMap, + network_secrets: &HashMap, lease_duration: Duration, ) { let now = std::time::SystemTime::now() @@ -82,11 +146,19 @@ impl ControlService { let lease_secs = lease_duration.as_secs() as i64; for (code, net) in custom_nets { + let Some(secret) = network_secrets.get(code) else { + log::error!( + "Skip seeding network '{}' because its secret is missing", + code + ); + continue; + }; let gateway = Ipv4Addr::from(u32::from(net.network()) + 1); let record = NetworkRecord { network_code: code.clone(), gateway: gateway.to_string(), netmask: net.prefix_len(), + secret: secret.clone(), lease_duration: lease_secs, source: NetworkSource::Config, created_at: now, @@ -99,27 +171,81 @@ impl ControlService { } } - async fn load_networks_from_db() -> HashMap { - let mut nets = HashMap::new(); - match db::load_all_networks().await { - Ok(records) => { - for record in records { - if let Some(net) = record.to_ipv4_net() { - nets.insert( - record.network_code, - NetworkConfig { - net, - lease_duration: Duration::from_secs(record.lease_duration as u64), - source: record.source, - }, - ); - } + async fn backfill_missing_secrets_from_config( + records: &[NetworkRecord], + network_secrets: &HashMap, + ) -> bool { + let mut updated = false; + + for record in records { + if !record.secret.trim().is_empty() { + continue; + } + + let Some(secret) = network_secrets.get(&record.network_code) else { + continue; + }; + + let mut updated_record = record.clone(); + updated_record.secret = secret.clone(); + + match db::save_network(&updated_record).await { + Ok(()) => { + updated = true; + log::info!( + "Backfilled missing secret for network '{}' from config", + record.network_code + ); + } + Err(e) => { + log::error!( + "Failed to backfill secret for network '{}': {}", + record.network_code, + e + ); } } + } + + updated + } + + async fn load_network_records_from_db() -> Vec { + match db::load_all_networks().await { + Ok(records) => records, Err(e) => { log::error!("Failed to load networks from DB: {}", e); + Vec::new() + } + } + } + + fn managed_networks_from_records( + records: Vec, + ) -> HashMap { + let mut nets = HashMap::new(); + + for record in records { + if let Some(net) = record.to_ipv4_net() { + nets.insert( + record.network_code, + ManagedNetwork { + config: NetworkConfig { + net, + lease_duration: Duration::from_secs(record.lease_duration as u64), + source: record.source, + }, + secret: record.secret, + }, + ); + } else { + log::error!( + "Skip network '{}' because gateway/netmask in DB is invalid", + record.network_code + ); } } + nets } @@ -129,15 +255,10 @@ impl ControlService { sender: Sender, ) -> anyhow::Result { reg_req.check()?; + self.validate_registration(®_req)?; let network_code = reg_req.network_code.clone(); let registration_mode = reg_req.registration_mode; - - let is_new_network = !self.db_nets.read().contains_key(®_req.network_code); - let config = self.network_config(®_req.network_code, reg_req.ip); - - if is_new_network { - self.db_nets.write().insert(reg_req.network_code.clone(), config); - } + let config = self.network_config(®_req.network_code)?; let state = self .get_or_create_network_state(reg_req.network_code.clone(), config) @@ -147,13 +268,14 @@ impl ControlService { let random_id = rand::rng().next_u64(); let device_id = reg_req.device_id.clone(); - let (ip, _old_ip, entry) = match state.allocate_ip_and_get_entry(reg_req, random_id, sender) { - Ok(rs) => rs, - Err(e) => { - log::warn!("network_code={network_code},device_id={device_id},e={e:?}"); - return Err(e); - } - }; + let (ip, _old_ip, entry) = + match state.allocate_ip_and_get_entry(reg_req, random_id, sender) { + Ok(rs) => rs, + Err(e) => { + log::warn!("network_code={network_code},device_id={device_id},e={e:?}"); + return Err(e); + } + }; ( Session { @@ -172,27 +294,6 @@ impl ControlService { ) }; - if is_new_network { - let gateway = Ipv4Addr::from(u32::from(config.net.network()) + 1); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64; - let record = NetworkRecord { - network_code: network_code.clone(), - gateway: gateway.to_string(), - netmask: config.net.prefix_len(), - lease_duration: config.lease_duration.as_secs() as i64, - source: NetworkSource::DeviceRegister, - created_at: now, - }; - tokio::spawn(async move { - if let Err(e) = db::save_network(&record).await { - log::error!("Failed to save new network: {:?}", e); - } - }); - } - if matches!(registration_mode, RegistrationMode::Normal) { if let Some(entry) = entry { let nc = network_code.clone(); @@ -208,20 +309,46 @@ impl ControlService { Ok(session) } - fn network_config(&self, network_code: &str, ip: Option) -> NetworkConfig { - if let Some(config) = self.db_nets.read().get(network_code) { - return *config; - } - let net = if let Some(ip) = ip { - Ipv4Net::new_assert(Ipv4Net::new_assert(ip, 24).network(), 24) - } else { - self.default_net + fn network_config(&self, network_code: &str) -> anyhow::Result { + self.db_nets + .read() + .get(network_code) + .map(|network| network.config) + .ok_or_else(|| anyhow::anyhow!("network_code '{}' is not allowed", network_code)) + } + + fn build_network_from_gateway(gateway: Ipv4Addr, netmask: u8) -> anyhow::Result { + let network_ip = u32::from(gateway) + .checked_sub(1) + .context("gateway must be the first usable IP in the subnet")?; + Ipv4Net::new(Ipv4Addr::from(network_ip), netmask).context("Invalid network") + } + + fn validate_registration(&self, reg_req: &RegRequestMsg) -> anyhow::Result<()> { + let networks = self.db_nets.read(); + let Some(network) = networks.get(®_req.network_code) else { + bail!( + "network_code '{}' is not allowed by server configuration or database", + reg_req.network_code + ); + }; + validate_network_secret_value(®_req.network_code, &network.secret)?; + + let Some(provided_secret) = reg_req.key_sign.as_deref() else { + bail!( + "network_code '{}' requires a network secret", + reg_req.network_code + ); }; - NetworkConfig { - net, - lease_duration: self.default_lease_duration, - source: NetworkSource::DeviceRegister, + + if provided_secret != network.secret { + bail!( + "invalid network secret for network_code '{}'", + reg_req.network_code + ); } + + Ok(()) } /// DCL: 获取或创建 NetworkState @@ -268,7 +395,10 @@ impl ControlService { .map(|v| v.key().clone()) .collect(); for network_code in keys { - let option = self.network_state_provider.get(&network_code).map(|v| v.clone()); + let option = self + .network_state_provider + .get(&network_code) + .map(|v| v.clone()); if let Some(state) = option { if !state.is_empty() { continue; @@ -377,12 +507,20 @@ impl ControlService { gateway: Ipv4Addr, netmask: u8, lease_duration: Option, + secret: String, ) -> anyhow::Result<()> { + validate_network_code_value(&network_code, "network_code")?; + validate_network_secret_value(&network_code, &secret)?; + if self.db_nets.read().contains_key(&network_code) { + bail!("network_code '{}' already exists", network_code); + } + if self.db_nets.read().contains_key(&network_code) { bail!("网络编号 '{}' 已存在", network_code); } let lease_duration = lease_duration.unwrap_or(self.default_lease_duration); + let net = Self::build_network_from_gateway(gateway, netmask)?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -392,6 +530,7 @@ impl ControlService { network_code: network_code.clone(), gateway: gateway.to_string(), netmask, + secret: secret.clone(), lease_duration: lease_duration.as_secs() as i64, source: NetworkSource::Manual, created_at: now, @@ -399,14 +538,15 @@ impl ControlService { db::save_network(&record).await?; - let network_ip = Ipv4Addr::from(u32::from(gateway) - 1); - let net = Ipv4Net::new(network_ip, netmask).context("Invalid network")?; self.db_nets.write().insert( network_code, - NetworkConfig { - net, - lease_duration, - source: NetworkSource::Manual, + ManagedNetwork { + config: NetworkConfig { + net, + lease_duration, + source: NetworkSource::Manual, + }, + secret, }, ); @@ -419,12 +559,14 @@ impl ControlService { gateway: Ipv4Addr, netmask: u8, lease_duration: Duration, + secret: String, ) -> anyhow::Result<()> { + validate_network_secret_value(network_code, &secret)?; let original_source = self .db_nets .read() .get(network_code) - .map(|c| c.source) + .map(|c| c.config.source) .ok_or_else(|| anyhow::anyhow!("网络编号 '{}' 不存在", network_code))?; if db::network_has_devices(network_code).await? { @@ -442,18 +584,21 @@ impl ControlService { network_code, &gateway.to_string(), netmask, + &secret, lease_duration.as_secs() as i64, ) .await?; - let network_ip = Ipv4Addr::from(u32::from(gateway) - 1); - let net = Ipv4Net::new(network_ip, netmask).context("Invalid network")?; + let net = Self::build_network_from_gateway(gateway, netmask)?; self.db_nets.write().insert( network_code.to_string(), - NetworkConfig { - net, - lease_duration, - source: original_source, + ManagedNetwork { + config: NetworkConfig { + net, + lease_duration, + source: original_source, + }, + secret, }, ); @@ -463,6 +608,10 @@ impl ControlService { } pub async fn delete_network(&self, network_code: &str) -> anyhow::Result<()> { + if network_code == DEFAULT_NETWORK_CODE { + bail!("the default network cannot be deleted"); + } + if !self.db_nets.read().contains_key(network_code) { bail!("网络编号 '{}' 不存在", network_code); } @@ -486,11 +635,7 @@ impl ControlService { Ok(()) } - pub async fn delete_device( - &self, - network_code: &str, - device_id: &str, - ) -> anyhow::Result<()> { + pub async fn delete_device(&self, network_code: &str, device_id: &str) -> anyhow::Result<()> { if let Some(state) = self.network_state_provider.get(network_code) { if state.is_device_online(device_id) { bail!("设备在线,无法删除"); @@ -506,11 +651,15 @@ impl ControlService { impl ControlService { pub fn get_network_codes(&self) -> Vec { - self.db_nets.read().keys().cloned().collect() + let mut codes: Vec = self.db_nets.read().keys().cloned().collect(); + codes.sort(); + codes } pub fn get_network_state(&self, network_code: &str) -> Option> { - self.network_state_provider.get(network_code).map(|s| s.clone()) + self.network_state_provider + .get(network_code) + .map(|s| s.clone()) } pub fn set_peer_manager(&self, manager: Arc) { @@ -521,17 +670,16 @@ impl ControlService { self.peer_manager.read().clone() } - pub fn get_network_state_provider(&self) -> &NetworkStateProvider { &self.network_state_provider } pub fn get_network_info(&self) -> Vec { let db_nets = self.db_nets.read(); - db_nets + let mut networks: Vec = db_nets .iter() - .map(|(code, config)| { - let gateway = Ipv4Addr::from(u32::from(config.net.network()) + 1); + .map(|(code, network)| { + let gateway = Ipv4Addr::from(u32::from(network.config.net.network()) + 1); let (all_count, online_count) = self .network_state_provider .get(code) @@ -541,15 +689,20 @@ impl ControlService { NetworkInfoVO { network_code: code.clone(), gateway, - netmask: config.net.prefix_len(), - net: config.net, - lease_duration: config.lease_duration.as_secs(), - source: config.source, + netmask: network.config.net.prefix_len(), + net: network.config.net, + secret: network.secret.clone(), + lease_duration: network.config.lease_duration.as_secs(), + source: network.config.source, + is_default: code == DEFAULT_NETWORK_CODE, + can_delete: code != DEFAULT_NETWORK_CODE, all_count, online_count, } }) - .collect() + .collect(); + networks.sort_by(|a, b| a.network_code.cmp(&b.network_code)); + networks } pub async fn get_device_info(&self, network_code: &str) -> Option> { @@ -558,7 +711,8 @@ impl ControlService { } else { match db::load_all_devices(network_code).await { Ok(records) => { - let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); + let format = + format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); records .into_iter() .map(|r| { @@ -570,7 +724,9 @@ impl ControlService { device_version: r.device_version, ip: r.ip.as_ref().and_then(|s| s.parse().ok()), status: "Offline".to_string(), - last_connect_time: last_connect_time.format(&format).unwrap_or_default(), + last_connect_time: last_connect_time + .format(&format) + .unwrap_or_default(), disconnect_time: None, latency_ms: None, server_addr: None, @@ -625,7 +781,9 @@ impl Drop for Session { fn drop(&mut self) { match self.registration_status { RegistrationStatus::Confirmed => { - let record = self.network_state.offline_ip(&self.device_id, self.ip, self.random_id); + let record = + self.network_state + .offline_ip(&self.device_id, self.ip, self.random_id); if let Some(record) = record { tokio::spawn(async move { if let Err(e) = db::save_or_update_device(&record).await { @@ -641,7 +799,11 @@ impl Drop for Session { self.device_id, self.ip ); - self.network_state.release_pre_registered_ip(&self.device_id, self.ip, self.random_id); + self.network_state.release_pre_registered_ip( + &self.device_id, + self.ip, + self.random_id, + ); } } } @@ -668,8 +830,11 @@ pub struct NetworkInfoVO { pub gateway: Ipv4Addr, pub netmask: u8, pub net: Ipv4Net, + pub secret: String, pub lease_duration: u64, pub source: NetworkSource, + pub is_default: bool, + pub can_delete: bool, pub all_count: u32, pub online_count: u32, } diff --git a/src/server/mod.rs b/src/server/mod.rs index 0dbab5b..6eced94 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -3,10 +3,10 @@ pub mod network_state_provider; pub mod peer_server; // 传输层 -pub mod tcp; pub mod quic; -pub mod websocket; +pub mod tcp; pub mod tcp_websocket; +pub mod websocket; use crate::server::control_server::service::ControlService; use crate::server::quic::QuicConfig; diff --git a/src/server/network_state_provider.rs b/src/server/network_state_provider.rs index 2abf0be..d7c5eb7 100644 --- a/src/server/network_state_provider.rs +++ b/src/server/network_state_provider.rs @@ -189,7 +189,12 @@ impl NetworkState { } /// 设备离线,返回需要持久化的记录。random_id 用于判断是否为当前会话。 - pub fn offline_ip(&self, device_id: &String, ip: Ipv4Addr, random_id: u64) -> Option { + pub fn offline_ip( + &self, + device_id: &String, + ip: Ipv4Addr, + random_id: u64, + ) -> Option { let mut guard = self.lease_state.lock(); let (success, record) = guard.offline_ip(&self.network_code, device_id, ip, random_id); if success { @@ -264,7 +269,11 @@ impl NetworkState { if should_remove { self.sender_map.remove(&ip); self.traffic_stats_map.remove(&ip); - log::info!("Released pre-registered IP device_id={}, ip={}", device_id, ip); + log::info!( + "Released pre-registered IP device_id={}, ip={}", + device_id, + ip + ); } } @@ -275,12 +284,18 @@ impl NetworkState { pub fn get_device_entry_by_ip(&self, ip: Ipv4Addr) -> Option { let guard = self.lease_state.lock(); - guard.device_ip_map.get(&ip) + guard + .device_ip_map + .get(&ip) .and_then(|device_id| guard.device_map.get(device_id).cloned()) } /// 同步保存到 DB 后再确认,防止 Drop 时状态不一致 - pub async fn confirm_registration(&self, network_code: &str, device_id: &str) -> anyhow::Result<()> { + pub async fn confirm_registration( + &self, + network_code: &str, + device_id: &str, + ) -> anyhow::Result<()> { if let Some(entry) = self.get_device_entry(device_id) { let record = entry.to_record(network_code); db::save_or_update_device(&record).await?; @@ -296,7 +311,8 @@ impl NetworkState { sender: Sender, ) -> anyhow::Result<(Ipv4Addr, Option, Option)> { let mut guard = self.lease_state.lock(); - let (ip, old_ip) = guard.allocate_ip(&self.net, self.gateway, reg_req.clone(), random_id)?; + let (ip, old_ip) = + guard.allocate_ip(&self.net, self.gateway, reg_req.clone(), random_id)?; if let Some(old_ip) = old_ip { self.sender_map.remove(&old_ip); @@ -307,7 +323,8 @@ impl NetworkState { self.sender_map.insert(ip, sender); if let Some(entry) = &entry { - self.traffic_stats_map.insert(ip, entry.traffic_stats.clone()); + self.traffic_stats_map + .insert(ip, entry.traffic_stats.clone()); } Ok((ip, old_ip, entry)) @@ -330,14 +347,18 @@ impl NetworkState { pub fn get_all_device_simple_info(&self) -> Vec<(String, Option, bool, u64)> { let guard = self.lease_state.lock(); - guard.device_map.values().map(|entry| { - ( - entry.device_id.clone(), - entry.ip, - entry.is_connected, - entry.data_version, - ) - }).collect() + guard + .device_map + .values() + .map(|entry| { + ( + entry.device_id.clone(), + entry.ip, + entry.is_connected, + entry.data_version, + ) + }) + .collect() } pub fn is_empty(&self) -> bool { @@ -354,8 +375,8 @@ impl NetworkState { } pub fn get_device_infos(&self) -> Vec { - use time::macros::format_description; use time::OffsetDateTime; + use time::macros::format_description; let guard = self.lease_state.lock(); let mut list = Vec::new(); @@ -431,7 +452,10 @@ impl NetworkState { }) } - pub fn client_info_list(&self, exclude_ip: Ipv4Addr) -> Vec { + pub fn client_info_list( + &self, + exclude_ip: Ipv4Addr, + ) -> Vec { use crate::protocol::rpc_message::ClientInfo; use time::OffsetDateTime; @@ -522,7 +546,8 @@ impl NetworkStateInner { fn add_device(&mut self, device_entry: DeviceEntry) { if let Some(ip) = device_entry.ip { - self.device_ip_map.insert(ip, device_entry.device_id.clone()); + self.device_ip_map + .insert(ip, device_entry.device_id.clone()); } self.device_map .insert(device_entry.device_id.clone(), device_entry); @@ -545,9 +570,10 @@ impl NetworkStateInner { ) -> anyhow::Result<(Ipv4Addr, Option)> { let expect_ip = reg_req.ip; - let existing_device_info = self.device_map.get(®_req.device_id).map(|e| { - (e.ip, expect_ip.is_none() || e.ip == expect_ip) - }); + let existing_device_info = self + .device_map + .get(®_req.device_id) + .map(|e| (e.ip, expect_ip.is_none() || e.ip == expect_ip)); if let Some((current_ip, ip_matches)) = existing_device_info { if ip_matches { @@ -712,9 +738,14 @@ impl NetworkState { } } - pub async fn new_from_db(network_code: String, net: Ipv4Net, lease_duration: Duration) -> NetworkState { + pub async fn new_from_db( + network_code: String, + net: Ipv4Net, + lease_duration: Duration, + ) -> NetworkState { let gateway = Ipv4Addr::from(u32::from(net.network()) + 1); - let initial_inner_state = Self::build_initial_inner_state(&network_code, net, gateway).await; + let initial_inner_state = + Self::build_initial_inner_state(&network_code, net, gateway).await; Self { time: Mutex::new(Instant::now()), network_code, @@ -759,7 +790,10 @@ impl NetworkStateProvider { } pub fn get_network_codes(&self) -> Vec { - self.network_states.iter().map(|entry| entry.key().clone()).collect() + self.network_states + .iter() + .map(|entry| entry.key().clone()) + .collect() } pub fn get_network_state(&self, network_code: &str) -> Option> { diff --git a/src/utils/config.rs b/src/utils/config.rs index 8d83f41..081f012 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -1,10 +1,21 @@ +use anyhow::{Context, bail}; use ipnet::Ipv4Net; +use rand::Rng; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::io::Write; use std::net::{Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; +pub const DEFAULT_NETWORK_CODE: &str = "default"; + +#[derive(Debug)] +pub struct LoadedConfig { + pub path: PathBuf, + pub config: ConfigFile, + pub created_default: bool, +} + #[derive(Debug, Deserialize, Serialize)] pub struct ConfigFile { pub tcp_bind: Option, @@ -12,8 +23,7 @@ pub struct ConfigFile { pub ws_bind: Option, pub cert: Option, pub key: Option, - pub network: Ipv4Net, - pub custom_nets: HashMap, + #[serde(default)] pub white_list: HashSet, pub lease_duration: u64, pub web_bind: Option, @@ -25,17 +35,48 @@ pub struct ConfigFile { #[serde(default)] pub peer_servers: Vec, pub server_token: Option, + #[serde(default)] + pub custom_nets: HashMap, + #[serde(default)] + pub network_secrets: HashMap, } + +fn generate_strong_secret() -> String { + const SECRET_CHARSET: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!@#$%^&*+=:"; + + let mut rng = rand::rng(); + let mut secret = String::with_capacity(32); + secret.push('A'); + secret.push('a'); + secret.push('0'); + secret.push('!'); + for _ in 4..32 { + let idx = rng.random_range(0..SECRET_CHARSET.len()); + secret.push(SECRET_CHARSET[idx] as char); + } + secret +} + impl Default for ConfigFile { fn default() -> Self { + let mut custom_nets = HashMap::new(); + custom_nets.insert( + DEFAULT_NETWORK_CODE.to_string(), + Ipv4Net::new_assert(Ipv4Addr::new(10, 26, 0, 0), 24), + ); + + let mut network_secrets = HashMap::new(); + network_secrets.insert(DEFAULT_NETWORK_CODE.to_string(), generate_strong_secret()); + Self { tcp_bind: Some("0.0.0.0:29872".parse().unwrap()), quic_bind: Some("0.0.0.0:29872".parse().unwrap()), ws_bind: Some("0.0.0.0:29872".parse().unwrap()), cert: None, key: None, - network: Ipv4Net::new_assert(Ipv4Addr::new(10, 26, 0, 0), 24), - custom_nets: Default::default(), + custom_nets, + network_secrets, white_list: Default::default(), lease_duration: 24 * 60 * 60, web_bind: Some("0.0.0.0:29871".parse().unwrap()), @@ -58,65 +99,169 @@ impl ConfigFile { Ok(()) } + pub fn load_from(path: Option) -> anyhow::Result { - let path = if let Some(path) = path { - path - } else { - let path = Path::new("config.toml"); - if !path.exists() { - let file = Self::default(); - _ = file.save_to(path); - return Ok(file); - } - path.to_path_buf() - }; - let content = std::fs::read_to_string(path)?; - let cfg: ConfigFile = toml::from_str(&content)?; - Ok(cfg) + Ok(Self::load_with_meta(path)?.config) + } + + pub fn load_with_meta(path: Option) -> anyhow::Result { + let path = path.unwrap_or_else(|| Path::new("config.toml").to_path_buf()); + if !path.exists() { + let file = Self::default(); + file.validate() + .with_context(|| "Generated default config is invalid".to_string())?; + file.save_to(&path)?; + return Ok(LoadedConfig { + path, + config: file, + created_default: true, + }); + } + + let content = std::fs::read_to_string(&path)?; + let cfg: ConfigFile = toml::from_str(&content) + .with_context(|| format!("Failed to parse config file {}", path.display()))?; + cfg.validate() + .with_context(|| format!("Invalid config file {}", path.display()))?; + + Ok(LoadedConfig { + path, + config: cfg, + created_default: false, + }) } + + pub fn validate(&self) -> anyhow::Result<()> { + if self.custom_nets.is_empty() { + bail!( + "custom_nets must contain at least one network, and it must include '{}'", + DEFAULT_NETWORK_CODE + ); + } + + if !self.custom_nets.contains_key(DEFAULT_NETWORK_CODE) { + bail!( + "custom_nets must contain '{}'. Clients must use network_code='{}' to join the default network", + DEFAULT_NETWORK_CODE, + DEFAULT_NETWORK_CODE + ); + } + + for code in self.custom_nets.keys() { + validate_network_code_value(code, "custom_nets")?; + } + + for code in self.white_list.iter() { + validate_network_code_value(code, "white_list")?; + } + + for (code, secret) in self.network_secrets.iter() { + validate_network_code_value(code, "network_secrets")?; + validate_network_secret_value(code, secret)?; + } + + for code in self.custom_nets.keys() { + let Some(secret) = self.network_secrets.get(code) else { + bail!( + "Missing secret for network_code '{}'. Add it under [network_secrets]", + code + ); + }; + validate_network_secret_value(code, secret)?; + } + + Ok(()) + } +} + +pub fn validate_network_code_value(code: &str, field_name: &str) -> anyhow::Result<()> { + if code.trim().is_empty() { + bail!("{field_name} contains an empty network_code"); + } + if code.len() > 32 { + bail!( + "{field_name} contains network_code '{}' longer than 32 characters", + code + ); + } + Ok(()) } -pub fn print_example(){ - let str = r#"# 绑定tcp地址,不写则不启用tcp服务 +pub fn validate_network_secret_value(network_code: &str, secret: &str) -> anyhow::Result<()> { + if secret.len() < 24 { + bail!( + "Secret for network_code '{}' is too short. Use at least 24 characters", + network_code + ); + } + if secret.chars().any(char::is_whitespace) { + bail!( + "Secret for network_code '{}' must not contain whitespace", + network_code + ); + } + + let has_lower = secret.chars().any(|c| c.is_ascii_lowercase()); + let has_upper = secret.chars().any(|c| c.is_ascii_uppercase()); + let has_digit = secret.chars().any(|c| c.is_ascii_digit()); + let has_symbol = secret.chars().any(|c| !c.is_ascii_alphanumeric()); + let category_count = [has_lower, has_upper, has_digit, has_symbol] + .into_iter() + .filter(|v| *v) + .count(); + let is_hex = secret.chars().all(|c| c.is_ascii_hexdigit()); + + if category_count < 3 && !(is_hex && secret.len() >= 32) { + bail!( + "Secret for network_code '{}' is too weak. Use a 24+ char mixed secret, or a 32+ char random hex secret", + network_code + ); + } + + Ok(()) +} + +pub fn print_example() { + let str = r#"# Listener addresses. Remove a line to disable that protocol. tcp_bind = "0.0.0.0:29872" -# 绑定quic地址,不写则不启用quic服务 quic_bind = "0.0.0.0:29872" -# 绑定wss地址,不写则不启用wss服务 ws_bind = "0.0.0.0:29872" -# 默认虚拟网段 -network = "10.26.0.0/24" -# 网络编号白名单 + +# Optional allow-list. Currently not enforced for registration. white_list = [] -# IP租约时长,单位秒,默认24小时,离线超过这个时间IP就会被回收 + +# Lease duration in seconds. lease_duration = 86400 -# Web管理端绑定地址,不写则不启用web服务 + +# Web admin UI/API login. web_bind = "0.0.0.0:29871" -# 管理端登录用户名密码 username = "admin" -# 管理端登录用户密码 password = "admin" -# 是否启用数据持久化 + +# SQLite persistence. persistence = true -# tls证书不填时将自动生成 -# 自定义tls证书路径 -cert = "cert.pem" -# 自定义tls私钥路径 -key = "key.pem" +# Leave these unset to auto-generate cert.pem and key.pem. +# cert = "cert.pem" +# key = "key.pem" -# 服务端互联配置(可选) -# 服务端之间通信的UDP端口,不填则不启用服务端互联 +# Optional peer-server settings. # server_quic_bind = "0.0.0.0:29873" -# 其他服务器地址列表 # peer_servers = ["server1.example.com:29873", "192.168.1.100:29873"] -# 服务器验证码,用于服务器之间的身份验证 # server_token = "your-secret-token" -# 自定义虚拟网段 格式:网络编号 = "网段" +# Bootstrap networks. When persistence is enabled and the database is empty, +# these entries are imported into the DB on first start. [custom_nets] +default = "10.26.0.0/24" +# office = "10.25.0.0/24" +# dev = "10.27.1.0/24" -# net1 = "10.25.0.0/24" -# net2 = "10.27.1.0/24" +# Bootstrap secrets for the first import. +[network_secrets] +default = "Use-A-Long-Strong-Secret-At-Least-24-Chars!" +# office = "Replace-With-A-Different-Long-Strong-Secret!" +# dev = "Another-Different-Long-Strong-Secret!" "#; println!("{}", str); -} \ No newline at end of file +} diff --git a/static/index.html b/static/index.html index 136b2f7..f58f8da 100644 --- a/static/index.html +++ b/static/index.html @@ -3,7 +3,7 @@ - IoT 控制中心 + VNTS Network Console @@ -33,7 +33,7 @@
- IoT Control Center + VNTS Network Console
+
+
+ + Network Secret + + +
+ {{ net.secret || '(empty)' }} +
点击查看设备 @@ -446,6 +466,13 @@

{{ isEditMode ? '编辑网络' : '新增网 class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="如: 24" required>

+
+ + +

每个 network_code 都必须使用长强密钥。

+
添加服务器 // 网络表单 const showNetworkModal = ref(false); const isEditMode = ref(false); - const networkForm = ref({ network_code: '', gateway: '', netmask: 24, lease_duration: null }); + const networkForm = ref({ network_code: '', gateway: '', netmask: 24, secret: '', lease_duration: null }); + const copiedSecretCode = ref(''); // 确认删除 const showConfirmModal = ref(false); @@ -601,12 +629,13 @@

添加服务器

loading.value = true; modalErrorMsg.value = ''; errorMsg.value = ''; + const isLoginRequest = endpoint === '/login'; const headers = { 'Content-Type': 'application/json', ...options.headers }; if (token.value) headers['Authorization'] = `Bearer ${token.value}`; try { const res = await fetch(`${API_BASE}${endpoint}`, { ...options, headers }); const json = await res.json(); - if (res.status === 401 || json.code === 401) { + if (!isLoginRequest && (res.status === 401 || json.code === 401)) { logout(); errorMsg.value = '登录已过期'; return null; @@ -685,7 +714,7 @@

添加服务器

localStorage.setItem('jwt_token', data.token); localStorage.setItem('username', loginForm.value.username); username.value = loginForm.value.username; - fetchNetworks(); + handleRouteChange(); } }; @@ -710,6 +739,21 @@

添加服务器

if (data) peerServers.value = data; }; + const copySecret = async (networkCode, secret) => { + if (!secret) return; + try { + await navigator.clipboard.writeText(secret); + copiedSecretCode.value = networkCode; + setTimeout(() => { + if (copiedSecretCode.value === networkCode) { + copiedSecretCode.value = ''; + } + }, 1500); + } catch (e) { + modalErrorMsg.value = '复制 secret 失败'; + } + }; + // 停止设备自动刷新 const stopDeviceRefresh = () => { if (deviceRefreshTimer) { @@ -832,7 +876,7 @@

添加服务器

// 网络管理 const openAddNetworkModal = () => { isEditMode.value = false; - networkForm.value = { network_code: '', gateway: '', netmask: 24, lease_duration: null }; + networkForm.value = { network_code: '', gateway: '', netmask: 24, secret: '', lease_duration: null }; modalErrorMsg.value = ''; showNetworkModal.value = true; }; @@ -843,6 +887,7 @@

添加服务器

network_code: net.network_code, gateway: net.gateway, netmask: net.netmask, + secret: net.secret || '', lease_duration: net.lease_duration }; modalErrorMsg.value = ''; @@ -862,6 +907,7 @@

添加服务器

body: JSON.stringify({ gateway: form.gateway, netmask: form.netmask, + secret: form.secret, lease_duration: form.lease_duration }) }); @@ -869,7 +915,8 @@

添加服务器

const body = { network_code: form.network_code, gateway: form.gateway, - netmask: form.netmask + netmask: form.netmask, + secret: form.secret }; if (form.lease_duration) body.lease_duration = form.lease_duration; result = await request('/networks', { method: 'POST', body: JSON.stringify(body) }); @@ -882,6 +929,10 @@

添加服务器

// 删除确认 const confirmDeleteNetwork = (net) => { + if (!net.can_delete) { + modalErrorMsg.value = '默认 network 不允许删除'; + return; + } deleteType.value = 'network'; deleteTarget.value = net; confirmMessage.value = `确定要删除网络 "${net.network_code}" 吗?此操作不可撤销。`; @@ -1174,9 +1225,9 @@

添加服务器

}; onMounted(() => { + window.addEventListener('hashchange', handleRouteChange); if (isLoggedIn.value) { handleRouteChange(); - window.addEventListener('hashchange', handleRouteChange); } }); @@ -1198,6 +1249,8 @@

添加服务器

networks, networkSearch, filteredNetworks, + copiedSecretCode, + copySecret, selectNetwork, devices, deviceSearch,