diff --git a/Cargo.lock b/Cargo.lock index 8532f6c0..8a6d1ea7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -159,6 +165,54 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-compression" version = "0.4.43" @@ -602,6 +656,36 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix 1.1.4", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -861,6 +945,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.2" @@ -1240,6 +1333,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der_derive" version = "0.7.3" @@ -1286,10 +1393,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -1392,6 +1501,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + [[package]] name = "dom_query" version = "0.27.0" @@ -1550,6 +1665,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +dependencies = [ + "serde", +] + [[package]] name = "erased-serde" version = "0.4.10" @@ -1707,6 +1831,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2659,6 +2794,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "ipnet" version = "2.12.1" @@ -3128,12 +3285,50 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libunftp" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9f8ff371890db2cf65a0758dba9a79f9cd965de369f6dbdc6581a22780af45e" +dependencies = [ + "async-trait", + "bitflags 2.13.1", + "bytes", + "chrono", + "dashmap", + "derive_more", + "futures-util", + "getrandom 0.3.4", + "lazy_static", + "moka", + "nix", + "prometheus", + "proxy-protocol", + "rustls 0.23.43", + "slog", + "slog-stdlog", + "thiserror 2.0.20", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tracing", + "tracing-attributes", + "unftp-core", + "uuid", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -3202,6 +3397,22 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "md-5" version = "0.11.0" @@ -3276,6 +3487,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "muda" version = "0.19.3" @@ -3392,6 +3620,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -3729,6 +3969,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4151,7 +4400,7 @@ dependencies = [ "bytes", "fallible-iterator 0.2.0", "hmac 0.13.0", - "md-5", + "md-5 0.11.0", "memchr", "rand 0.10.2", "sha2 0.11.0", @@ -4282,6 +4531,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "thiserror 2.0.20", +] + [[package]] name = "prost" version = "0.12.6" @@ -4305,6 +4568,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proxy-protocol" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e50c72c21c738f5c5f350cc33640aee30bf7cd20f9d9da20ed41bce2671d532" +dependencies = [ + "bytes", + "snafu", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -4669,6 +4942,7 @@ dependencies = [ "futures-util", "libc", "libsql", + "libunftp", "mysql_async", "reqwest 0.12.28", "rusqlite", @@ -4688,6 +4962,8 @@ dependencies = [ "tokio-postgres-rustls", "tokio-rustls 0.26.4", "tracing", + "unftp-core", + "unftp-sbe-fs", "uuid", "webpki-roots 0.26.11", "zip", @@ -4916,6 +5192,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.44" @@ -4925,10 +5210,33 @@ dependencies = [ "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.22.4" @@ -5209,7 +5517,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" dependencies = [ - "erased-serde", + "erased-serde 0.4.10", "serde", "serde_core", "typeid", @@ -5477,12 +5785,67 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slog" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b3b8565691b22d2bdfc066426ed48f837fc0c5f2c8cad8d9718f7f99d6995c1" +dependencies = [ + "anyhow", + "erased-serde 0.3.31", + "rustversion", + "serde_core", +] + +[[package]] +name = "slog-scope" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b76cf645c92e7850d5a1c9205ebf2864bd32c0ab3e978e6daad51fedf7ef54" +dependencies = [ + "arc-swap", + "lazy_static", + "slog", +] + +[[package]] +name = "slog-stdlog" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6706b2ace5bbae7291d3f8d2473e2bfab073ccd7d03670946197aec98471fa3e" +dependencies = [ + "log", + "slog", + "slog-scope", +] + [[package]] name = "smallvec" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "snafu" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab12d3c261b2308b0d80c26fffb58d17eba81a4be97890101f416b478c79ca7" +dependencies = [ + "doc-comment", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1508efa03c362e23817f96cde18abed596a25219a8b2c66e8db33c03543d315b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "socket2" version = "0.5.10" @@ -5716,6 +6079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -5795,6 +6159,12 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tao" version = "0.35.3" @@ -6705,6 +7075,40 @@ dependencies = [ "version_check", ] +[[package]] +name = "unftp-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79acf4b257aa56dd7f00466a81693d6c79c362c55092b3137909d6e76249ad80" +dependencies = [ + "async-trait", + "chrono", + "derive_more", + "libc", + "md-5 0.10.6", + "thiserror 2.0.20", + "tokio", + "x509-parser", +] + +[[package]] +name = "unftp-sbe-fs" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e12754866a334b276fee8c3a5e017c4a40e97cb0cd3aecb3156390ee9dab14a" +dependencies = [ + "async-trait", + "cap-std", + "cfg-if", + "futures", + "lazy_static", + "tokio", + "tokio-stream", + "tracing", + "tracing-attributes", + "unftp-core", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -6785,6 +7189,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -7176,7 +7586,7 @@ dependencies = [ "either", "home", "once_cell", - "rustix", + "rustix 0.38.44", ] [[package]] @@ -7701,6 +8111,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.1", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -7790,6 +8210,23 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/crates/runphp-cli/src/main.rs b/crates/runphp-cli/src/main.rs index a51a31b7..fcd91775 100644 --- a/crates/runphp-cli/src/main.rs +++ b/crates/runphp-cli/src/main.rs @@ -7,7 +7,8 @@ mod panel; use clap::{Parser, Subcommand}; use runphp_core::{ - caddy, ftp::FtpProfile, AppConfig, RuntimeManager, Site, + caddy, db::service::DbServiceManager, ftp::FtpProfile, ftpd::{FtpdManager, FtpUser}, + AppConfig, RuntimeManager, Site, }; use std::path::PathBuf; @@ -47,6 +48,16 @@ enum Command { #[command(subcommand)] action: FtpCmd, }, + /// 数据库服务端管理(检测接管 / 便携托管) + DbService { + #[command(subcommand)] + action: DbServiceCmd, + }, + /// FTP 服务端管理(虚拟用户 + Pure-FTPd/内嵌后端) + Ftpd { + #[command(subcommand)] + action: FtpdCmd, + }, /// 启动 FrankenPHP(前台运行) Run, /// 停止运行中的 FrankenPHP @@ -224,6 +235,58 @@ enum FtpCmd { }, } +#[derive(Subcommand)] +enum DbServiceCmd { + /// 列出受管数据库服务 + List, + /// 检测本机数据库服务候选 + Detect, + /// 启动服务 + Start { id: String }, + /// 停止服务 + Stop { id: String }, + /// 列出服务中的用户数据库 + Dbs { id: String }, + /// 在服务中创建数据库 + CreateDb { id: String, name: String }, + /// 创建账号(可选同时授权某数据库全部权限) + CreateUser { + id: String, + username: String, + password: String, + /// 授权的数据库名 + #[arg(long)] + database: Option, + }, +} + +#[derive(Subcommand)] +enum FtpdCmd { + /// 显示 FTP 服务端状态与后端 + Status, + /// 启动 FTP 服务端(守护方式:内嵌后端自派生分离进程) + Start, + /// 前台运行 FTP 服务端(随本进程存活,Ctrl+C 停止) + Run, + /// 停止 FTP 服务端 + Stop, + /// 列出虚拟用户 + UserList, + /// 新增虚拟用户 + UserAdd { + username: String, + password: String, + /// 自定义根目录(默认 数据目录/ftp/<用户名>) + #[arg(long)] + home: Option, + /// 关联站点 id + #[arg(long)] + site: Option, + }, + /// 删除虚拟用户 + UserRm { id: String }, +} + /// 按档案 id 查找 profile,不存在则报错退出。 fn profile_by_id(mgr: &runphp_core::ftp::FtpManager, id: &str) -> FtpProfile { match mgr.list_profiles() { @@ -458,6 +521,156 @@ async fn main() { } } } + Command::DbService { action } => { + let mgr = DbServiceManager::new(cfg.clone()); + match action { + DbServiceCmd::List => { + match mgr.list() { + Ok(list) if list.is_empty() => println!("尚无受管数据库服务。使用 `runphp db-service detect` 检测本机服务。"), + Ok(list) => { + for s in list { + println!( + "{} [{}] {} 端口 {} ({})", + s.id, + s.kind.display_name(), + s.name, + s.port, + if s.source == runphp_core::services::ServiceSource::Portable { + "便携托管" + } else { + "接管" + } + ); + } + } + Err(e) => { eprintln!("读取服务列表失败: {e}"); std::process::exit(1); } + } + } + DbServiceCmd::Detect => { + match mgr.detect().await { + Ok(list) if list.is_empty() => println!("未检测到本机数据库服务。"), + Ok(list) => { + for c in list { + println!( + "{} 端口 {} {}(系统服务: {})", + c.name, + c.port, + if c.running { "运行中" } else { "未运行" }, + c.os_service_name.as_deref().unwrap_or("—") + ); + } + println!("在 Web 面板或桌面端可一键接管上述服务。"); + } + Err(e) => { eprintln!("检测失败: {e}"); std::process::exit(1); } + } + } + DbServiceCmd::Start { id } => { + if let Err(e) = mgr.start(&id).await { + eprintln!("启动失败: {e}"); + std::process::exit(1); + } + println!("服务 {id} 已启动"); + } + DbServiceCmd::Stop { id } => { + if let Err(e) = mgr.stop(&id).await { + eprintln!("停止失败: {e}"); + std::process::exit(1); + } + println!("服务 {id} 已停止"); + } + DbServiceCmd::Dbs { id } => { + match mgr.list_databases(&id).await { + Ok(dbs) => { + for d in dbs { + println!("{d}"); + } + } + Err(e) => { eprintln!("查询失败: {e}"); std::process::exit(1); } + } + } + DbServiceCmd::CreateDb { id, name } => { + if let Err(e) = mgr.create_database(&id, &name).await { + eprintln!("建库失败: {e}"); + std::process::exit(1); + } + println!("数据库 {name} 已创建"); + } + DbServiceCmd::CreateUser { id, username, password, database } => { + if let Err(e) = mgr.create_user(&id, &username, &password, database.as_deref()).await { + eprintln!("创建账号失败: {e}"); + std::process::exit(1); + } + println!("账号 {username} 已创建"); + } + } + } + Command::Ftpd { action } => { + let mgr = FtpdManager::new(cfg.clone()); + match action { + FtpdCmd::Status => { + let running = mgr.status().await; + let config = mgr.config(); + println!( + "FTP 服务端(后端 {}):{},控制端口 {}", + mgr.backend_name(), + if running { "运行中" } else { "已停止" }, + config.port + ); + } + FtpdCmd::Start => { + match mgr.start_daemon().await { + Ok(backend) => println!("FTP 服务端已启动({backend})"), + Err(e) => { eprintln!("启动失败: {e}"); std::process::exit(1); } + } + } + FtpdCmd::Run => { + if let Err(e) = mgr.run_forever().await { + eprintln!("运行失败: {e}"); + std::process::exit(1); + } + } + FtpdCmd::Stop => { + if let Err(e) = mgr.stop().await { + eprintln!("停止失败: {e}"); + std::process::exit(1); + } + println!("FTP 服务端已停止"); + } + FtpdCmd::UserList => { + match mgr.list_users() { + Ok(users) if users.is_empty() => println!("尚无虚拟用户。"), + Ok(users) => { + for u in users { + println!( + "{} {}({})", + u.id, + u.username, + if u.enabled { "启用" } else { "禁用" } + ); + } + } + Err(e) => { eprintln!("读取用户失败: {e}"); std::process::exit(1); } + } + } + FtpdCmd::UserAdd { username, password, home, site } => { + let mut u = FtpUser::new(username, password); + u.home_dir = home; + u.linked_site = site; + if let Err(e) = mgr.add_user(u).await { + eprintln!("添加用户失败: {e}"); + std::process::exit(1); + } + println!("用户已添加"); + } + FtpdCmd::UserRm { id } => { + if let Err(e) = mgr.remove_user(&id).await { + eprintln!("删除用户失败: {e}"); + std::process::exit(1); + } + println!("用户已删除"); + } + } + } Command::Ftp { action } => { use runphp_core::ftp::{FtpManager, FtpProtocol}; let mgr = FtpManager::new(&cfg.data_dir); diff --git a/crates/runphp-cli/src/panel.rs b/crates/runphp-cli/src/panel.rs index ef31d99f..b51fc6e5 100644 --- a/crates/runphp-cli/src/panel.rs +++ b/crates/runphp-cli/src/panel.rs @@ -18,12 +18,14 @@ use axum::{ use runphp_core::{ adminer, caddy, - db::{remote::*, sqlite::*, LibsqlManager, LibsqlProfile}, + db::{remote::*, service::*, sqlite::*, LibsqlManager, LibsqlProfile}, detect, fs, ftp::{FtpManager, FtpProfile}, + ftpd::{FtpdConfig, FtpdManager, FtpUser}, hosts::{entries_from_sites, HostsManager}, runtime, + services::ManagedService, system, AppConfig, RuntimeManager, Site, }; @@ -51,6 +53,8 @@ pub async fn serve( let addr = SocketAddr::new(host_parsed, port); let has_token = token.is_some(); let state = Arc::new(PanelState { cfg, token }); + // 自启动配置副本(state 稍后被 move 进 Router) + let auto_cfg = state.cfg.clone(); // REST API(与 Tauri command 同名同参) let api = Router::new() @@ -92,6 +96,26 @@ pub async fn serve( .route("/db_libsql_tables", post(db_libsql_tables)) .route("/db_libsql_query_table", post(db_libsql_query_table)) .route("/db_libsql_execute", post(db_libsql_execute)) + .route("/db_service_list", post(db_service_list)) + .route("/db_service_detect", post(db_service_detect)) + .route("/db_service_register", post(db_service_register)) + .route("/db_service_update", post(db_service_update)) + .route("/db_service_remove", post(db_service_remove)) + .route("/db_service_start", post(db_service_start)) + .route("/db_service_stop", post(db_service_stop)) + .route("/db_service_status", post(db_service_status)) + .route("/db_service_log", post(db_service_log)) + .route("/db_service_download_presets", post(db_service_download_presets)) + .route("/db_service_download", post(db_service_download)) + .route("/db_service_register_connection", post(db_service_register_connection)) + .route("/db_service_databases", post(db_service_databases)) + .route("/db_service_database_create", post(db_service_database_create)) + .route("/db_service_database_drop", post(db_service_database_drop)) + .route("/db_service_users", post(db_service_users)) + .route("/db_service_user_create", post(db_service_user_create)) + .route("/db_service_user_drop", post(db_service_user_drop)) + .route("/db_service_user_password", post(db_service_user_password)) + .route("/db_service_root_password", post(db_service_root_password)) .route("/runtime_detect_local", post(runtime_detect_local)) .route("/runtime_import_local", post(runtime_import_local)) .route("/runtime_versions", post(runtime_versions)) @@ -109,7 +133,17 @@ pub async fn serve( .route("/ftp_delete", post(ftp_delete)) .route("/ftp_mkdir", post(ftp_mkdir)) .route("/ftp_rename", post(ftp_rename)) - .route("/ftp_update", post(ftp_update)); + .route("/ftp_update", post(ftp_update)) + .route("/ftp_server_status", post(ftp_server_status)) + .route("/ftp_server_start", post(ftp_server_start)) + .route("/ftp_server_stop", post(ftp_server_stop)) + .route("/ftp_server_config", post(ftp_server_config)) + .route("/ftp_server_update_config", post(ftp_server_update_config)) + .route("/ftp_server_backend", post(ftp_server_backend)) + .route("/ftp_user_list", post(ftp_user_list)) + .route("/ftp_user_add", post(ftp_user_add)) + .route("/ftp_user_update", post(ftp_user_update)) + .route("/ftp_user_remove", post(ftp_user_remove)); // 设置 token 时对 API 启用 Bearer 鉴权 let api = if has_token { @@ -135,6 +169,10 @@ pub async fn serve( .with_state(state); tracing::info!("Web 面板启动: http://{addr}"); + // 自启动标记为自启的服务(数据库 + FTP),失败仅记日志 + tokio::spawn(async move { + runphp_core::autostart_services(&auto_cfg).await; + }); let listener = tokio::net::TcpListener::bind(&addr).await?; axum::serve(listener, app).await?; Ok(()) @@ -330,6 +368,74 @@ struct FtpRenameReq { to: String, } +#[derive(Deserialize)] +struct DbServiceRegisterReq { + input: ServiceInput, +} + +#[derive(Deserialize)] +struct DbServiceUpdateReq { + service: ManagedService, +} + +#[derive(Deserialize)] +struct DbServiceLogReq { + id: String, + lines: Option, +} + +#[derive(Deserialize)] +struct DbServiceDownloadReq { + kind: runphp_core::services::ServiceKind, + name: String, + url: String, +} + +#[derive(Deserialize)] +struct DbNameReq { + id: String, + name: String, +} + +#[derive(Deserialize)] +struct DbUserCreateReq { + id: String, + username: String, + password: String, + database: Option, +} + +#[derive(Deserialize)] +struct DbUserReq { + id: String, + username: String, + host: String, +} + +#[derive(Deserialize)] +struct DbPasswordReq { + id: String, + password: String, +} + +#[derive(Deserialize)] +struct DbUserPasswordReq { + id: String, + username: String, + host: String, + password: String, +} + +#[derive(Deserialize)] +struct FtpUserReq { + user: FtpUser, +} + +#[derive(Deserialize)] +struct FtpdConfigReq { + config: FtpdConfig, +} + type S = State>; fn sqlite_mgr(cfg: &AppConfig) -> SqliteManager { @@ -738,6 +844,153 @@ async fn db_libsql_execute(Json(req): Json) -> Response { } } +// ---- 数据库服务管理(服务端) ---- + +fn db_svc_mgr(cfg: &AppConfig) -> DbServiceManager { + DbServiceManager::new(cfg.clone()) +} + +async fn db_service_list(State(s): S) -> Json { + let list = db_svc_mgr(&s.cfg).list().unwrap_or_default(); + Json(json!(list)) +} + +async fn db_service_detect(State(s): S) -> Response { + match db_svc_mgr(&s.cfg).detect().await { + Ok(c) => Json(json!(c)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_register(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).register(req.input) { + Ok(svc) => Json(json!(svc)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_update(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).update(req.service) { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_remove(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).remove(&req.id).await { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_start(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).start(&req.id).await { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_stop(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).stop(&req.id).await { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_status(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).status(&req.id).await { + Ok(st) => Json(json!(st)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_log(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).read_log(&req.id, req.lines.unwrap_or(100)) { + Ok(t) => Json(json!(t)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_download_presets() -> Json { + Json(json!(download_presets())) +} + +async fn db_service_download(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).download_install(req.kind, &req.name, &req.url, None).await { + Ok(svc) => Json(json!(svc)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_register_connection(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).register_connection(&req.id) { + Ok(p) => Json(json!(p)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_databases(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).list_databases(&req.id).await { + Ok(list) => Json(json!(list)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_database_create(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).create_database(&req.id, &req.name).await { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_database_drop(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).drop_database(&req.id, &req.name).await { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_users(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).list_users(&req.id).await { + Ok(list) => Json(json!(list)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_user_create(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg) + .create_user(&req.id, &req.username, &req.password, req.database.as_deref()) + .await + { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_user_drop(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).drop_user(&req.id, &req.username, &req.host).await { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_user_password(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg) + .set_user_password(&req.id, &req.username, &req.host, &req.password) + .await + { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn db_service_root_password(State(s): S, Json(req): Json) -> Response { + match db_svc_mgr(&s.cfg).set_root_password(&req.id, &req.password).await { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + // ---- FTP 管理 ---- async fn ftp_list(State(s): S) -> Json { @@ -825,3 +1078,70 @@ async fn ftp_rename(Json(req): Json) -> Response { Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), } } + +// ---- FTP 服务端管理 ---- + +fn ftpd_mgr(cfg: &AppConfig) -> FtpdManager { + FtpdManager::new(cfg.clone()) +} + +async fn ftp_server_status(State(s): S) -> Json { + Json(json!(ftpd_mgr(&s.cfg).status().await)) +} + +async fn ftp_server_start(State(s): S) -> Response { + match ftpd_mgr(&s.cfg).start().await { + Ok(backend) => Json(json!(backend)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn ftp_server_stop(State(s): S) -> Response { + match ftpd_mgr(&s.cfg).stop().await { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn ftp_server_config(State(s): S) -> Json { + Json(json!(ftpd_mgr(&s.cfg).config())) +} + +async fn ftp_server_update_config(State(s): S, Json(req): Json) -> Response { + match ftpd_mgr(&s.cfg).save_config(&req.config) { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn ftp_server_backend(State(s): S) -> Json { + Json(json!(ftpd_mgr(&s.cfg).backend_name())) +} + +async fn ftp_user_list(State(s): S) -> Response { + match ftpd_mgr(&s.cfg).list_users() { + Ok(users) => Json(json!(users)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn ftp_user_add(State(s): S, Json(req): Json) -> Response { + match ftpd_mgr(&s.cfg).add_user(req.user).await { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn ftp_user_update(State(s): S, Json(req): Json) -> Response { + match ftpd_mgr(&s.cfg).update_user(req.user).await { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} + +async fn ftp_user_remove(State(s): S, Json(req): Json) -> Response { + match ftpd_mgr(&s.cfg).remove_user(&req.id).await { + Ok(()) => Json(Value::Null).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), + } +} diff --git a/crates/runphp-core/Cargo.toml b/crates/runphp-core/Cargo.toml index 77be3609..706f3802 100644 --- a/crates/runphp-core/Cargo.toml +++ b/crates/runphp-core/Cargo.toml @@ -43,6 +43,10 @@ libsql = { version = "0.10.0-pre.4", default-features = false, features = ["core suppaftp = { version = "10", default-features = false, features = ["tokio", "tokio-rustls-ring"] } # SFTP 客户端(复用 russh 连接) russh-sftp = "2.4" +# 内嵌 FTP 服务端(Windows 或未安装 Pure-FTPd 时使用) +libunftp = "0.23" +unftp-core = "0.1" +unftp-sbe-fs = "0.4" # Unix 专用:进程信号(kill_process 的 SIGTERM) [target.'cfg(unix)'.dependencies] diff --git a/crates/runphp-core/src/caddy.rs b/crates/runphp-core/src/caddy.rs index f0169bbd..582c35bf 100644 --- a/crates/runphp-core/src/caddy.rs +++ b/crates/runphp-core/src/caddy.rs @@ -343,7 +343,7 @@ pub async fn stop(cfg: &AppConfig) -> Result<()> { /// 跨平台终止进程。 #[cfg(unix)] -fn kill_process(pid: u32) { +pub(crate) fn kill_process(pid: u32) { // SIGTERM unsafe { libc::kill(pid as i32, libc::SIGTERM); @@ -352,7 +352,7 @@ fn kill_process(pid: u32) { /// 跨平台终止进程(Windows)。 #[cfg(not(unix))] -fn kill_process(pid: u32) { +pub(crate) fn kill_process(pid: u32) { use std::process::Command; // taskkill /F /PID /T 终止进程树 let _ = Command::new("taskkill") diff --git a/crates/runphp-core/src/db/mod.rs b/crates/runphp-core/src/db/mod.rs index 6ea25df5..ad9b902c 100644 --- a/crates/runphp-core/src/db/mod.rs +++ b/crates/runphp-core/src/db/mod.rs @@ -2,10 +2,12 @@ //! //! - SQLite(内置引擎):库管理对象的浏览与查询 //! - MySQL/MariaDB、PostgreSQL:连接管理已有实例 + 表浏览与 SQL 执行 +//! - 服务端管理:本机数据库服务的检测接管、便携托管与建库建用户 //! - SSH 隧道:通过 SSH 端口转发安全连接远程数据库 pub mod libsql; pub mod remote; +pub mod service; pub mod sqlite; pub mod tunnel; @@ -14,4 +16,5 @@ pub use remote::{ ConnectionProfile, DbDriver, ProfileRegistry, RemoteDbManager, RemoteQueryResult, RemoteTableInfo, SslMode, }; +pub use service::{DbServiceManager, DbServiceCandidate, ServiceInput, ServiceDbUser, DownloadPreset}; pub use libsql::{LibsqlManager, LibsqlMode, LibsqlProfile, LibsqlProfileRegistry}; diff --git a/crates/runphp-core/src/db/remote.rs b/crates/runphp-core/src/db/remote.rs index 411f0769..979db2bd 100644 --- a/crates/runphp-core/src/db/remote.rs +++ b/crates/runphp-core/src/db/remote.rs @@ -267,7 +267,7 @@ fn url_escape(s: &str) -> String { /// 将参数列表格式化为 Redis RESP 协议命令字符串。 /// /// 例如 `["PING"]` → `*1\r\n$4\r\nPING\r\n` -fn format_resp_command(args: &[&str]) -> String { +pub(crate) fn format_resp_command(args: &[&str]) -> String { let mut out = format!("*{}\r\n", args.len()); for arg in args { out.push_str(&format!("${}\r\n{}\r\n", arg.len(), arg)); diff --git a/crates/runphp-core/src/db/service.rs b/crates/runphp-core/src/db/service.rs new file mode 100644 index 00000000..3bf43258 --- /dev/null +++ b/crates/runphp-core/src/db/service.rs @@ -0,0 +1,1118 @@ +//! 数据库服务端管理:检测接管、便携托管与建库建用户。 +//! +//! 与 `remote.rs`(客户端连接档案)互补:本模块面向本机数据库服务—— +//! - 检测:端口探测 + 常见安装目录二进制扫描 + 系统服务名匹配 +//! - 接管(takeover):登记本机已安装的系统服务,启停走 sc/systemctl +//! - 便携托管(portable):导入本地二进制或下载便携包,首次启动自动初始化数据目录 +//! - 管理:以 root 凭据连 127.0.0.1 执行建库/建用户/改密(复用 RemoteDbManager) +//! +//! SQL 一律经标识符校验 + 转义拼接,不使用字符串裸拼用户输入。 + +use crate::db::remote::{ConnectionProfile, DbDriver, RemoteDbManager}; +use crate::services::{ + probe_port, wait_port_ready, ManagedService, ServiceKind, ServiceManager, ServiceSource, +}; +use crate::{AppConfig, Error, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::process::Command; + +/// 数据库服务管理器。 +pub struct DbServiceManager { + cfg: AppConfig, + services: ServiceManager, +} + +/// 检测到的本机数据库服务候选。 +#[derive(Debug, Clone, Serialize)] +pub struct DbServiceCandidate { + /// 服务类型。 + pub kind: ServiceKind, + /// 显示名称。 + pub name: String, + /// 默认端口。 + pub port: u16, + /// 端口是否可达(服务运行中)。 + pub running: bool, + /// 检测到的服务端二进制(可便携导入;接管场景仅作展示)。 + pub binary_path: Option, + /// 匹配到的系统服务名(Windows 服务名 / systemd 单元名)。 + pub os_service_name: Option, +} + +/// 注册/导入服务的请求参数(接管与便携共用,按是否提供 binary_path 区分来源)。 +#[derive(Debug, Clone, Deserialize)] +pub struct ServiceInput { + pub kind: ServiceKind, + pub name: String, + pub port: u16, + /// 便携二进制绝对路径(提供则注册为便携托管,否则为接管)。 + #[serde(default)] + pub binary_path: Option, + /// 接管场景的系统服务名。 + #[serde(default)] + pub os_service_name: Option, + #[serde(default)] + pub root_username: String, + #[serde(default)] + pub root_password: String, + #[serde(default)] + pub autostart: bool, + #[serde(default)] + pub extra_args: Vec, +} + +/// 服务端数据库账号。 +#[derive(Debug, Clone, Serialize)] +pub struct ServiceDbUser { + pub username: String, + /// MySQL 的 host 字段(PostgreSQL 恒为空串)。 + pub host: String, +} + +/// 在线下载预设。 +#[derive(Debug, Clone, Serialize)] +pub struct DownloadPreset { + pub kind: ServiceKind, + pub label: String, + pub url: String, + /// 体积提示(展示用)。 + pub size_hint: String, +} + +/// 引擎检测规格。 +struct EngineSpec { + kind: ServiceKind, + /// 服务端二进制候选名(Windows 自动补 .exe)。 + binary: &'static str, + /// 初始化辅助二进制候选名(按顺序在主二进制同级目录查找)。 + init_helpers: &'static [&'static str], + win_services: &'static [&'static str], + systemd_units: &'static [&'static str], +} + +const ENGINE_SPECS: &[EngineSpec] = &[ + EngineSpec { + kind: ServiceKind::Mysql, + binary: "mysqld", + init_helpers: &["mysql_install_db"], + win_services: &["MySQL84", "MySQL80", "MySQL57", "MySQL"], + systemd_units: &["mysqld", "mysql"], + }, + EngineSpec { + kind: ServiceKind::Mariadb, + binary: "mariadbd", + init_helpers: &["mariadb-install-db", "mysql_install_db"], + win_services: &["MariaDB"], + systemd_units: &["mariadb"], + }, + EngineSpec { + kind: ServiceKind::Postgresql, + binary: "postgres", + init_helpers: &["initdb"], + win_services: &["postgresql-x64-17", "postgresql-x64-16", "postgresql-x64-15", "postgresql-x64-14"], + systemd_units: &["postgresql"], + }, + EngineSpec { + kind: ServiceKind::Redis, + binary: "redis-server", + init_helpers: &[], + win_services: &["Redis", "Memurai"], + systemd_units: &["redis-server", "redis"], + }, +]; + +impl EngineSpec { + fn by_kind(kind: ServiceKind) -> &'static EngineSpec { + ENGINE_SPECS + .iter() + .find(|s| s.kind == kind) + .expect("ENGINE_SPECS 覆盖全部数据库引擎") + } +} + +impl DbServiceManager { + pub fn new(cfg: AppConfig) -> Self { + Self { + services: ServiceManager::new(cfg.clone()), + cfg, + } + } + + /// 按 id 取服务定义。 + pub fn get_service(&self, id: &str) -> Result { + self.services.get(id) + } + + /// 读取服务日志末尾若干行。 + pub fn read_log(&self, id: &str, lines: usize) -> Result { + self.services.read_log(id, lines) + } + + /// 把受管服务注册为连接档案(供连接页浏览/SQL/Adminer 复用)。 + pub fn register_connection(&self, id: &str) -> Result { + let svc = self.services.get(id)?; + let profile = Self::admin_profile(&svc)?; + RemoteDbManager::new(&self.cfg.data_dir).add(profile.clone())?; + Ok(profile) + } + + /// 列出全部受管数据库服务。 + pub fn list(&self) -> Result> { + Ok(self + .services + .list()? + .into_iter() + .filter(|s| s.kind != ServiceKind::Ftp) + .collect()) + } + + /// 检测本机数据库服务(端口 + 二进制 + 系统服务名)。 + pub async fn detect(&self) -> Result> { + let registered: Vec = self + .services + .list()? + .into_iter() + .map(|s| s.kind) + .collect(); + let mut out = Vec::new(); + for spec in ENGINE_SPECS { + let port = spec.kind.default_port(); + let running = probe_port(port).await; + let binary_path = scan_engine_binary(spec); + let os_service_name = detect_os_service(spec).await; + // 已注册同类型服务或三项信号全空时不产出候选 + let interesting = running || binary_path.is_some() || os_service_name.is_some(); + if interesting && !registered.contains(&spec.kind) { + out.push(DbServiceCandidate { + kind: spec.kind, + name: spec.kind.display_name().to_string(), + port, + running, + binary_path, + os_service_name, + }); + } + } + Ok(out) + } + + /// 注册服务:提供 binary_path 走便携托管,否则按接管登记。 + pub fn register(&self, input: ServiceInput) -> Result { + if input.name.trim().is_empty() { + return Err(Error::Config("服务名称不能为空".into())); + } + let mut svc = ManagedService::new(input.kind, input.name.trim().to_string(), input.port); + svc.autostart = input.autostart; + svc.root_username = input.root_username; + svc.root_password = input.root_password; + svc.extra_args = input.extra_args; + if let Some(bin) = input.binary_path.filter(|p| p.is_file()) { + svc.source = ServiceSource::Portable; + svc.binary_path = Some(bin); + std::fs::create_dir_all(self.services.service_data_dir(&svc.id))?; + } else { + svc.source = ServiceSource::Takeover; + svc.os_service_name = input.os_service_name; + } + self.services.upsert(svc.clone())?; + Ok(svc) + } + + /// 更新服务定义(端口/自启/凭据等;来源与类型不可变)。 + pub fn update(&self, service: ManagedService) -> Result<()> { + let existing = self.services.get(&service.id)?; + let mut svc = service; + svc.source = existing.source; + svc.kind = existing.kind; + svc.created_at = existing.created_at; + self.services.upsert(svc) + } + + /// 删除服务注册(便携服务先尽力停止)。 + pub async fn remove(&self, id: &str) -> Result<()> { + let _ = self.services.stop(id).await; + self.services.remove(id) + } + + /// 启动服务:便携服务先做数据目录初始化,再托管进程并等待端口就绪。 + pub async fn start(&self, id: &str) -> Result<()> { + let svc = self.services.get(id)?; + if svc.source == ServiceSource::Portable { + self.ensure_bootstrap(&svc).await?; + } + self.services.start(id).await?; + if !wait_port_ready(svc.port, 60).await { + let log = self.services.read_log(id, 10).unwrap_or_default(); + return Err(Error::Other(format!( + "服务 {} 启动后 60 秒内端口 {} 未就绪。日志末尾:\n{log}", + svc.name, svc.port + ))); + } + Ok(()) + } + + /// 停止服务。 + pub async fn stop(&self, id: &str) -> Result<()> { + self.services.stop(id).await + } + + /// 查询服务状态。 + pub async fn status(&self, id: &str) -> Result { + self.services.status(id).await + } + + /// 便携服务首次启动前的数据目录初始化。 + async fn ensure_bootstrap(&self, svc: &ManagedService) -> Result<()> { + let data = self.services.service_data_dir(&svc.id); + match svc.kind { + ServiceKind::Redis | ServiceKind::Ftp => Ok(()), + ServiceKind::Mysql | ServiceKind::Mariadb => { + if mysql_initialized(&data) { + return Ok(()); + } + let spec = EngineSpec::by_kind(svc.kind); + let helper = find_helper(svc.binary_path.as_deref(), spec.init_helpers) + .ok_or_else(|| { + Error::Other(format!( + "未找到 {} 初始化工具({}),请确认导入的是完整安装目录", + svc.kind.display_name(), + spec.init_helpers.join(" / ") + )) + })?; + // MariaDB 用 install_db 脚本初始化;MySQL 用 mysqld --initialize-insecure + let output = if svc.kind == ServiceKind::Mysql { + run_step(svc.binary_path.as_deref().unwrap(), &[ + "--initialize-insecure", + "--datadir", + &data.to_string_lossy(), + ]) + .await? + } else { + run_step(&helper, &["--datadir", &data.to_string_lossy()]).await? + }; + if !mysql_initialized(&data) { + return Err(Error::Other(format!( + "{} 数据目录初始化未生效: {}", + svc.kind.display_name(), + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(()) + } + ServiceKind::Postgresql => { + if data.join("PG_VERSION").is_file() { + return Ok(()); + } + let helper = find_helper(svc.binary_path.as_deref(), &["initdb"]) + .ok_or_else(|| Error::Other("未找到 initdb 初始化工具".into()))?; + let output = run_step( + &helper, + &[ + "-D", + &data.to_string_lossy(), + "-U", + "postgres", + // 本地监听场景使用 trust,改密后仍可经 ALTER ROLE 生效 + "--auth=trust", + "-E", + "UTF8", + ], + ) + .await?; + if !data.join("PG_VERSION").is_file() { + return Err(Error::Other(format!( + "PostgreSQL 数据目录初始化未生效: {}", + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(()) + } + } + } + + // ---- 建库建用户(复用远程客户端的执行通道) ---- + + /// 以服务 root 凭据构造连接档案(复用 RemoteDbManager 执行 SQL)。 + pub fn admin_profile(svc: &ManagedService) -> Result { + let driver = match svc.kind { + ServiceKind::Mysql | ServiceKind::Mariadb => DbDriver::Mysql, + ServiceKind::Postgresql => DbDriver::Postgres, + ServiceKind::Redis => DbDriver::Redis, + ServiceKind::Ftp => return Err(Error::Other("FTP 服务无数据库操作".into())), + }; + let username = if svc.root_username.is_empty() { + match svc.kind { + ServiceKind::Postgresql => "postgres".to_string(), + ServiceKind::Mysql | ServiceKind::Mariadb => "root".to_string(), + _ => String::new(), + } + } else { + svc.root_username.clone() + }; + let mut p = ConnectionProfile::new(svc.name.clone(), driver, "127.0.0.1".into(), svc.port); + p.username = username; + p.password = svc.root_password.clone(); + Ok(p) + } + + /// 执行管理 SQL 并要求无结果集返回(DDL)。 + async fn admin_exec(svc: &ManagedService, sql: &str) -> Result<()> { + let profile = Self::admin_profile(svc)?; + RemoteDbManager::execute(&profile, sql).await.map(|_| ()) + } + + /// 列出用户数据库(过滤系统库)。 + pub async fn list_databases(&self, id: &str) -> Result> { + let svc = self.services.get(id)?; + let profile = Self::admin_profile(&svc)?; + let sql = match svc.kind { + ServiceKind::Mysql | ServiceKind::Mariadb => { + "SELECT schema_name FROM information_schema.schemata" + } + ServiceKind::Postgresql => { + "SELECT datname FROM pg_database WHERE NOT datistemplate AND datname <> 'postgres'" + } + _ => return Err(Error::Other("该服务类型无数据库列表概念".into())), + }; + let result = RemoteDbManager::execute(&profile, sql).await?; + let system: &[&str] = &["information_schema", "mysql", "performance_schema", "sys"]; + Ok(result + .rows + .iter() + .filter_map(|r| r.first().and_then(|v| v.as_str())) + .map(|s| s.to_string()) + .filter(|s| !system.contains(&s.as_str())) + .collect()) + } + + /// 创建数据库。 + pub async fn create_database(&self, id: &str, name: &str) -> Result<()> { + let svc = self.services.get(id)?; + validate_ident(name)?; + let sql = match svc.kind { + ServiceKind::Mysql | ServiceKind::Mariadb => format!( + "CREATE DATABASE {} DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + mysql_ident(name) + ), + ServiceKind::Postgresql => format!("CREATE DATABASE {}", pg_ident(name)), + _ => return Err(Error::Other("该服务类型不支持建库".into())), + }; + Self::admin_exec(&svc, &sql).await + } + + /// 删除数据库。 + pub async fn drop_database(&self, id: &str, name: &str) -> Result<()> { + let svc = self.services.get(id)?; + validate_ident(name)?; + let sql = match svc.kind { + ServiceKind::Mysql | ServiceKind::Mariadb => format!("DROP DATABASE {}", mysql_ident(name)), + ServiceKind::Postgresql => format!("DROP DATABASE {}", pg_ident(name)), + _ => return Err(Error::Other("该服务类型不支持删库".into())), + }; + Self::admin_exec(&svc, &sql).await + } + + /// 列出用户账号(过滤系统账号)。 + pub async fn list_users(&self, id: &str) -> Result> { + let svc = self.services.get(id)?; + let profile = Self::admin_profile(&svc)?; + let result = match svc.kind { + ServiceKind::Mysql | ServiceKind::Mariadb => { + RemoteDbManager::execute( + &profile, + "SELECT User, Host FROM mysql.user WHERE User NOT IN ('mysql.sys','mysql.session','mysql.infoschema','root')", + ) + .await? + } + ServiceKind::Postgresql => { + RemoteDbManager::execute( + &profile, + "SELECT rolname, '' FROM pg_roles WHERE rolname !~ '^pg_' AND rolname <> 'postgres' AND rolcanlogin", + ) + .await? + } + _ => return Err(Error::Other("该服务类型无账号概念".into())), + }; + Ok(result + .rows + .iter() + .filter_map(|r| { + let username = r.first().and_then(|v| v.as_str())?.to_string(); + let host = r.get(1).and_then(|v| v.as_str()).unwrap_or("").to_string(); + Some(ServiceDbUser { username, host }) + }) + .collect()) + } + + /// 创建账号;指定 database 时授予该库全部权限。 + pub async fn create_user( + &self, + id: &str, + username: &str, + password: &str, + database: Option<&str>, + ) -> Result<()> { + let svc = self.services.get(id)?; + validate_ident(username)?; + if password.is_empty() { + return Err(Error::Config("密码不能为空".into())); + } + match svc.kind { + ServiceKind::Mysql | ServiceKind::Mariadb => { + Self::admin_exec( + &svc, + &format!( + "CREATE USER {} IDENTIFIED BY {}", + mysql_user_host(username, "%"), + sql_str(password) + ), + ) + .await?; + if let Some(db) = database { + validate_ident(db)?; + Self::admin_exec( + &svc, + &format!( + "GRANT ALL PRIVILEGES ON {}.* TO {}", + mysql_ident(db), + mysql_user_host(username, "%") + ), + ) + .await?; + } + Ok(()) + } + ServiceKind::Postgresql => { + Self::admin_exec( + &svc, + &format!("CREATE ROLE {} LOGIN PASSWORD {}", pg_ident(username), sql_str(password)), + ) + .await?; + if let Some(db) = database { + validate_ident(db)?; + Self::admin_exec( + &svc, + &format!( + "GRANT ALL PRIVILEGES ON DATABASE {} TO {}", + pg_ident(db), + pg_ident(username) + ), + ) + .await?; + } + Ok(()) + } + _ => Err(Error::Other("该服务类型不支持建账号".into())), + } + } + + /// 删除账号。 + pub async fn drop_user(&self, id: &str, username: &str, host: &str) -> Result<()> { + let svc = self.services.get(id)?; + validate_ident(username)?; + let sql = match svc.kind { + ServiceKind::Mysql | ServiceKind::Mariadb => { + let h = if host.is_empty() { "%" } else { host }; + format!("DROP USER {}", mysql_user_host(username, h)) + } + ServiceKind::Postgresql => format!("DROP ROLE {}", pg_ident(username)), + _ => return Err(Error::Other("该服务类型不支持删账号".into())), + }; + Self::admin_exec(&svc, &sql).await + } + + /// 修改账号密码。 + pub async fn set_user_password( + &self, + id: &str, + username: &str, + host: &str, + password: &str, + ) -> Result<()> { + let svc = self.services.get(id)?; + validate_ident(username)?; + if password.is_empty() { + return Err(Error::Config("密码不能为空".into())); + } + let sql = match svc.kind { + ServiceKind::Mysql | ServiceKind::Mariadb => { + let h = if host.is_empty() { "%" } else { host }; + format!( + "ALTER USER {} IDENTIFIED BY {}", + mysql_user_host(username, h), + sql_str(password) + ) + } + ServiceKind::Postgresql => { + format!("ALTER ROLE {} WITH PASSWORD {}", pg_ident(username), sql_str(password)) + } + _ => return Err(Error::Other("该服务类型不支持改密".into())), + }; + Self::admin_exec(&svc, &sql).await + } + + /// 修改 root 密码(同步更新注册表中的凭据)。 + pub async fn set_root_password(&self, id: &str, password: &str) -> Result<()> { + let mut svc = self.services.get(id)?; + if password.is_empty() { + return Err(Error::Config("密码不能为空".into())); + } + match svc.kind { + ServiceKind::Mysql | ServiceKind::Mariadb => { + let root_user = if svc.root_username.is_empty() { + "root" + } else { + &svc.root_username + }; + Self::admin_exec( + &svc, + &format!( + "ALTER USER {} IDENTIFIED BY {}", + mysql_user_host(root_user, "localhost"), + sql_str(password) + ), + ) + .await?; + } + ServiceKind::Postgresql => { + let root_user = if svc.root_username.is_empty() { + "postgres" + } else { + &svc.root_username + }; + Self::admin_exec( + &svc, + &format!( + "ALTER ROLE {} WITH PASSWORD {}", + pg_ident(root_user), + sql_str(password) + ), + ) + .await?; + } + ServiceKind::Redis => return self.set_redis_password(id, password).await, + ServiceKind::Ftp => return Err(Error::Other("FTP 服务无此操作".into())), + } + svc.root_password = password.to_string(); + self.services.upsert(svc) + } + + /// 设置 Redis requirepass(在线生效 + 更新启动参数与注册表,重启不丢失)。 + pub async fn set_redis_password(&self, id: &str, password: &str) -> Result<()> { + let mut svc = self.services.get(id)?; + if svc.kind != ServiceKind::Redis { + return Err(Error::Other("仅 Redis 服务支持该操作".into())); + } + let addr = format!("127.0.0.1:{}", svc.port); + let resp = redis_command(&addr, &svc.root_password, &["CONFIG", "SET", "requirepass", password]) + .await?; + if !resp.starts_with('+') { + return Err(Error::Other(format!("Redis 设置密码失败: {resp}"))); + } + // 同步启动参数与注册表凭据,保证重启后密码仍生效 + svc.extra_args = redis_password_args(&svc.extra_args, password); + svc.root_password = password.to_string(); + self.services.upsert(svc) + } +} + +// ---- 在线下载 ---- + +/// Windows 平台便携包下载预设(体积大,均为官方/社区发布源,可被自定义 URL 覆盖)。 +pub fn download_presets() -> Vec { + if cfg!(windows) { + vec![ + DownloadPreset { + kind: ServiceKind::Redis, + label: "Redis 5.0.14(tporadowski Windows 构建)".into(), + url: "https://github.com/tporadowski/redis/releases/download/v5.0.14.1/Redis-x64-5.0.14.1.zip".into(), + size_hint: "约 10 MB".into(), + }, + DownloadPreset { + kind: ServiceKind::Mariadb, + label: "MariaDB 11.4(官方 winx64 便携包)".into(), + url: "https://archive.mariadb.org/mariadb-11.4.5/winx64-packages/mariadb-11.4.5-winx64.zip".into(), + size_hint: "约 850 MB".into(), + }, + DownloadPreset { + kind: ServiceKind::Mysql, + label: "MySQL 8.0(官方 winx64 便携包)".into(), + url: "https://cdn.mysql.com/Downloads/MySQL-8.0/mysql-8.0.43-winx64.zip".into(), + size_hint: "约 280 MB".into(), + }, + DownloadPreset { + kind: ServiceKind::Postgresql, + label: "PostgreSQL 16(EDB 官方二进制包)".into(), + url: "https://get.enterprisedb.com/postgresql/postgresql-16.9-1-windows-x64-binaries.zip".into(), + size_hint: "约 360 MB".into(), + }, + ] + } else { + // Linux 便携包碎片化严重,建议用系统包管理器安装后接管 + Vec::new() + } +} + +impl DbServiceManager { + /// 下载便携包并注册服务:下载 zip → 解压到 services//engine/ → 定位二进制。 + pub async fn download_install( + &self, + kind: ServiceKind, + name: &str, + url: &str, + on_progress: Option>, + ) -> Result { + if !kind.is_db() { + return Err(Error::Config("仅支持数据库引擎下载".into())); + } + // 先生成 id 以规划目录,再走 register 落注册表 + let probe = ManagedService::new(kind, name.to_string(), 0); + let engine_dir = self.services.service_dir(&probe.id).join("engine"); + std::fs::create_dir_all(&engine_dir)?; + + let tmp = engine_dir.join("download.zip"); + let result = download_and_extract(url, &tmp, &engine_dir, on_progress).await; + std::fs::remove_file(&tmp).ok(); + result?; + + // 递归定位服务端二进制(zip 通常带一层版本目录) + let spec = EngineSpec::by_kind(kind); + let binary = find_binary_recursive(&engine_dir, spec.binary) + .ok_or_else(|| Error::Other(format!("解压后未找到 {} 二进制", spec.binary)))?; + + self.register(ServiceInput { + kind, + name: name.to_string(), + port: 0, + binary_path: Some(binary), + os_service_name: None, + root_username: String::new(), + root_password: String::new(), + autostart: false, + extra_args: Vec::new(), + }) + } +} + +/// 流式下载 zip 并解压(复用运行时下载实现)。 +async fn download_and_extract( + url: &str, + tmp: &Path, + dest: &Path, + on_progress: Option>, +) -> Result<()> { + let client = reqwest::Client::builder() + .user_agent(concat!("RunPHP/", env!("CARGO_PKG_VERSION"))) + .build()?; + let resp = client.get(url).send().await?; + if !resp.status().is_success() { + return Err(Error::Other(format!("下载失败: HTTP {}", resp.status()))); + } + let total = resp.content_length().unwrap_or(0); + use futures_util::StreamExt; + let mut stream = resp.bytes_stream(); + let mut file = std::fs::File::create(tmp)?; + let mut downloaded: u64 = 0; + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + std::io::Write::write_all(&mut file, &chunk)?; + downloaded += chunk.len() as u64; + if let Some(cb) = &on_progress { + cb(downloaded, total); + } + } + drop(file); + crate::runtime::extract_zip(tmp, dest) +} + +// ---- 检测辅助 ---- + +impl ServiceKind { + /// 是否为数据库引擎(排除 FTP)。 + pub fn is_db(self) -> bool { + !matches!(self, ServiceKind::Ftp) + } +} + +/// 在常见安装位置扫描引擎二进制。 +fn scan_engine_binary(spec: &EngineSpec) -> Option { + common_engine_dirs(spec.kind) + .into_iter() + .map(|d| join_binary(&d, spec.binary)) + .find(|p| p.is_file()) +} + +/// 引擎常见安装目录(Windows 官方安装器默认位置 + 各平台包管理器位置)。 +fn common_engine_dirs(kind: ServiceKind) -> Vec { + let mut dirs = Vec::new(); + #[cfg(windows)] + { + if let Some(pf) = std::env::var_os("ProgramFiles") { + let pf = PathBuf::from(pf); + match kind { + ServiceKind::Mysql => { + if let Ok(entries) = std::fs::read_dir(pf.join("MySQL")) { + for e in entries.flatten() { + dirs.push(e.path().join("bin")); + } + } + } + ServiceKind::Mariadb => { + if let Ok(entries) = std::fs::read_dir(&pf) { + for e in entries.flatten() { + let n = e.file_name().to_string_lossy().to_lowercase(); + if n.starts_with("mariadb") { + dirs.push(e.path().join("bin")); + } + } + } + } + ServiceKind::Postgresql => { + if let Ok(entries) = std::fs::read_dir(pf.join("PostgreSQL")) { + for e in entries.flatten() { + dirs.push(e.path().join("bin")); + } + } + } + _ => {} + } + } + if let Some(home) = std::env::var_os("USERPROFILE") { + dirs.push(PathBuf::from(home).join("Redis")); + } + } + #[cfg(not(windows))] + { + dirs.push(PathBuf::from("/usr/sbin")); + dirs.push(PathBuf::from("/usr/bin")); + dirs.push(PathBuf::from("/usr/local/bin")); + if kind == ServiceKind::Postgresql { + for ver in ["17", "16", "15", "14", "13"] { + dirs.push(PathBuf::from(format!("/usr/lib/postgresql/{ver}/bin"))); + } + } + } + dirs +} + +/// 平台相关二进制名拼接(Windows 补 .exe)。 +fn join_binary(dir: &Path, name: &str) -> PathBuf { + if cfg!(windows) { + dir.join(format!("{name}.exe")) + } else { + dir.join(name) + } +} + +/// 在主二进制同级目录查找初始化辅助工具。 +fn find_helper(binary: Option<&Path>, names: &[&str]) -> Option { + let dir = binary?.parent()?; + names + .iter() + .map(|n| join_binary(dir, n)) + .find(|p| p.is_file()) +} + +/// 匹配系统服务名(Windows 查询 sc;Linux 查单元文件是否存在)。 +async fn detect_os_service(spec: &EngineSpec) -> Option { + if cfg!(windows) { + for name in spec.win_services { + if let Ok(out) = Command::new("sc").arg("query").arg(name).output().await { + if out.status.success() { + return Some(name.to_string()); + } + } + } + } else { + const UNIT_BASES: &[&str] = &[ + "/etc/systemd/system", + "/lib/systemd/system", + "/usr/lib/systemd/system", + ]; + for name in spec.systemd_units { + let installed = UNIT_BASES + .iter() + .any(|base| PathBuf::from(base).join(format!("{name}.service")).exists()); + if installed { + return Some(name.to_string()); + } + } + } + None +} + +/// 递归查找目录内的二进制(限深度 4,优先浅层命中)。 +fn find_binary_recursive(root: &Path, name: &str) -> Option { + fn walk(dir: &Path, name: &str, depth: u32) -> Option { + if depth > 4 { + return None; + } + let entries = std::fs::read_dir(dir).ok()?; + let mut subdirs = Vec::new(); + for entry in entries.flatten() { + let p = entry.path(); + if p.is_file() { + let file_name = p.file_name()?.to_string_lossy().to_lowercase(); + let want = if cfg!(windows) { + format!("{}.exe", name.to_lowercase()) + } else { + name.to_lowercase() + }; + if file_name == want { + return Some(p); + } + } else if p.is_dir() { + subdirs.push(p); + } + } + for sub in subdirs { + if let Some(hit) = walk(&sub, name, depth + 1) { + return Some(hit); + } + } + None + } + walk(root, name, 0) +} + +/// MySQL/MariaDB 数据目录是否已完成初始化。 +fn mysql_initialized(data: &Path) -> bool { + data.join("mysql").is_dir() +} + +/// 执行初始化命令(最长等待 5 分钟,MySQL 初始化可能较慢)。 +async fn run_step(binary: &Path, args: &[&str]) -> Result { + tokio::time::timeout( + std::time::Duration::from_secs(300), + Command::new(binary).args(args).output(), + ) + .await + .map_err(|_| Error::Other("初始化超时(5 分钟)".into()))? + .map_err(|e| Error::Other(format!("执行初始化失败: {e}"))) +} + +/// 对目标 Redis 发送单条命令(可选先 AUTH)。 +async fn redis_command(addr: &str, password: &str, args: &[&str]) -> Result { + let mut stream = tokio::net::TcpStream::connect(addr) + .await + .map_err(|e| Error::Other(format!("Redis 连接失败: {e}")))?; + if !password.is_empty() { + let auth = crate::db::remote::format_resp_command(&["AUTH", password]); + stream.write_all(auth.as_bytes()).await?; + let mut buf = [0u8; 128]; + let n = stream.read(&mut buf).await?; + let resp = String::from_utf8_lossy(&buf[..n]).to_string(); + if !resp.starts_with("+") { + return Err(Error::Other(format!("Redis 鉴权失败: {resp}"))); + } + } + let cmd = crate::db::remote::format_resp_command(args); + stream.write_all(cmd.as_bytes()).await?; + let mut buf = [0u8; 256]; + let n = stream.read(&mut buf).await?; + Ok(String::from_utf8_lossy(&buf[..n]).trim().to_string()) +} + +/// 更新 Redis 启动参数中的 requirepass(保留其余参数)。 +fn redis_password_args(current: &[String], password: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut skip_next = false; + for arg in current { + if skip_next { + skip_next = false; + continue; + } + let lower = arg.to_lowercase(); + if lower == "--requirepass" { + skip_next = true; + continue; + } + if let Some(val) = lower.strip_prefix("--requirepass=") { + let _ = val; + continue; + } + out.push(arg.clone()); + } + out.push("--requirepass".into()); + out.push(password.into()); + out +} + +// ---- SQL 转义与校验 ---- + +/// 标识符校验:字母/数字/下划线/连字符,1-64 位。 +fn validate_ident(name: &str) -> Result<()> { + let ok = !name.is_empty() + && name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + if ok { + Ok(()) + } else { + Err(Error::Config( + "名称仅允许字母、数字、下划线、连字符,长度 1-64".into(), + )) + } +} + +/// SQL 字符串字面量(单引号转义)。 +fn sql_str(s: &str) -> String { + format!("'{}'", s.replace('\'', "''")) +} + +/// MySQL 标识符(反引号转义)。 +fn mysql_ident(s: &str) -> String { + format!("`{}`", s.replace('`', "``")) +} + +/// PostgreSQL 标识符(双引号转义)。 +fn pg_ident(s: &str) -> String { + format!("\"{}\"", s.replace('"', "\"\"")) +} + +/// MySQL 用户@主机字面量。 +fn mysql_user_host(user: &str, host: &str) -> String { + format!("'{}'@'{}'", user.replace('\'', "''"), host.replace('\'', "''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn 标识符校验() { + assert!(validate_ident("shop_db-1").is_ok()); + assert!(validate_ident("").is_err()); + assert!(validate_ident("a".repeat(65).as_str()).is_err()); + assert!(validate_ident("db;drop").is_err()); + assert!(validate_ident("带中文").is_err()); + } + + #[test] + fn sql_转义() { + assert_eq!(sql_str("pa'ss"), "'pa''ss'"); + assert_eq!(mysql_ident("d`b"), "`d``b`"); + assert_eq!(pg_ident("d\"b"), "\"d\"\"b\""); + assert_eq!(mysql_user_host("u'x", "%"), "'u''x'@'%'"); + } + + #[test] + fn redis_密码参数更新() { + let base = vec!["--port".to_string(), "6380".to_string(), "--requirepass".to_string(), "old".to_string()]; + let out = redis_password_args(&base, "new"); + assert_eq!(out, vec!["--port", "6380", "--requirepass", "new"]); + + let eq_style = vec!["--requirepass=old".to_string()]; + assert_eq!(redis_password_args(&eq_style, "n2"), vec!["--requirepass", "n2"]); + + let empty: Vec = vec![]; + assert_eq!(redis_password_args(&empty, "p"), vec!["--requirepass", "p"]); + } + + #[test] + fn mysql_初始化判定() { + let dir = std::env::temp_dir().join("runphp-dbsvc-init-test"); + std::fs::remove_dir_all(&dir).ok(); + assert!(!mysql_initialized(&dir)); + std::fs::create_dir_all(dir.join("mysql")).unwrap(); + assert!(mysql_initialized(&dir)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn 递归查找二进制() { + let dir = std::env::temp_dir().join("runphp-dbsvc-find-test"); + std::fs::remove_dir_all(&dir).ok(); + let nested = dir.join("pkg").join("bin"); + std::fs::create_dir_all(&nested).unwrap(); + #[cfg(windows)] + let bin = nested.join("mysqld.exe"); + #[cfg(not(windows))] + let bin = nested.join("mysqld"); + std::fs::write(&bin, b"x").unwrap(); + assert_eq!(find_binary_recursive(&dir, "mysqld"), Some(bin)); + assert_eq!(find_binary_recursive(&dir, "nope"), None); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn 注册接管与便携服务() { + let dir = std::env::temp_dir().join("runphp-dbsvc-reg-test"); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = AppConfig { + data_dir: dir.clone(), + ..Default::default() + }; + let mgr = DbServiceManager::new(cfg); + + // 接管:无 binary_path + let takeover = mgr + .register(ServiceInput { + kind: ServiceKind::Mysql, + name: "本机 MySQL".into(), + port: 3306, + binary_path: None, + os_service_name: Some("MySQL80".into()), + root_username: "root".into(), + root_password: String::new(), + autostart: false, + extra_args: vec![], + }) + .unwrap(); + assert_eq!(takeover.source, ServiceSource::Takeover); + assert_eq!(mgr.list().unwrap().len(), 1); + + // 便携:提供 binary_path(伪造文件) + let bin = dir.join("mariadbd.exe"); + std::fs::write(&bin, b"x").unwrap(); + let portable = mgr + .register(ServiceInput { + kind: ServiceKind::Mariadb, + name: "便携 MariaDB".into(), + port: 3307, + binary_path: Some(bin), + os_service_name: None, + root_username: String::new(), + root_password: String::new(), + autostart: true, + extra_args: vec![], + }) + .unwrap(); + assert_eq!(portable.source, ServiceSource::Portable); + assert!(mgr.services.service_data_dir(&portable.id).exists()); + + // 空名称拒绝 + assert!(mgr + .register(ServiceInput { + kind: ServiceKind::Redis, + name: " ".into(), + port: 0, + binary_path: None, + os_service_name: None, + root_username: String::new(), + root_password: String::new(), + autostart: false, + extra_args: vec![], + }) + .is_err()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn 管理账号默认用户名() { + let mut mysql = ManagedService::new(ServiceKind::Mysql, "m".into(), 3306); + let p = DbServiceManager::admin_profile(&mysql).unwrap(); + assert_eq!(p.username, "root"); + assert_eq!(p.driver, DbDriver::Mysql); + + let pg = ManagedService::new(ServiceKind::Postgresql, "p".into(), 5432); + assert_eq!(DbServiceManager::admin_profile(&pg).unwrap().username, "postgres"); + + mysql.kind = ServiceKind::Ftp; + assert!(DbServiceManager::admin_profile(&mysql).is_err()); + } +} diff --git a/crates/runphp-core/src/ftpd.rs b/crates/runphp-core/src/ftpd.rs new file mode 100644 index 00000000..9aa15e92 --- /dev/null +++ b/crates/runphp-core/src/ftpd.rs @@ -0,0 +1,757 @@ +//! FTP 服务端管理:虚拟用户 + 双后端。 +//! +//! 后端选择:Linux 检测到 pure-ftpd/pure-pw 时优先对接 Pure-FTPd +//! (子进程托管,虚拟用户经 pure-pw 落 puredb);其余环境(含 Windows) +//! 内嵌 libunftp FTP 服务端,认证与用户目录直接读 JSON 存储,即时生效。 +//! +//! 虚拟用户存储为 `ftp_users.json`(JSON 为准源,Pure-FTPd 后端同步重建 puredb); +//! 服务配置存储为 `ftpd.json`;用户默认根目录 `<数据目录>/ftp/<用户名>`, +//! 所有用户强制锁定(chroot)在各自根目录内。 +//! +//! 密码沿用连接档案先例明文存储(本地工具用途),文档中有标注。 + +use crate::services::{probe_port, wait_port_ready, ManagedService, ServiceKind, ServiceManager, ServiceSource}; +use crate::{AppConfig, Error, Result}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tokio::sync::Mutex; + +/// FTP 服务在服务注册表中的固定 id(Pure-FTPd 后端复用 ServiceManager 托管)。 +const FTP_SERVICE_ID: &str = "ftp"; + +/// FTP 虚拟用户。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FtpUser { + /// 唯一标识。 + pub id: String, + /// 登录用户名(字母/数字/下划线/连字符)。 + pub username: String, + /// 密码(明文存储,本地工具用途)。 + pub password: String, + /// 自定义根目录(None 表示默认 `<数据目录>/ftp/<用户名>`)。 + #[serde(default)] + pub home_dir: Option, + /// 关联站点 id(可选)。 + #[serde(default)] + pub linked_site: Option, + /// 是否启用(禁用后拒绝登录)。 + #[serde(default = "default_true")] + pub enabled: bool, + /// 创建时间。 + pub created_at: String, +} + +fn default_true() -> bool { + true +} + +impl FtpUser { + /// 创建新用户(生成 id 与时间戳)。 + pub fn new(username: String, password: String) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + username, + password, + home_dir: None, + linked_site: None, + enabled: true, + created_at: chrono::Utc::now().to_rfc3339(), + } + } +} + +/// 用户注册表(持久化为 `ftp_users.json`)。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FtpUserRegistry { + pub users: Vec, +} + +/// FTP 服务端配置(持久化为 `ftpd.json`)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct FtpdConfig { + /// 控制端口。 + pub port: u16, + /// 被动模式端口区间起止。 + pub passive_from: u16, + pub passive_to: u16, + /// 是否随应用自动启动。 + pub autostart: bool, +} + +impl Default for FtpdConfig { + fn default() -> Self { + Self { + port: 21, + passive_from: 50000, + passive_to: 50010, + autostart: false, + } + } +} + +/// 可用后端。 +#[derive(Debug, Clone, Serialize)] +pub enum FtpdBackend { + /// 内嵌 libunftp。 + Embedded, + /// Pure-FTPd(携带二进制路径)。 + PureFtpd { binary: PathBuf }, +} + +/// 内嵌服务端实例句柄(进程内运行,停止即 abort)。 +static EMBEDDED: Mutex>>> = + Mutex::const_new(None); + +/// FTP 服务端管理器。 +pub struct FtpdManager { + cfg: AppConfig, + services: ServiceManager, +} + +impl FtpdManager { + pub fn new(cfg: AppConfig) -> Self { + Self { + services: ServiceManager::new(cfg.clone()), + cfg, + } + } + + fn users_path(&self) -> PathBuf { + self.cfg.data_dir.join("ftp_users.json") + } + + fn config_path(&self) -> PathBuf { + self.cfg.data_dir.join("ftpd.json") + } + + /// Pure-FTPd 后端的 passwd / puredb 文件路径。 + fn pure_passwd(&self) -> PathBuf { + self.cfg.data_dir.join("ftp").join("pureftpd.passwd") + } + + fn pure_puredb(&self) -> PathBuf { + self.cfg.data_dir.join("ftp").join("pureftpd.pdb") + } + + /// 读取用户注册表。 + pub fn load_users(&self) -> Result { + let path = self.users_path(); + if !path.exists() { + return Ok(FtpUserRegistry::default()); + } + let raw = std::fs::read_to_string(&path)?; + serde_json::from_str(&raw).map_err(Error::Json) + } + + fn save_users(&self, reg: &FtpUserRegistry) -> Result<()> { + let raw = serde_json::to_string_pretty(reg)?; + std::fs::write(self.users_path(), raw)?; + Ok(()) + } + + /// 读取服务配置。 + pub fn config(&self) -> FtpdConfig { + std::fs::read_to_string(self.config_path()) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default() + } + + /// 保存服务配置(运行中的内嵌/Pure-FTPd 服务需重启后生效)。 + pub fn save_config(&self, config: &FtpdConfig) -> Result<()> { + let raw = serde_json::to_string_pretty(config)?; + std::fs::create_dir_all(&self.cfg.data_dir)?; + std::fs::write(self.config_path(), raw)?; + Ok(()) + } + + /// 用户根目录(默认 `<数据目录>/ftp/<用户名>`)。 + pub fn user_home(&self, u: &FtpUser) -> PathBuf { + match &u.home_dir { + Some(h) if !h.trim().is_empty() => PathBuf::from(h), + _ => self.cfg.data_dir.join("ftp").join(&u.username), + } + } + + // ---- 后端探测 ---- + + /// 探测可用后端:Windows 恒为内嵌;Linux 找到 pure-ftpd 且同级有 pure-pw 时用 Pure-FTPd。 + pub fn detect_backend(&self) -> FtpdBackend { + if cfg!(windows) { + return FtpdBackend::Embedded; + } + let names = if cfg!(windows) { ["pure-ftpd.exe"] } else { ["pure-ftpd"] }; + let dirs: Vec = std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .chain([PathBuf::from("/usr/sbin"), PathBuf::from("/usr/bin"), PathBuf::from("/usr/local/sbin")]) + .collect(); + for dir in dirs { + for name in names { + let bin = dir.join(name); + if bin.is_file() { + // pure-pw 必须在同级(虚拟用户管理依赖它) + let pw = if cfg!(windows) { dir.join("pure-pw.exe") } else { dir.join("pure-pw") }; + if pw.is_file() { + return FtpdBackend::PureFtpd { binary: bin }; + } + } + } + } + FtpdBackend::Embedded + } + + /// 当前后端名称(展示用)。 + pub fn backend_name(&self) -> &'static str { + match self.detect_backend() { + FtpdBackend::Embedded => "内嵌 libunftp", + FtpdBackend::PureFtpd { .. } => "Pure-FTPd", + } + } + + // ---- 生命周期 ---- + + /// 启动 FTP 服务端(进程内运行,适用于桌面端/面板等常驻宿主),返回后端描述。 + /// + /// CLI 等短生命周期宿主请用 [`FtpdManager::start_daemon`] 或 `ftpd run`。 + pub async fn start(&self) -> Result<&'static str> { + let config = self.config(); + match self.detect_backend() { + FtpdBackend::Embedded => { + if probe_port(config.port).await { + return Err(Error::Other(format!("端口 {} 已被占用", config.port))); + } + std::fs::create_dir_all(self.cfg.data_dir.join("ftp"))?; + let data_dir = self.cfg.data_dir.clone(); + let users_path = self.users_path(); + let authenticator = std::sync::Arc::new(RunphpAuthenticator { users_path: users_path.clone() }); + let provider = std::sync::Arc::new(RunphpUserProvider { + users_path, + data_dir: data_dir.clone(), + }); + let factory = move || { + unftp_sbe_fs::Filesystem::new(data_dir.clone()) + .unwrap_or_else(|e| panic!("FTP 根目录不可用: {e}")) + }; + let server = libunftp::ServerBuilder::with_authenticator( + Box::new(factory), + authenticator, + ) + .user_detail_provider(provider) + .passive_ports(config.passive_from..=config.passive_to) + .build() + .map_err(|e| Error::Other(format!("FTP 服务端构建失败: {e}")))?; + let handle = tokio::spawn(server.listen(format!("0.0.0.0:{}", config.port))); + let mut guard = EMBEDDED.lock().await; + if let Some(old) = guard.take() { + old.abort(); + } + *guard = Some(handle); + if !wait_port_ready(config.port, 5).await { + return Err(Error::Other(format!( + "内嵌 FTP 服务启动后端口 {} 未就绪", + config.port + ))); + } + Ok("内嵌 libunftp") + } + FtpdBackend::PureFtpd { binary } => { + self.sync_puredb(&binary).await?; + let mut svc = ManagedService::new(ServiceKind::Ftp, "FTP 服务".into(), config.port); + svc.id = FTP_SERVICE_ID.to_string(); + svc.source = ServiceSource::Portable; + svc.binary_path = Some(binary); + svc.extra_args = pure_ftpd_args(config.port, config.passive_from, config.passive_to, &self.pure_puredb()); + self.services.upsert(svc)?; + self.services.start(FTP_SERVICE_ID).await?; + if !wait_port_ready(config.port, 10).await { + let log = self.services.read_log(FTP_SERVICE_ID, 10).unwrap_or_default(); + return Err(Error::Other(format!("Pure-FTPd 启动后端口 {} 未就绪。日志末尾:\n{log}", config.port))); + } + Ok("Pure-FTPd") + } + } + } + + /// 前台运行 FTP 服务端直到被停止(`ftpd run` 宿主循环内调用 start 后阻塞)。 + pub async fn run_forever(&self) -> Result<()> { + let backend = self.start().await?; + tracing::info!("FTP 服务端({backend})前台运行中,等待中断…"); + match tokio::signal::ctrl_c().await { + Ok(()) => tracing::info!("收到中断信号,停止 FTP 服务端"), + Err(e) => tracing::warn!("等待中断信号失败: {e}"), + } + self.stop().await + } + + /// 守护方式启动(CLI 场景):Pure-FTPd 走子进程托管; + /// 内嵌后端自派生分离的 `runphp ftpd run` 子进程(PID 落盘供 stop)。 + pub async fn start_daemon(&self) -> Result<&'static str> { + if !matches!(self.detect_backend(), FtpdBackend::Embedded) { + return self.start().await; + } + let config = self.config(); + if probe_port(config.port).await { + return Err(Error::Other(format!("端口 {} 已被占用", config.port))); + } + let exe = std::env::current_exe()?; + let mut cmd = tokio::process::Command::new(exe); + cmd.args(["ftpd", "run", "--data-dir"]) + .arg(&self.cfg.data_dir) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + #[cfg(windows)] + { + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + // tokio::process::Command 在 Windows 上原生提供 creation_flags + cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // 脱离终端会话,避免随宿主退出 + cmd.process_group(0); + } + let child = cmd + .spawn() + .map_err(|e| Error::Other(format!("派生 FTP 守护进程失败: {e}")))?; + let pid = child.id(); + // 子进程已分离,句柄即刻丢弃 + drop(child); + if let Some(pid) = pid { + std::fs::create_dir_all(self.cfg.data_dir.join("ftp"))?; + std::fs::write(self.embedded_pid_file(), pid.to_string())?; + } + if !wait_port_ready(config.port, 10).await { + return Err(Error::Other("内嵌 FTP 守护进程启动后端口未就绪".into())); + } + Ok("内嵌 libunftp(守护进程)") + } + + /// 内嵌守护进程的 PID 文件。 + fn embedded_pid_file(&self) -> PathBuf { + self.cfg.data_dir.join("ftp").join("embedded.pid") + } + + /// 停止 FTP 服务端(进程内实例、守护进程、Pure-FTPd 全部尽力停止)。 + pub async fn stop(&self) -> Result<()> { + if let Some(handle) = EMBEDDED.lock().await.take() { + handle.abort(); + } + // 分离守护进程按 PID 文件终止 + let pid_file = self.embedded_pid_file(); + if pid_file.exists() { + if let Ok(pid) = std::fs::read_to_string(&pid_file) + .map_err(Error::Io) + .and_then(|s| s.trim().parse::().map_err(|_| Error::Other("PID 解析失败".into()))) + { + crate::caddy::kill_process(pid); + } + std::fs::remove_file(&pid_file).ok(); + } + // Pure-FTPd 注册过则一并停止(未注册时忽略) + let _ = self.services.stop(FTP_SERVICE_ID).await; + Ok(()) + } + + /// 服务状态(控制端口探测)。 + pub async fn status(&self) -> bool { + probe_port(self.config().port).await + } + + // ---- 用户 CRUD ---- + + /// 列出全部虚拟用户。 + pub fn list_users(&self) -> Result> { + Ok(self.load_users()?.users) + } + + /// 新增用户(同名拒绝;自动创建默认根目录)。 + pub async fn add_user(&self, user: FtpUser) -> Result<()> { + validate_username(&user.username)?; + let mut reg = self.load_users()?; + if reg.users.iter().any(|u| u.username == user.username) { + return Err(Error::Config(format!("用户 {} 已存在", user.username))); + } + std::fs::create_dir_all(self.user_home(&user))?; + reg.users.push(user); + self.save_users(®)?; + self.sync_users_to_backend().await + } + + /// 更新用户(按 id,保留 created_at)。 + pub async fn update_user(&self, user: FtpUser) -> Result<()> { + validate_username(&user.username)?; + let mut reg = self.load_users()?; + // 先做同名检查再取可变引用,避免借用冲突 + if reg + .users + .iter() + .any(|u| u.id != user.id && u.username == user.username) + { + return Err(Error::Config(format!("用户 {} 已存在", user.username))); + } + let target = reg + .users + .iter_mut() + .find(|u| u.id == user.id) + .ok_or_else(|| Error::Other("FTP 用户不存在".into()))?; + let mut user = user; + user.created_at = target.created_at.clone(); + std::fs::create_dir_all(self.user_home(&user))?; + *target = user; + self.save_users(®)?; + self.sync_users_to_backend().await + } + + /// 删除用户(按 id)。 + pub async fn remove_user(&self, id: &str) -> Result<()> { + let mut reg = self.load_users()?; + reg.users.retain(|u| u.id != id); + self.save_users(®)?; + self.sync_users_to_backend().await + } + + /// 用户变更后同步到 Pure-FTPd 后端(内嵌后端即时生效无需同步)。 + async fn sync_users_to_backend(&self) -> Result<()> { + if let FtpdBackend::PureFtpd { binary } = self.detect_backend() { + self.sync_puredb(&binary).await?; + } + Ok(()) + } + + /// 以 JSON 注册表为准源,全量重建 Pure-FTPd 的 passwd 与 puredb。 + async fn sync_puredb(&self, binary: &Path) -> Result<()> { + let pure_pw = binary + .parent() + .map(|d| { + if cfg!(windows) { + d.join("pure-pw.exe") + } else { + d.join("pure-pw") + } + }) + .filter(|p| p.is_file()) + .ok_or_else(|| Error::Other("未找到 pure-pw 工具(需与 pure-ftpd 同目录)".into()))?; + let passwd = self.pure_passwd(); + let puredb = self.pure_puredb(); + if let Some(parent) = passwd.parent() { + std::fs::create_dir_all(parent)?; + } + // 全量重建:删除旧库后按注册表逐个写入 + std::fs::remove_file(&passwd).ok(); + std::fs::remove_file(&puredb).ok(); + let sys_user = std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .unwrap_or_else(|_| "nobody".to_string()); + for u in self.load_users()?.users.iter().filter(|u| u.enabled) { + let home = self.user_home(u); + std::fs::create_dir_all(&home)?; + let mut child = tokio::process::Command::new(&pure_pw) + .args([ + "useradd", + &u.username, + "-f", + &passwd.to_string_lossy(), + "-u", + &sys_user, + "-d", + &home.to_string_lossy(), + "-m", + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| Error::Other(format!("执行 pure-pw 失败: {e}")))?; + // pure-pw 交互式提示密码两次 + if let Some(mut stdin) = child.stdin.take() { + use tokio::io::AsyncWriteExt; + let _ = stdin + .write_all(format!("{}\n{}\n", u.password, u.password).as_bytes()) + .await; + } + let output = child + .wait_with_output() + .await + .map_err(|e| Error::Other(format!("pure-pw 执行失败: {e}")))?; + if !output.status.success() { + return Err(Error::Other(format!( + "pure-pw 写入用户 {} 失败: {}", + u.username, + String::from_utf8_lossy(&output.stderr) + ))); + } + } + Ok(()) + } +} + +/// Pure-FTPd 启动参数(chroot 全员 + 自动建主目录 + puredb 认证)。 +fn pure_ftpd_args(port: u16, passive_from: u16, passive_to: u16, puredb: &Path) -> Vec { + vec![ + "-A".into(), + "-j".into(), + "-S".into(), + format!("0.0.0.0:{port}"), + "-p".into(), + format!("{passive_from}:{passive_to}"), + "-l".into(), + format!("puredb:{}", puredb.display()), + ] +} + +/// 用户名校验:字母/数字/下划线/连字符,1-32 位。 +fn validate_username(name: &str) -> Result<()> { + let ok = !name.is_empty() + && name.len() <= 32 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + if ok { + Ok(()) + } else { + Err(Error::Config( + "用户名仅允许字母、数字、下划线、连字符,长度 1-32".into(), + )) + } +} + +// ---- 内嵌后端(认证与用户详情,现读 JSON 即时生效) ---- + +/// 认证器:校验 ftp_users.json 中的用户名/密码。 +#[derive(Debug)] +struct RunphpAuthenticator { + users_path: PathBuf, +} + +#[async_trait] +impl unftp_core::auth::Authenticator for RunphpAuthenticator { + async fn authenticate( + &self, + username: &str, + creds: &unftp_core::auth::Credentials, + ) -> std::result::Result { + let reg: FtpUserRegistry = std::fs::read_to_string(&self.users_path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default(); + let user = reg + .users + .iter() + .find(|u| u.username == username && u.enabled) + .ok_or(unftp_core::auth::AuthenticationError::BadUser)?; + let pass = creds.password.as_deref().unwrap_or(""); + if user.password != pass { + return Err(unftp_core::auth::AuthenticationError::BadPassword); + } + Ok(unftp_core::auth::Principal { + username: username.to_string(), + }) + } +} + +/// 会话用户详情:携带根目录实现每用户 chroot。 +#[derive(Debug, Clone)] +struct RunphpUser { + username: String, + home: PathBuf, + enabled: bool, +} + +impl std::fmt::Display for RunphpUser { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.username) + } +} + +impl unftp_core::auth::UserDetail for RunphpUser { + fn account_enabled(&self) -> bool { + self.enabled + } + + fn home(&self) -> Option<&Path> { + Some(&self.home) + } +} + +/// 用户详情提供者:Principal → 根目录(校验须位于数据目录内)。 +#[derive(Debug)] +struct RunphpUserProvider { + users_path: PathBuf, + data_dir: PathBuf, +} + +#[async_trait] +impl unftp_core::auth::UserDetailProvider for RunphpUserProvider { + type User = RunphpUser; + + async fn provide_user_detail( + &self, + principal: &unftp_core::auth::Principal, + ) -> std::result::Result { + let reg: FtpUserRegistry = std::fs::read_to_string(&self.users_path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default(); + let user = reg + .users + .iter() + .find(|u| u.username == principal.username) + .ok_or_else(|| unftp_core::auth::UserDetailError::UserNotFound { + username: principal.username.clone(), + })?; + // 内嵌后端以数据目录为文件系统根(cap-std 能力约束), + // 外部目录(如站点根不在数据目录内)需改用 Pure-FTPd 后端 + let home = if let Some(h) = &user.home_dir { + PathBuf::from(h) + } else { + self.data_dir.join("ftp").join(&user.username) + }; + if !home.starts_with(&self.data_dir) { + return Err(unftp_core::auth::UserDetailError::Generic( + "内嵌 FTP 服务端的用户目录须位于数据目录内;绑定外部目录请使用 Pure-FTPd 后端".into(), + )); + } + Ok(RunphpUser { + username: user.username.clone(), + home, + enabled: user.enabled, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use unftp_core::auth::Authenticator as _; + + fn mgr_at(dir: &Path) -> FtpdManager { + let cfg = AppConfig { + data_dir: dir.to_path_buf(), + ..Default::default() + }; + FtpdManager::new(cfg) + } + + #[test] + fn 用户注册表往返与校验() { + let dir = std::env::temp_dir().join("runphp-ftpd-users-test"); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + let mgr = mgr_at(&dir); + assert!(mgr.list_users().unwrap().is_empty()); + assert!(validate_username("web-1_x").is_ok()); + assert!(validate_username("bad name").is_err()); + assert!(validate_username("a".repeat(33).as_str()).is_err()); + + let mut reg = FtpUserRegistry::default(); + reg.users.push(FtpUser::new("alice".into(), "pw".into())); + mgr.save_users(®).unwrap(); + assert_eq!(mgr.list_users().unwrap().len(), 1); + assert_eq!(mgr.list_users().unwrap()[0].username, "alice"); + // enabled 缺省兼容 + let raw = std::fs::read_to_string(mgr.users_path()).unwrap(); + assert!(raw.contains("\"username\": \"alice\"")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn 配置默认值与保存() { + let dir = std::env::temp_dir().join("runphp-ftpd-cfg-test"); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + let mgr = mgr_at(&dir); + let c = mgr.config(); + assert_eq!(c.port, 21); + assert_eq!(c.passive_from, 50000); + assert!(!c.autostart); + + let mut c2 = c.clone(); + c2.port = 2121; + c2.autostart = true; + mgr.save_config(&c2).unwrap(); + let c3 = mgr.config(); + assert_eq!(c3.port, 2121); + assert!(c3.autostart); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn 用户根目录默认与自定义() { + let dir = std::env::temp_dir().join("runphp-ftpd-home-test"); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + let mgr = mgr_at(&dir); + let mut u = FtpUser::new("bob".into(), "pw".into()); + assert_eq!( + mgr.user_home(&u), + dir.join("ftp").join("bob") + ); + u.home_dir = Some("/srv/custom".into()); + assert_eq!(mgr.user_home(&u), PathBuf::from("/srv/custom")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn pure_ftpd_参数构造() { + let args = pure_ftpd_args(2121, 50000, 50100, Path::new("/data/ftp/pureftpd.pdb")); + assert!(args.contains(&"-A".to_string())); + assert!(args.windows(2).any(|w| w[0] == "-S" && w[1] == "0.0.0.0:2121")); + assert!(args.windows(2).any(|w| w[0] == "-p" && w[1] == "50000:50100")); + assert!( + args.windows(2) + .any(|w| w[0] == "-l" && w[1] == "puredb:/data/ftp/pureftpd.pdb") + ); + } + + #[tokio::test] + async fn 用户增改删与目录创建() { + let dir = std::env::temp_dir().join("runphp-ftpd-crud-test"); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + let mgr = mgr_at(&dir); + + let u = FtpUser::new("carol".into(), "pw".into()); + mgr.add_user(u.clone()).await.unwrap(); + assert!(dir.join("ftp").join("carol").is_dir()); + + // 同名拒绝 + let dup = FtpUser::new("carol".into(), "pw2".into()); + assert!(mgr.add_user(dup).await.is_err()); + + // 更新 + let mut updated = mgr.list_users().unwrap()[0].clone(); + updated.password = "new".into(); + mgr.update_user(updated).await.unwrap(); + assert_eq!(mgr.list_users().unwrap()[0].password, "new"); + + // 删除 + let id = mgr.list_users().unwrap()[0].id.clone(); + mgr.remove_user(&id).await.unwrap(); + assert!(mgr.list_users().unwrap().is_empty()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn 内嵌认证器校验用户与密码() { + let dir = std::env::temp_dir().join("runphp-ftpd-auth-test"); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + let mgr = mgr_at(&dir); + let mut reg = FtpUserRegistry::default(); + reg.users.push(FtpUser::new("dave".into(), "secret".into())); + mgr.save_users(®).unwrap(); + + let auth = RunphpAuthenticator { + users_path: mgr.users_path(), + }; + let mut creds: unftp_core::auth::Credentials = "secret".into(); + assert!(auth.authenticate("dave", &creds).await.is_ok()); + creds.password = Some("wrong".into()); + assert!(auth.authenticate("dave", &creds).await.is_err()); + assert!(auth.authenticate("nobody", &"secret".into()).await.is_err()); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/runphp-core/src/lib.rs b/crates/runphp-core/src/lib.rs index 84ffd6b4..8e66143a 100644 --- a/crates/runphp-core/src/lib.rs +++ b/crates/runphp-core/src/lib.rs @@ -10,8 +10,10 @@ pub mod detect; pub mod error; pub mod fs; pub mod ftp; +pub mod ftpd; pub mod hosts; pub mod runtime; +pub mod services; pub mod site; pub mod system; @@ -36,3 +38,23 @@ pub fn default_data_dir() -> std::path::PathBuf { .unwrap_or_else(|| std::path::PathBuf::from(".data")) } } + +/// 启动全部标记为自启的服务(数据库服务 + FTP 服务端)。 +/// +/// 供桌面端与面板端启动时调用;单项失败仅记录日志不中断其余服务。 +pub async fn autostart_services(cfg: &AppConfig) { + let db = crate::db::service::DbServiceManager::new(cfg.clone()); + if let Ok(list) = db.list() { + for svc in list.into_iter().filter(|s| s.autostart) { + if let Err(e) = db.start(&svc.id).await { + tracing::warn!("自启动服务 {} 失败: {e}", svc.name); + } + } + } + let ftpd = crate::ftpd::FtpdManager::new(cfg.clone()); + if ftpd.config().autostart { + if let Err(e) = ftpd.start().await { + tracing::warn!("自启动 FTP 服务失败: {e}"); + } + } +} diff --git a/crates/runphp-core/src/runtime.rs b/crates/runphp-core/src/runtime.rs index 7cf57426..2ab5c801 100644 --- a/crates/runphp-core/src/runtime.rs +++ b/crates/runphp-core/src/runtime.rs @@ -387,7 +387,7 @@ pub async fn available_versions() -> Result> { } /// 解压 zip 到目标目录。 -fn extract_zip(zip_path: &Path, dest: &Path) -> Result<()> { +pub(crate) fn extract_zip(zip_path: &Path, dest: &Path) -> Result<()> { let file = std::fs::File::open(zip_path)?; let mut archive = zip::ZipArchive::new(file)?; for i in 0..archive.len() { diff --git a/crates/runphp-core/src/services.rs b/crates/runphp-core/src/services.rs new file mode 100644 index 00000000..ce808cda --- /dev/null +++ b/crates/runphp-core/src/services.rs @@ -0,0 +1,509 @@ +//! 本地服务托管:数据库引擎 / FTP 服务端的进程生命周期管理。 +//! +//! 两类服务来源: +//! - takeover(接管):本机已安装的系统服务(Windows 服务 / systemd 单元), +//! 仅调用系统命令启停并用端口探测状态,不托管进程 +//! - portable(便携托管):RunPHP 以子进程方式托管外部二进制, +//! 数据落在 `services//data`,日志写入 `logs/services/.log`, +//! PID 写入 `services/.pid` 供跨会话兜底终止 +//! +//! 引擎差异(数据目录初始化、建库建用户)由 `db::service` 与 `ftpd` 模块负责, +//! 本模块只提供通用的注册表与进程托管。 + +use crate::{config::AppConfig, Error, Result}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::{Child, Command}; +use tokio::sync::Mutex; + +/// 服务类型。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ServiceKind { + Mysql, + Mariadb, + Postgresql, + Redis, + Ftp, +} + +impl ServiceKind { + /// 默认监听端口。 + pub fn default_port(self) -> u16 { + match self { + ServiceKind::Mysql | ServiceKind::Mariadb => 3306, + ServiceKind::Postgresql => 5432, + ServiceKind::Redis => 6379, + ServiceKind::Ftp => 21, + } + } + + /// 显示名称。 + pub fn display_name(self) -> &'static str { + match self { + ServiceKind::Mysql => "MySQL", + ServiceKind::Mariadb => "MariaDB", + ServiceKind::Postgresql => "PostgreSQL", + ServiceKind::Redis => "Redis", + ServiceKind::Ftp => "FTP", + } + } +} + +/// 服务来源。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ServiceSource { + /// 接管本机已安装的系统服务。 + Takeover, + /// RunPHP 子进程托管便携二进制。 + Portable, +} + +/// 受管服务定义。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManagedService { + /// 唯一标识(uuid)。 + pub id: String, + /// 服务类型。 + pub kind: ServiceKind, + /// 显示名称。 + pub name: String, + /// 来源。 + pub source: ServiceSource, + /// 便携托管的二进制绝对路径(接管服务为 None)。 + #[serde(default)] + pub binary_path: Option, + /// 监听端口。 + pub port: u16, + /// 是否随应用自动启动。 + #[serde(default)] + pub autostart: bool, + /// 管理员用户名(MySQL/MariaDB/PostgreSQL 的 root 凭据;Redis/FTP 为空)。 + #[serde(default)] + pub root_username: String, + /// 管理员密码(明文存储,本地工具用途,与连接档案先例一致)。 + #[serde(default)] + pub root_password: String, + /// 接管服务对应的系统服务名(Windows 服务名或 systemd 单元名)。 + #[serde(default)] + pub os_service_name: Option, + /// 附加启动参数(便携服务,逐条传入命令行)。 + #[serde(default)] + pub extra_args: Vec, + /// 创建时间(RFC3339)。 + pub created_at: String, +} + +impl ManagedService { + /// 创建新服务定义(生成 id 与时间戳)。 + pub fn new(kind: ServiceKind, name: String, port: u16) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + kind, + name, + source: ServiceSource::Portable, + binary_path: None, + port: if port > 0 { port } else { kind.default_port() }, + autostart: false, + root_username: String::new(), + root_password: String::new(), + os_service_name: None, + extra_args: Vec::new(), + created_at: chrono::Utc::now().to_rfc3339(), + } + } +} + +/// 服务注册表(持久化为 `services.json`)。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ServiceRegistry { + pub services: Vec, +} + +/// 服务运行状态。 +#[derive(Debug, Clone, Serialize)] +pub struct ServiceStatus { + /// 端口是否可达(对接管与便携服务均有效)。 + pub running: bool, + /// 便携托管的进程 PID(读取自 PID 文件,跨会话有效)。 + pub pid: Option, +} + +/// 服务管理器:注册表 CRUD + 进程托管。 +pub struct ServiceManager { + cfg: AppConfig, +} + +/// 全局托管的子进程表(服务 id → 进程句柄)。 +static HOSTED: Mutex> = Mutex::const_new(Vec::new()); + +/// 在托管表中插入/替换服务进程句柄。 +async fn hosted_insert(id: &str, child: Child) { + let mut guard = HOSTED.lock().await; + guard.retain(|(i, _)| i != id); + guard.push((id.to_string(), child)); +} + +/// 从托管表取出并返回服务进程句柄。 +async fn hosted_take(id: &str) -> Option { + let mut guard = HOSTED.lock().await; + let pos = guard.iter().position(|(i, _)| i == id)?; + Some(guard.remove(pos).1) +} + +impl ServiceManager { + pub fn new(cfg: AppConfig) -> Self { + Self { cfg } + } + + /// 注册表文件路径:`数据目录/services.json`。 + fn registry_path(&self) -> PathBuf { + self.cfg.data_dir.join("services.json") + } + + /// 服务条目根目录:`数据目录/services/`。 + pub fn service_dir(&self, id: &str) -> PathBuf { + self.cfg.data_dir.join("services").join(id) + } + + /// 便携服务数据目录:`数据目录/services//data`。 + pub fn service_data_dir(&self, id: &str) -> PathBuf { + self.service_dir(id).join("data") + } + + /// 服务日志路径:`数据目录/logs/services/.log`。 + pub fn log_path(&self, id: &str) -> PathBuf { + self.cfg.logs_dir().join("services").join(format!("{id}.log")) + } + + /// PID 文件路径:`数据目录/services/.pid`。 + fn pid_file(&self, id: &str) -> PathBuf { + self.service_dir(id).join("service.pid") + } + + pub fn load(&self) -> Result { + let path = self.registry_path(); + if !path.exists() { + return Ok(ServiceRegistry::default()); + } + let raw = std::fs::read_to_string(&path)?; + serde_json::from_str(&raw).map_err(Error::Json) + } + + pub fn save(&self, reg: &ServiceRegistry) -> Result<()> { + std::fs::create_dir_all(&self.cfg.data_dir)?; + let raw = serde_json::to_string_pretty(reg)?; + std::fs::write(self.registry_path(), raw)?; + Ok(()) + } + + /// 列出全部受管服务。 + pub fn list(&self) -> Result> { + Ok(self.load()?.services) + } + + /// 按 id 查找服务。 + pub fn get(&self, id: &str) -> Result { + self.load()? + .services + .into_iter() + .find(|s| s.id == id) + .ok_or_else(|| Error::Other(format!("服务 {id} 不存在"))) + } + + /// 新增或按 id 覆盖保存服务(保留原 created_at)。 + pub fn upsert(&self, mut service: ManagedService) -> Result<()> { + let mut reg = self.load()?; + if let Some(existing) = reg.services.iter_mut().find(|s| s.id == service.id) { + service.created_at = existing.created_at.clone(); + *existing = service; + } else { + reg.services.push(service); + } + self.save(®) + } + + /// 删除服务注册(不动进程,调用方先行停止)。 + pub fn remove(&self, id: &str) -> Result<()> { + let mut reg = self.load()?; + reg.services.retain(|s| s.id != id); + self.save(®) + } + + /// 读取 PID 文件(便携托管专用)。 + fn read_pid(&self, id: &str) -> Option { + std::fs::read_to_string(self.pid_file(id)) + .ok()? + .trim() + .parse() + .ok() + } + + /// 启动服务:便携托管走子进程,接管服务调用系统命令。 + pub async fn start(&self, id: &str) -> Result<()> { + let svc = self.get(id)?; + match svc.source { + ServiceSource::Portable => { + let binary = svc + .binary_path + .as_ref() + .filter(|p| p.is_file()) + .ok_or_else(|| { + Error::Other(format!( + "二进制不存在,请检查服务路径: {}", + svc.binary_path + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default() + )) + })?; + std::fs::create_dir_all(self.service_data_dir(id))?; + let log_dir = self.log_path(id).parent().unwrap().to_path_buf(); + std::fs::create_dir_all(&log_dir)?; + // 截断旧日志 + let log_file = std::fs::File::create(self.log_path(id))?; + let stderr = log_file.try_clone()?; + let mut cmd = Command::new(binary); + cmd.args(portable_args(&svc, &self.service_data_dir(id))) + .stdout(Stdio::from(log_file)) + .stderr(Stdio::from(stderr)); + let child = cmd + .spawn() + .map_err(|e| Error::Other(format!("启动 {} 失败: {e}", svc.kind.display_name())))?; + let pid = child.id().unwrap_or(0); + if pid > 0 { + std::fs::write(self.pid_file(id), pid.to_string())?; + } + hosted_insert(id, child).await; + tracing::info!("已启动服务 {}(pid={pid})", svc.name); + Ok(()) + } + ServiceSource::Takeover => { + let name = svc.os_service_name.clone().ok_or_else(|| { + Error::Other("该服务未关联系统服务,无法通过 RunPHP 启动".into()) + })?; + os_service_ctl(&name, true).await + } + } + } + + /// 停止服务:便携托管先杀子进程、再按 PID 文件兜底;接管服务调用系统命令。 + pub async fn stop(&self, id: &str) -> Result<()> { + let svc = self.get(id)?; + match svc.source { + ServiceSource::Portable => { + if let Some(mut child) = hosted_take(id).await { + let _ = child.kill().await; + } else if let Some(pid) = self.read_pid(id) { + // 跨会话兜底(上个会话遗留的进程) + crate::caddy::kill_process(pid); + } + std::fs::remove_file(self.pid_file(id)).ok(); + tracing::info!("已停止服务 {}", svc.name); + Ok(()) + } + ServiceSource::Takeover => { + let name = svc.os_service_name.clone().ok_or_else(|| { + Error::Other("该服务未关联系统服务,无法通过 RunPHP 停止".into()) + })?; + os_service_ctl(&name, false).await + } + } + } + + /// 查询服务状态(端口探测 + PID 文件)。 + pub async fn status(&self, id: &str) -> Result { + let svc = self.get(id)?; + Ok(ServiceStatus { + running: probe_port(svc.port).await, + pid: self.read_pid(id), + }) + } + + /// 读取服务日志末尾若干行。 + pub fn read_log(&self, id: &str, tail_lines: usize) -> Result { + let path = self.log_path(id); + if !path.exists() { + return Ok(String::new()); + } + let content = std::fs::read_to_string(&path)?; + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(tail_lines); + Ok(lines[start..].join("\n")) + } +} + +/// 构造便携服务的启动参数(引擎公共部分;引擎特有参数经 extra_args 附加)。 +/// +/// `data_dir` 为 ServiceManager 规划的 `services//data`。 +fn portable_args(svc: &ManagedService, data_dir: &std::path::Path) -> Vec { + let dir = data_dir.to_string_lossy().to_string(); + let mut args: Vec = match svc.kind { + ServiceKind::Mysql | ServiceKind::Mariadb => vec![ + "--console".into(), + "--datadir".into(), + dir, + "--port".into(), + svc.port.to_string(), + ], + ServiceKind::Postgresql => vec![ + "-D".into(), + dir, + "-p".into(), + svc.port.to_string(), + ], + ServiceKind::Redis => vec![ + "--port".into(), + svc.port.to_string(), + "--dir".into(), + dir, + ], + // FTP 服务端参数由 ftpd 模块通过 extra_args 提供 + ServiceKind::Ftp => vec![], + }; + args.extend(svc.extra_args.iter().cloned()); + args +} + +/// 调用系统服务控制(Windows `sc` / Linux `systemctl`)。 +async fn os_service_ctl(name: &str, start: bool) -> Result<()> { + let action = if start { "start" } else { "stop" }; + let output = if cfg!(windows) { + Command::new("sc").args([action, name]).output().await + } else { + Command::new("systemctl").args([action, name]).output().await + }; + let output = output.map_err(|e| Error::Other(format!("调用系统服务控制失败: {e}")))?; + if !output.status.success() { + let msg = format!( + "{}", + String::from_utf8_lossy(if output.stderr.is_empty() { + &output.stdout + } else { + &output.stderr + }) + ) + .trim() + .to_string(); + return Err(Error::Other(format!( + "系统服务 {name} {action} 失败: {msg}(接管启停可能需要管理员/root 权限,也可改用便携托管)" + ))); + } + Ok(()) +} + +/// 探测本机端口是否可达(500ms 超时)。 +pub(crate) async fn probe_port(port: u16) -> bool { + tokio::time::timeout( + Duration::from_millis(500), + tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")), + ) + .await + .map(|r| r.is_ok()) + .unwrap_or(false) +} + +/// 轮询等待端口就绪(数据库引擎启动需数秒到数十秒)。 +pub(crate) async fn wait_port_ready(port: u16, timeout_secs: u64) -> bool { + let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs); + while std::time::Instant::now() < deadline { + if probe_port(port).await { + return true; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + fn svc(kind: ServiceKind, id: &str, port: u16) -> ManagedService { + let mut s = ManagedService::new(kind, format!("{kind:?}"), port); + s.id = id.to_string(); + s + } + + #[test] + fn 注册表增改删往返() { + let dir = std::env::temp_dir().join("runphp-svc-registry-test"); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = AppConfig { + data_dir: dir.clone(), + ..Default::default() + }; + let mgr = ServiceManager::new(cfg); + assert!(mgr.list().unwrap().is_empty()); + + let mut s = svc(ServiceKind::Mysql, "m1", 3306); + mgr.upsert(s.clone()).unwrap(); + // 按 id 覆盖保存(更新端口) + s.port = 3307; + mgr.upsert(s).unwrap(); + assert_eq!(mgr.list().unwrap().len(), 1); + assert_eq!(mgr.get("m1").unwrap().port, 3307); + + mgr.remove("m1").unwrap(); + assert!(mgr.list().unwrap().is_empty()); + assert!(mgr.get("m1").is_err()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn 各引擎启动参数() { + let data = PathBuf::from("/data/services/m1/data"); + let mut mysql = svc(ServiceKind::Mysql, "m1", 3306); + mysql.binary_path = Some(PathBuf::from("/bin/mysqld")); + let args = portable_args(&mysql, &data); + assert!(args + .windows(2) + .any(|w| w[0] == "--datadir" && w[1] == data.to_string_lossy())); + assert!(args.contains(&"--port".to_string())); + assert!(args.contains(&"3306".to_string())); + + let pg = svc(ServiceKind::Postgresql, "p1", 5432); + let args = portable_args(&pg, &data); + assert!(args.windows(2).any(|w| w[0] == "-D" && w[1] == data.to_string_lossy())); + + let mut redis = svc(ServiceKind::Redis, "r1", 6379); + redis.extra_args = vec! ["--requirepass".into(), "secret".into()]; + let args = portable_args(&redis, &data); + assert!(args.contains(&"--requirepass".to_string())); + assert!(args.contains(&"secret".to_string())); + + // FTP 服务端参数全部来自 extra_args + let mut ftp = svc(ServiceKind::Ftp, "f1", 21); + ftp.extra_args = vec!["-A".into(), "-j".into()]; + assert_eq!(portable_args(&ftp, &data), vec!["-A", "-j"]); + } + + #[test] + fn 默认端口() { + assert_eq!(ServiceKind::Mysql.default_port(), 3306); + assert_eq!(ServiceKind::Mariadb.default_port(), 3306); + assert_eq!(ServiceKind::Postgresql.default_port(), 5432); + assert_eq!(ServiceKind::Redis.default_port(), 6379); + assert_eq!(ServiceKind::Ftp.default_port(), 21); + } + + #[tokio::test] + async fn 未注册服务启停报错() { + let dir = std::env::temp_dir().join("runphp-svc-missing-test"); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + let cfg = AppConfig { + data_dir: dir.clone(), + ..Default::default() + }; + let mgr = ServiceManager::new(cfg); + assert!(mgr.start("nope").await.is_err()); + assert!(mgr.stop("nope").await.is_err()); + assert!(mgr.status("nope").await.is_err()); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/runphp-core/src/site.rs b/crates/runphp-core/src/site.rs index 90f4ffce..456623f1 100644 --- a/crates/runphp-core/src/site.rs +++ b/crates/runphp-core/src/site.rs @@ -16,6 +16,15 @@ pub struct WorkerConfig { pub num: u32, } +/// 站点关联的受管数据库(服务端管理产生的库)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SiteDbBinding { + /// 受管服务 id(services.json 中的条目)。 + pub service_id: String, + /// 数据库名。 + pub database: String, +} + /// 单个站点配置。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Site { @@ -35,6 +44,9 @@ pub struct Site { pub worker: Option, /// PHP ini 覆盖指令,每行一条如 `memory_limit = 256M`。 pub php_ini: Vec, + /// 关联的受管数据库(向后兼容缺省为空)。 + #[serde(default)] + pub databases: Vec, /// 创建时间(RFC3339)。 pub created_at: String, /// 更新时间(RFC3339)。 @@ -54,6 +66,7 @@ impl Site { https: false, worker: None, php_ini: Vec::new(), + databases: Vec::new(), created_at: now.clone(), updated_at: now, } @@ -152,4 +165,27 @@ mod tests { assert!(removed.is_some()); assert!(reg.sites.is_empty()); } + + #[test] + fn 旧版站点json缺省databases字段兼容() { + // 旧版 sites.json 没有 databases 字段,反序列化应得到空列表 + let raw = r#"{ + "id": "s1", "name": "旧站", "domains": ["old.test"], "port": 0, + "root": "/tmp/old", "https": false, "worker": null, "php_ini": [], + "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" + }"#; + let site: Site = serde_json::from_str(raw).unwrap(); + assert!(site.databases.is_empty()); + + // 新版带绑定字段可正常往返 + let mut s = Site::new("新站".into(), vec!["new.test".into()], PathBuf::from("/tmp/new")); + s.databases.push(SiteDbBinding { + service_id: "svc-1".into(), + database: "shop".into(), + }); + let json = serde_json::to_string(&s).unwrap(); + let back: Site = serde_json::from_str(&json).unwrap(); + assert_eq!(back.databases.len(), 1); + assert_eq!(back.databases[0].database, "shop"); + } } diff --git "a/docs/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/docs/\346\236\266\346\236\204\350\256\276\350\256\241.md" index 3bf01c00..ec6ac411 100644 --- "a/docs/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/docs/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -60,16 +60,19 @@ | `config.rs` | 应用配置加载 / 保存,站点注册表持久化 | | `runtime.rs` | FrankenPHP 二进制下载(GitHub Releases)、可安装版本列表拉取、本地已有二进制导入(自动解析版本)、多版本管理、Windows zip 解压、可执行位设置 | | `caddy.rs` | Caddyfile 模板生成、子进程启停、admin API 状态查询、`reload` 热重载、日志读取 | -| `site.rs` | 站点模型(域名 / HTTPS / Worker / ini 覆盖)、SiteRegistry CRUD、域名校验 | +| `site.rs` | 站点模型(域名 / HTTPS / Worker / ini 覆盖 / 关联数据库绑定)、SiteRegistry CRUD、域名校验 | | `hosts.rs` | 系统 hosts 解析、受管区块标记读写、自动备份、权限检测与提权命令生成 | -| `ftp.rs` | FTP/FTPS/SFTP 连接管理(suppaftp + russh)、档案 CRUD、上传/下载/目录递归上传、限定根目录范围 | -| `error.rs` | 统一错误类型(thiserror 派生) | +| `services.rs` | 通用服务托管抽象:数据库引擎与 FTP 的注册表(services.json)、便携子进程启停(PID + 日志 + 端口探测)、takeover 模式经 `sc` / `systemctl` 启停 | +| `db/service.rs` | 数据库服务端管理:引擎检测(PATH / 安装目录 / 系统服务 / 端口)、接管注册、便携导入 bootstrap(`--initialize-insecure` / `initdb`)、在线下载预置、建库建用户改密(root 凭据连 127.0.0.1) | | `db/sqlite.rs` | rusqlite bundled 引擎,数据库文件管理、表结构浏览、SQL 执行器 | -| `db/remote.rs` | MySQL / MariaDB / PostgreSQL 连接档案管理 + 表浏览 + SQL 执行;MongoDB / Redis / Qdrant 仅连接测试 | -| `db/tunnel.rs` | SSH 隧道:通过 russh 端口转发安全连接远程数据库 | -| `db/libsql.rs` | libSQL 管理(本地文件/远程连接/嵌入式副本),复用 TableInfo/QueryResult | -| `adminer.rs` | Adminer 单文件下载 + 管理页面 URL 生成 | -| `detect.rs` | 本地环境检测:扫描 PATH 与常见安装位置中的 FrankenPHP 二进制、探测数据库服务端口 | +| `db/remote.rs` | MySQL / MariaDB / PostgreSQL 连接档案管理 + 表浏览 + SQL 执行;MongoDB / Redis / Qdrant 连接测试与 Redis RESP 协议 | +| `db/tunnel.rs` | SSH 隧道:russh 端口转发连接远程数据库 | +| `db/libsql.rs` | libSQL 管理(本地文件 / 远程连接 / 嵌入式副本),复用 TableInfo / QueryResult | +| `ftpd.rs` | FTP 服务端管理:双后端(Linux 对接 Pure-FTPd 子进程 + pure-pw 同步;Windows 及无 pure-ftpd 环境内嵌 libunftp + unftp-sbe-fs,每用户 chroot)、虚拟用户注册表(ftp_users.json)、守护进程自孵化 | +| `adminer.rs` | Adminer 单文件下载与管理页 URL 生成 | +| `detect.rs` | 本地环境检测:扫描 PATH 中的 FrankenPHP 二进制、探测常见数据库服务端口 | +| `ftp.rs` | FTP/FTPS/SFTP 客户端:连接档案管理、上传下载、目录同步 | +| `error.rs` | 统一错误类型(thiserror 派生) | | `fs.rs` | 文件系统浏览:为 UI 目录选择器提供目录列举(Windows 盘符 / Unix 根、子目录列表、上级导航) | | `system.rs` | sysinfo 收集主机信息:CPU 架构 / 内存 / 硬盘 / 系统版本,供仪表盘底部状态栏 | @@ -77,7 +80,7 @@ | 文件 | 职责 | |---|---| -| `main.rs` | clap 命令行解析与子命令分发(runtime / site / hosts / ftp / run / stop / reload / status / logs / panel / service) | +| `main.rs` | clap 命令行解析与子命令分发(runtime / site / hosts / ftp / db-service / ftpd / run / stop / reload / status / logs / panel / service) | | `panel.rs` | axum Web 面板(嵌入式前端 dist + REST API + token 鉴权) | ### 2.3 src-tauri(桌面壳) @@ -109,9 +112,15 @@ FrankenPHP 是 Go 编译的 Caddy + PHP 单二进制。Rust 无法直接嵌入 G 桌面端(Tauri WebView)和 Web 面板使用相同的 Vue 构建产物。`src/api/index.ts` 适配层在运行时检测 `window.__TAURI_INTERNALS__` 是否注入,自动切换 `invoke` / `fetch`,无需维护两套前端。 -### 3.3 为什么不捆绑 MySQL / MariaDB / PostgreSQL / MongoDB / Redis / Qdrant 服务器? +### 3.3 数据库与 FTP 服务器:检测接管 + 便携托管,而非打包分发 + +不把 MySQL / PostgreSQL 等数据库服务器打进安装包(控制体量、避免升级滞后),而是提供三条通道(与运行时管理一致): + +1. **检测接管(takeover)**:扫描 PATH、常见安装目录、Windows 服务名(MySQL80 等)/ systemd 单元与默认端口,把已有实例登记为托管服务;启停经 `sc` / `systemctl`(需管理员权限,失败时明确报错并引导便携托管)。 +2. **便携导入(portable)**:仅注册本地二进制引用,不复制文件;首次启动按引擎 bootstrap(MySQL `--initialize-insecure`、MariaDB `mysql_install_db`、PostgreSQL `initdb --auth=trust`、Redis 免初始化),数据落在 `数据目录/services//`。 +3. **在线下载**:预置 MariaDB / PostgreSQL / Redis(Windows 版)下载源,流式下载解压后转便携托管。 -控制软件体量。用户通常已有数据库实例,RunPHP 只做连接管理(连接档案 + SQL 编辑器 + 连接测试)。SQLite 作为内置引擎满足零依赖需求。MySQL / PostgreSQL 支持完整的表浏览与 SQL 执行;MongoDB 仅做 TCP 端口探测(避免引入重量级官方 crate);Redis 走原生 RESP 协议;Qdrant 走 HTTP REST API。 +FTP 服务端采用**双后端**策略:Linux 优先对接系统 Pure-FTPd(`pure-ftpd -A -j -l puredb` 子进程 + `pure-pw` 用户同步,JSON 为准源);Windows 及未检测到 pure-ftpd 的环境使用内嵌 Rust FTP 服务端(libunftp + unftp-sbe-fs),每用户 chroot 到 `数据目录/ftp/<用户名>`,CLI 下以自孵化守护进程运行。纯客户端的连接管理(连接档案 + SQL 编辑器 + FTP 上传下载)保留为页内二级 Tab。SQLite 仍作为零依赖内置引擎;MongoDB 仅端口探测、Redis 走原生 RESP 协议、Qdrant 走 HTTP REST API。 ### 3.4 hosts 无权限不硬写 @@ -161,6 +170,6 @@ hosts 文件中以成对注释标记 RunPHP 托管的区块,区块外内容只 | 层 | 策略 | |---|---| -| **单元测试** | core 库每个模块附带测试(配置往返、站点校验、hosts 解析、SQLite CRUD/多语句/路径穿越防护、运行时路径、FTP 管理),共 37 个 | +| **单元测试** | core 库每个模块附带测试(配置往返、站点校验、hosts 解析、SQLite CRUD/多语句/路径穿越防护、运行时路径),共 13 个 | | **集成验收** | 每个里程碑用真实操作验证(下载运行时 → 建站 → curl phpinfo → 热重载 → 启停) | | **前端类型检查** | `vue-tsc --noEmit` 保证类型安全 | diff --git "a/docs/\347\224\250\346\210\267\346\211\213\345\206\214.md" "b/docs/\347\224\250\346\210\267\346\211\213\345\206\214.md" index b6ba596e..5ff7ce75 100644 --- "a/docs/\347\224\250\346\210\267\346\211\213\345\206\214.md" +++ "b/docs/\347\224\250\346\210\267\346\211\213\345\206\214.md" @@ -94,6 +94,33 @@ chmod +x runphp ### 2.5 数据库管理 +数据库页提供两个 Tab:**「服务」(默认,服务端管理)** 与 **「连接」(远程客户端)**。 + +#### 数据库服务托管(服务端管理) + +把本机的 MySQL / MariaDB / PostgreSQL / Redis 纳入 RunPHP 托管,支持启停、建库建用户、改密与日志查看: + +1. 进入 **「数据库」→「服务」** Tab,点击 **「检测」** 扫描本机已有服务(PATH、安装目录、系统服务、默认端口) +2. 检测结果可一键 **「接管」**(登记为托管服务,启停走系统服务);或点击 **「导入」** 登记本地二进制为便携实例(不复制文件,数据落在 RunPHP 数据目录);也可从 **「下载」** 预置源在线安装(MariaDB / PostgreSQL / Redis Windows 版) +3. 首次启动便携实例时自动初始化数据目录(MySQL `--initialize-insecure` / PostgreSQL `initdb` / Redis 免初始化) +4. 服务启动后可展开管理:**建库 / 删库、建用户 / 删用户 / 改密、root 改密**(Redis 为 `requirepass` 配置) +5. 点击 **「在连接页打开」** 自动注册连接档案,复用表浏览与 SQL 执行器 +6. 站点编辑弹窗中可将站点与服务的数据库 **关联绑定** + +> Windows 下「接管」系统服务启停需管理员权限,失败时会明确提示;建议无权限时改用便携导入。设置页「环境管理」提供各服务端口 / 自启 / 启停的简单配置卡片。 + +CLI 等价命令: + +```bash +runphp db-service list # 列出托管服务 +runphp db-service detect # 检测本机可接管/导入的服务 +runphp db-service start # 启动 +runphp db-service stop # 停止 +runphp db-service dbs # 列出数据库 +runphp db-service create-db --name myapp +runphp db-service create-user --username u1 --password p1 --database myapp +``` + #### SQLite(内置) 1. 进入 **「数据库」** 页面 @@ -103,6 +130,8 @@ chmod +x runphp #### MySQL / MariaDB / PostgreSQL / MongoDB / Redis / Qdrant(远程连接) +切换到 **「连接」** Tab: + 1. 点击 **「+ 添加连接」** 2. 选择类型(MySQL / PostgreSQL / MongoDB / Redis / Qdrant),填写连接信息(主机 / 端口 / 用户名 / 密码 / 数据库) 3. 点击 **「测试」** 验证连接 → **「保存」** @@ -129,7 +158,34 @@ chmod +x runphp --- -## 2.7 FTP 文件管理 +## 2.7 FTP 管理 + +FTP 页提供两个 Tab:**「服务端」(默认,本机 FTP 服务管理)** 与 **「远程文件」(远程客户端)**。 + +### FTP 服务端 + +在本机提供 FTP 服务并管理虚拟用户(Linux 优先对接系统 Pure-FTPd;Windows 及未检测到 pure-ftpd 的环境自动使用内嵌 FTP 服务端): + +1. 进入 **「FTP」→「服务端」** Tab,点击 **「启动」**(默认端口 21,被动端口区间 50000–50010,均可在设置页或本页配置) +2. **虚拟用户管理**:添加用户(用户名 / 密码 / 主目录,可关联站点),每个用户 chroot 到自己的主目录,默认 `数据目录/ftp/<用户名>` +3. 勾选 **「随面板/应用自启」** 后服务随 RunPHP 启动 +4. 客户端即可用 `ftp://127.0.0.1` + 虚拟用户凭据连接上传下载 + +CLI 等价命令: + +```bash +runphp ftpd status # 查看服务状态与后端 +runphp ftpd start # 以守护进程方式启动 +runphp ftpd run # 前台运行(配合 systemd / 服务托管) +runphp ftpd stop # 停止 +runphp ftpd user-list # 列出虚拟用户 +runphp ftpd user-add u1 --password p1 [--home D:\ftp\u1] [--site myapp] +runphp ftpd user-rm +``` + +> Linux 下以非 root 运行且需绑定 21 端口时,可改用高位端口或以 root / `setcap` 授权。 + +### 远程文件(客户端) 支持 FTP / SFTP / FTPS 三种协议的远程文件管理,桌面端和 CLI 均可使用。 @@ -195,8 +251,14 @@ runphp logs --lines 300 # 设置默认运行时版本 runphp runtime default 1.12.7 + +# 数据库服务与 FTP 服务端管理(详见 2.5 / 2.7 的 CLI 命令) +runphp db-service detect +runphp ftpd start ``` +> 托管服务与 FTP 服务端若开启自启,会随 `runphp run` / `runphp panel` 一并启动。 + ### 3.2 Web 管理面板 ```bash @@ -234,7 +296,7 @@ FrankenPHP 默认监听 80 和 443 端口。请确保这些端口未被其他程 ### Q2: HTTPS 访问提示证书不安全? -首次使用 HTTPS 时,Caddy 会安装本地 CA 证书。如未自动安装,在「设置」页面的 Hosts 管理区块查看提权命令并手动执行。 +首次使用 HTTPS 时,Caddy 会安装本地 CA 证书。如未自动安装,在 Hosts 页面查看提权命令并手动执行。 ### Q3: hosts 同步提示无权限? diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a3506449..479ca6ef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,12 +5,18 @@ use runphp_core::{ caddy, db::libsql::{LibsqlManager, LibsqlProfile}, db::remote::{ConnectionProfile, RemoteDbManager, RemoteQueryResult, RemoteTableInfo}, + db::service::{ + DbServiceCandidate, DbServiceManager, DownloadPreset, ServiceDbUser, ServiceInput, + }, db::sqlite::{DatabaseFile, QueryResult, SqliteManager, TableInfo}, detect::{self, LocalDetection}, ftp::{FtpEntry, FtpManager, FtpProfile}, + ftpd::{FtpdConfig, FtpdManager, FtpUser}, fs, hosts::{entries_from_sites, HostEntry, HostsManager}, - runtime::{self, ImportResult}, system, AppConfig, RuntimeManager, Site, + runtime::{self, ImportResult}, system, + services::{ManagedService, ServiceKind, ServiceStatus}, + AppConfig, RuntimeManager, Site, }; use tauri::Emitter; @@ -423,9 +429,184 @@ async fn db_libsql_execute(profile: LibsqlProfile, sql: String) -> Result Result, String> { + db_service_mgr().list().map_err(|e| e.to_string()) +} + +/// 数据库服务管理器实例。 +fn db_service_mgr() -> DbServiceManager { + DbServiceManager::new(cfg()) +} + +/// 检测本机数据库服务候选。 +#[tauri::command] +async fn db_service_detect() -> Result, String> { + db_service_mgr().detect().await.map_err(|e| e.to_string()) +} + +/// 注册服务(提供 binary_path 为便携托管,否则为接管)。 +#[tauri::command] +fn db_service_register(input: ServiceInput) -> Result { + db_service_mgr().register(input).map_err(|e| e.to_string()) +} + +/// 更新服务定义(端口/自启/凭据)。 +#[tauri::command] +fn db_service_update(service: ManagedService) -> Result<(), String> { + db_service_mgr().update(service).map_err(|e| e.to_string()) +} + +/// 删除服务注册。 +#[tauri::command] +async fn db_service_remove(id: String) -> Result<(), String> { + db_service_mgr().remove(&id).await.map_err(|e| e.to_string()) +} + +/// 启动服务(便携服务自动初始化数据目录,等待端口就绪)。 +#[tauri::command] +async fn db_service_start(id: String) -> Result<(), String> { + db_service_mgr().start(&id).await.map_err(|e| e.to_string()) +} + +/// 停止服务。 +#[tauri::command] +async fn db_service_stop(id: String) -> Result<(), String> { + db_service_mgr().stop(&id).await.map_err(|e| e.to_string()) +} + +/// 查询服务状态。 +#[tauri::command] +async fn db_service_status(id: String) -> Result { + db_service_mgr().status(&id).await.map_err(|e| e.to_string()) +} + +/// 读取服务日志末尾若干行。 +#[tauri::command] +fn db_service_log(id: String, lines: Option) -> Result { + db_service_mgr() + .read_log(&id, lines.unwrap_or(100)) + .map_err(|e| e.to_string()) +} + +/// 便携包下载预设清单。 +#[tauri::command] +fn db_service_download_presets() -> Vec { + runphp_core::db::service::download_presets() +} + +/// 下载便携包并注册服务,进度通过事件推送。 +#[tauri::command] +async fn db_service_download( + kind: String, + name: String, + url: String, + app: tauri::AppHandle, +) -> Result { + let kind: ServiceKind = serde_json::from_value(serde_json::json!(kind)) + .map_err(|e| format!("未知引擎类型: {e}"))?; + let progress_app = std::sync::Arc::new(app); + db_service_mgr() + .download_install( + kind, + &name, + &url, + Some(Box::new(move |d, t| { + let _ = progress_app.emit("db-service-download-progress", (d, t)); + })), + ) + .await + .map_err(|e| e.to_string()) +} + +/// 把受管服务注册为连接档案(供连接页浏览/SQL/Adminer 复用)。 +#[tauri::command] +fn db_service_register_connection(id: String) -> Result { + db_service_mgr() + .register_connection(&id) + .map_err(|e| e.to_string()) +} + +/// 列出服务中的用户数据库。 +#[tauri::command] +async fn db_service_databases(id: String) -> Result, String> { + db_service_mgr().list_databases(&id).await.map_err(|e| e.to_string()) +} + +/// 创建数据库。 +#[tauri::command] +async fn db_service_database_create(id: String, name: String) -> Result<(), String> { + db_service_mgr() + .create_database(&id, &name) + .await + .map_err(|e| e.to_string()) +} + +/// 删除数据库。 +#[tauri::command] +async fn db_service_database_drop(id: String, name: String) -> Result<(), String> { + db_service_mgr() + .drop_database(&id, &name) + .await + .map_err(|e| e.to_string()) +} + +/// 列出服务中的用户账号。 +#[tauri::command] +async fn db_service_users(id: String) -> Result, String> { + db_service_mgr().list_users(&id).await.map_err(|e| e.to_string()) +} + +/// 创建账号(可同时授予某库全部权限)。 +#[tauri::command] +async fn db_service_user_create( + id: String, + username: String, + password: String, + database: Option, +) -> Result<(), String> { + db_service_mgr() + .create_user(&id, &username, &password, database.as_deref()) + .await + .map_err(|e| e.to_string()) +} + +/// 删除账号。 +#[tauri::command] +async fn db_service_user_drop(id: String, username: String, host: String) -> Result<(), String> { + db_service_mgr() + .drop_user(&id, &username, &host) + .await + .map_err(|e| e.to_string()) +} + +/// 修改账号密码。 +#[tauri::command] +async fn db_service_user_password( + id: String, + username: String, + host: String, + password: String, +) -> Result<(), String> { + db_service_mgr() + .set_user_password(&id, &username, &host, &password) + .await + .map_err(|e| e.to_string()) +} -/// 列出 FTP 连接档案。 +/// 修改服务 root 密码(Redis 为 requirepass)。 +#[tauri::command] +async fn db_service_root_password(id: String, password: String) -> Result<(), String> { + db_service_mgr() + .set_root_password(&id, &password) + .await + .map_err(|e| e.to_string()) +} + +// ---- FTP 管理 ---- #[tauri::command] fn ftp_list() -> Result, String> { FtpManager::new(&cfg().data_dir) @@ -577,6 +758,77 @@ fn ftp_update(profile: FtpProfile) -> Result<(), String> { .map_err(|e| e.to_string()) } +// ---- FTP 服务端管理 ---- + +/// FTP 服务端管理器实例。 +fn ftpd_mgr() -> FtpdManager { + FtpdManager::new(cfg()) +} + +/// FTP 服务端状态(控制端口探测)。 +#[tauri::command] +async fn ftp_server_status() -> bool { + ftpd_mgr().status().await +} + +/// 启动 FTP 服务端,返回实际使用的后端描述。 +#[tauri::command] +async fn ftp_server_start() -> Result { + ftpd_mgr() + .start() + .await + .map(|s| s.to_string()) + .map_err(|e| e.to_string()) +} + +/// 停止 FTP 服务端。 +#[tauri::command] +async fn ftp_server_stop() -> Result<(), String> { + ftpd_mgr().stop().await.map_err(|e| e.to_string()) +} + +/// 读取 FTP 服务端配置。 +#[tauri::command] +fn ftp_server_config() -> FtpdConfig { + ftpd_mgr().config() +} + +/// 更新 FTP 服务端配置(运行中服务需重启后生效)。 +#[tauri::command] +fn ftp_server_update_config(config: FtpdConfig) -> Result<(), String> { + ftpd_mgr().save_config(&config).map_err(|e| e.to_string()) +} + +/// 当前后端名称(内嵌 libunftp / Pure-FTPd)。 +#[tauri::command] +fn ftp_server_backend() -> String { + ftpd_mgr().backend_name().to_string() +} + +/// 列出 FTP 虚拟用户。 +#[tauri::command] +fn ftp_user_list() -> Result, String> { + ftpd_mgr().list_users().map_err(|e| e.to_string()) +} + +/// 新增 FTP 虚拟用户。 +#[tauri::command] +async fn ftp_user_add(user: FtpUser) -> Result<(), String> { + ftpd_mgr().add_user(user).await.map_err(|e| e.to_string()) +} + +/// 更新 FTP 虚拟用户。 +#[tauri::command] +async fn ftp_user_update(user: FtpUser) -> Result<(), String> { + ftpd_mgr().update_user(user).await.map_err(|e| e.to_string()) +} + +/// 删除 FTP 虚拟用户。 +#[tauri::command] +async fn ftp_user_remove(id: String) -> Result<(), String> { + ftpd_mgr().remove_user(&id).await.map_err(|e| e.to_string()) +} + // 给前端的简化结构(路径转字符串)。 #[derive(serde::Serialize)] struct RuntimeInfo { @@ -590,6 +842,14 @@ struct RuntimeInfo { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .setup(|_app| { + // 自启动标记为自启的服务(数据库 + FTP),失败仅记日志 + let cfg = cfg(); + tauri::async_runtime::spawn(async move { + runphp_core::autostart_services(&cfg).await; + }); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ data_dir, runtime_list, @@ -635,6 +895,26 @@ pub fn run() { db_libsql_tables, db_libsql_query_table, db_libsql_execute, + db_service_list, + db_service_detect, + db_service_register, + db_service_update, + db_service_remove, + db_service_start, + db_service_stop, + db_service_status, + db_service_log, + db_service_download_presets, + db_service_download, + db_service_register_connection, + db_service_databases, + db_service_database_create, + db_service_database_drop, + db_service_users, + db_service_user_create, + db_service_user_drop, + db_service_user_password, + db_service_root_password, ftp_list, ftp_add, ftp_remove, @@ -647,6 +927,16 @@ pub fn run() { ftp_mkdir, ftp_rename, ftp_update, + ftp_server_status, + ftp_server_start, + ftp_server_stop, + ftp_server_config, + ftp_server_update_config, + ftp_server_backend, + ftp_user_list, + ftp_user_add, + ftp_user_update, + ftp_user_remove, ]) .run(tauri::generate_context!()) .expect("运行 Tauri 应用时出错"); diff --git a/src/api/index.ts b/src/api/index.ts index b7c39393..1debdb8f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -59,6 +59,10 @@ export interface WorkerConfig { num: number; } +export interface SiteDbBinding { + service_id: string; + database: string; +} export interface Site { id: string; name: string; @@ -68,6 +72,8 @@ export interface Site { https: boolean; worker: WorkerConfig | null; php_ini: string[]; + /** 关联的受管数据库(旧数据可能缺省) */ + databases?: SiteDbBinding[]; created_at: string; updated_at: string; } @@ -240,6 +246,124 @@ export const dbLibsqlQueryTable = ( export const dbLibsqlExecute = (profile: LibsqlProfile, sql: string) => call("db_libsql_execute", { profile, sql }); +// 数据库服务端管理(接管本机服务 / 便携托管) +export type ServiceKind = "mysql" | "mariadb" | "postgresql" | "redis" | "ftp"; +export type ServiceSource = "takeover" | "portable"; +export interface ManagedService { + id: string; + kind: ServiceKind; + name: string; + source: ServiceSource; + binary_path: string | null; + port: number; + autostart: boolean; + root_username: string; + root_password: string; + os_service_name: string | null; + extra_args: string[]; + created_at: string; +} +export interface ServiceInput { + kind: ServiceKind; + name: string; + port: number; + binary_path?: string | null; + os_service_name?: string | null; + root_username?: string; + root_password?: string; + autostart?: boolean; + extra_args?: string[]; +} +export interface ServiceStatus { + running: boolean; + pid: number | null; +} +export interface DbServiceCandidate { + kind: ServiceKind; + name: string; + port: number; + running: boolean; + binary_path: string | null; + os_service_name: string | null; +} +export interface ServiceDbUser { + username: string; + host: string; +} +export interface DownloadPreset { + kind: ServiceKind; + label: string; + url: string; + size_hint: string; +} + +export const dbServiceList = () => call("db_service_list"); +export const dbServiceDetect = () => + call("db_service_detect"); +export const dbServiceRegister = (input: ServiceInput) => + call("db_service_register", { input }); +export const dbServiceUpdate = (service: ManagedService) => + call("db_service_update", { service }); +export const dbServiceRemove = (id: string) => + call("db_service_remove", { id }); +export const dbServiceStart = (id: string) => + call("db_service_start", { id }); +export const dbServiceStop = (id: string) => call("db_service_stop", { id }); +export const dbServiceStatus = (id: string) => + call("db_service_status", { id }); +export const dbServiceLog = (id: string, lines?: number) => + call("db_service_log", { id, lines: lines ?? 100 }); +export const dbServiceDownloadPresets = () => + call("db_service_download_presets"); +export const dbServiceDownload = (kind: ServiceKind, name: string, url: string) => + call("db_service_download", { kind, name, url }); +export const dbServiceRegisterConnection = (id: string) => + call("db_service_register_connection", { id }); +export const dbServiceDatabases = (id: string) => + call("db_service_databases", { id }); +export const dbServiceDatabaseCreate = (id: string, name: string) => + call("db_service_database_create", { id, name }); +export const dbServiceDatabaseDrop = (id: string, name: string) => + call("db_service_database_drop", { id, name }); +export const dbServiceUsers = (id: string) => + call("db_service_users", { id }); +export const dbServiceUserCreate = ( + id: string, + username: string, + password: string, + database?: string, +) => + call("db_service_user_create", { + id, + username, + password, + database: database ?? null, + }); +export const dbServiceUserDrop = (id: string, username: string, host: string) => + call("db_service_user_drop", { id, username, host }); +export const dbServiceUserPassword = ( + id: string, + username: string, + host: string, + password: string, +) => call("db_service_user_password", { id, username, host, password }); +export const dbServiceRootPassword = (id: string, password: string) => + call("db_service_root_password", { id, password }); + +/** 监听数据库服务便携包下载进度(桌面端 Tauri 事件,面板端 no-op)。 */ +export async function onDbServiceDownloadProgress( + cb: (p: { transferred: number; total: number }) => void, +): Promise<() => void> { + if (isDesktop) { + const { listen } = await import("@tauri-apps/api/event"); + return listen<[number, number]>("db-service-download-progress", (e) => { + const [transferred, total] = e.payload; + cb({ transferred, total }); + }); + } + return () => {}; +} + // 本地环境检测(类型定义见上方 DetectedBinary / DetectedService / LocalDetection / ImportResult) export const runtimeDetectLocal = () => call("runtime_detect_local"); @@ -358,3 +482,37 @@ export async function onFtpProgress( } return () => {}; } + +// FTP 服务端管理(虚拟用户 + 双后端:内嵌 libunftp / Pure-FTPd) +export interface FtpServerUser { + id: string; + username: string; + password: string; + /** 自定义根目录(null 表示默认 数据目录/ftp/<用户名>) */ + home_dir: string | null; + /** 关联站点 id */ + linked_site: string | null; + enabled: boolean; + created_at: string; +} +export interface FtpdConfig { + port: number; + passive_from: number; + passive_to: number; + autostart: boolean; +} + +export const ftpServerStatus = () => call("ftp_server_status"); +export const ftpServerStart = () => call("ftp_server_start"); +export const ftpServerStop = () => call("ftp_server_stop"); +export const ftpServerConfig = () => call("ftp_server_config"); +export const ftpServerUpdateConfig = (config: FtpdConfig) => + call("ftp_server_update_config", { config }); +export const ftpServerBackend = () => call("ftp_server_backend"); +export const ftpUserList = () => call("ftp_user_list"); +export const ftpUserAdd = (user: FtpServerUser) => + call("ftp_user_add", { user }); +export const ftpUserUpdate = (user: FtpServerUser) => + call("ftp_user_update", { user }); +export const ftpUserRemove = (id: string) => + call("ftp_user_remove", { id }); diff --git a/src/components/DbServicePanel.vue b/src/components/DbServicePanel.vue new file mode 100644 index 00000000..09e94a76 --- /dev/null +++ b/src/components/DbServicePanel.vue @@ -0,0 +1,870 @@ + + + + + diff --git a/src/components/FtpServerPanel.vue b/src/components/FtpServerPanel.vue new file mode 100644 index 00000000..9acbfeaa --- /dev/null +++ b/src/components/FtpServerPanel.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/src/views/DatabasesView.vue b/src/views/DatabasesView.vue index a643b13b..ebaa305c 100644 --- a/src/views/DatabasesView.vue +++ b/src/views/DatabasesView.vue @@ -2,6 +2,7 @@ import { onMounted, ref, computed, h } from "vue"; import { useMessage, useDialog, NButton, NTag } from "naive-ui"; import { useRoute, useRouter } from "vue-router"; +import DbServicePanel from "../components/DbServicePanel.vue"; import { dbSqliteList, dbSqliteCreate, @@ -39,6 +40,15 @@ const dialog = useDialog(); const route = useRoute(); const router = useRouter(); +/** 页面主 Tab:服务端管理为默认,连接管理保留为二级入口 */ +const activeTab = ref("service"); + +/** 子面板请求跳转连接页(服务已注册为连接档案) */ +function openConnectionTab() { + activeTab.value = "connection"; + void refreshAll(); +} + // ---- 统一选中模型 ---- interface ActiveDb { kind: "sqlite" | "remote" | "libsql"; @@ -551,6 +561,8 @@ onMounted(async () => { await refreshAll(); const q = route.query; if (q.add_db) { + // 连接档案预填入口:切到连接页再打开添加框 + activeTab.value = "connection"; const driver = String(q.add_db) as DbDriver; if (driver in DEFAULT_PORTS) { addType.value = driver; @@ -568,7 +580,12 @@ onMounted(async () => { - + + + diff --git a/src/views/FtpView.vue b/src/views/FtpView.vue index 724c354b..cc2d940a 100644 --- a/src/views/FtpView.vue +++ b/src/views/FtpView.vue @@ -2,6 +2,7 @@ import { onMounted, ref, computed, h, onUnmounted } from "vue"; import { useMessage, useDialog, NButton } from "naive-ui"; import DirectoryPicker from "../components/DirectoryPicker.vue"; +import FtpServerPanel from "../components/FtpServerPanel.vue"; import { ftpList, ftpAdd, @@ -24,6 +25,9 @@ import { const message = useMessage(); const dialog = useDialog(); +/** 页面主 Tab:服务端管理为默认,远程文件客户端保留为二级入口 */ +const activeTab = ref("server"); + const profiles = ref([]); const activeProfile = ref(null); const currentPath = ref("/"); @@ -471,7 +475,12 @@ async function doDownload(remotePath: string, localPath: string) { diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index ecc3e9a0..72bd4da5 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -1,7 +1,8 @@