├── .gitattributes ├── .gitignore ├── nyanpasu_ipc ├── src │ ├── api │ │ ├── ws │ │ │ ├── mod.rs │ │ │ └── events.rs │ │ ├── network │ │ │ ├── mod.rs │ │ │ └── set_dns.rs │ │ ├── core │ │ │ ├── mod.rs │ │ │ ├── stop.rs │ │ │ ├── restart.rs │ │ │ └── start.rs │ │ ├── log.rs │ │ ├── status.rs │ │ └── mod.rs │ ├── types │ │ ├── mod.rs │ │ └── status.rs │ ├── lib.rs │ ├── utils │ │ ├── mod.rs │ │ ├── os.rs │ │ └── acl.rs │ ├── client │ │ ├── wrapper.rs │ │ ├── mod.rs │ │ └── shortcuts.rs │ └── server │ │ └── mod.rs ├── Cargo.toml ├── scripts │ └── validate_acl.ps1 └── examples │ └── get_pipe_sddl.rs ├── rust-toolchain.toml ├── nyanpasu_service ├── src │ ├── utils │ │ ├── os │ │ │ ├── mod.rs │ │ │ └── user.rs │ │ ├── dirs.rs │ │ ├── mod.rs │ │ └── acl.rs │ ├── server │ │ ├── routing │ │ │ ├── core │ │ │ │ ├── mod.rs │ │ │ │ ├── stop.rs │ │ │ │ ├── restart.rs │ │ │ │ └── start.rs │ │ │ ├── mod.rs │ │ │ ├── logs.rs │ │ │ ├── status.rs │ │ │ ├── network.rs │ │ │ └── ws.rs │ │ ├── consts.rs │ │ ├── mod.rs │ │ ├── logger.rs │ │ └── instance.rs │ ├── consts.rs │ ├── cmds │ │ ├── start.rs │ │ ├── restart.rs │ │ ├── uninstall.rs │ │ ├── update.rs │ │ ├── status.rs │ │ ├── stop.rs │ │ ├── rpc │ │ │ └── mod.rs │ │ ├── server.rs │ │ ├── install.rs │ │ └── mod.rs │ ├── main.rs │ ├── logging.rs │ └── win_service.rs ├── build.rs └── Cargo.toml ├── rustfmt.toml ├── .cargo └── config.toml ├── renovate.json ├── .editorconfig ├── .github ├── scripts │ ├── get-version.ts │ └── get-version.rs └── workflows │ ├── ci.yml │ ├── build.yml │ └── publish.yml ├── Cross.toml ├── README.md ├── Cargo.toml ├── cliff.toml └── CHANGELOGS.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .vscode/ 3 | .idea/ -------------------------------------------------------------------------------- /nyanpasu_ipc/src/api/ws/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod events; 2 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/api/network/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod set_dns; 2 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/types/mod.rs: -------------------------------------------------------------------------------- 1 | mod status; 2 | pub use status::*; 3 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/api/core/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod restart; 2 | pub mod start; 3 | pub mod stop; 4 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/api/core/stop.rs: -------------------------------------------------------------------------------- 1 | use crate::api::R; 2 | 3 | pub const CORE_STOP_ENDPOINT: &str = "/core/stop"; 4 | 5 | pub type CoreStopRes<'a> = R<'a, ()>; 6 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/api/core/restart.rs: -------------------------------------------------------------------------------- 1 | use crate::api::R; 2 | 3 | pub const CORE_RESTART_ENDPOINT: &str = "/core/restart"; 4 | 5 | pub type CoreRestartRes<'a> = R<'a, ()>; 6 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate derive_builder; 3 | 4 | pub mod api; 5 | #[cfg(feature = "client")] 6 | pub mod client; 7 | #[cfg(feature = "server")] 8 | pub mod server; 9 | pub mod types; 10 | pub mod utils; 11 | 12 | pub const SERVICE_PLACEHOLDER: &str = "nyanpasu_ipc"; 13 | -------------------------------------------------------------------------------- /nyanpasu_service/src/utils/os/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod user; 2 | 3 | pub fn register_ctrlc_handler() -> tokio::sync::mpsc::Receiver<()> { 4 | let (tx, rx) = tokio::sync::mpsc::channel(1); 5 | ctrlc::set_handler(move || { 6 | eprintln!("Ctrl-C received, stopping service..."); 7 | let _ = tx.try_send(()); 8 | }) 9 | .expect("Error setting Ctrl-C handler"); 10 | rx 11 | } 12 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 100 2 | hard_tabs = false 3 | tab_spaces = 4 4 | newline_style = "Unix" 5 | use_small_heuristics = "Default" 6 | reorder_imports = true 7 | reorder_modules = true 8 | remove_nested_parens = true 9 | edition = "2021" 10 | merge_derives = true 11 | use_try_shorthand = false 12 | use_field_init_shorthand = false 13 | force_explicit_abi = true 14 | imports_granularity = "Crate" 15 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/api/network/set_dns.rs: -------------------------------------------------------------------------------- 1 | use crate::api::R; 2 | use serde::{Deserialize, Serialize}; 3 | use std::{borrow::Cow, net::IpAddr}; 4 | 5 | pub const NETWORK_SET_DNS_ENDPOINT: &str = "/network/set_dns"; 6 | 7 | #[derive(Debug, Serialize, Deserialize, Clone)] 8 | pub struct NetworkSetDnsReq<'n> { 9 | pub dns_servers: Option>>, 10 | } 11 | 12 | pub type NetworkSetDnsRes<'a> = R<'a, ()>; 13 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | debug-run = "run --features debug" 3 | debug-build = "build --features debug" 4 | 5 | [build] 6 | rustflags = ["--cfg", "tokio_unstable", "--cfg", "tracing_unstable"] 7 | 8 | 9 | [target.'cfg(any(target_os = "windows", target_os = "linux"))'] 10 | rustflags = [ 11 | "--cfg", 12 | "tokio_unstable", 13 | "--cfg", 14 | "tracing_unstable", 15 | "-C", 16 | "target-feature=-crt-static", 17 | ] 18 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/api/core/start.rs: -------------------------------------------------------------------------------- 1 | use crate::api::R; 2 | use serde::{Deserialize, Serialize}; 3 | use std::{borrow::Cow, path::PathBuf}; 4 | 5 | pub const CORE_START_ENDPOINT: &str = "/core/start"; 6 | 7 | #[derive(Debug, Serialize, Deserialize, Clone)] 8 | pub struct CoreStartReq<'n> { 9 | pub core_type: Cow<'n, nyanpasu_utils::core::CoreType>, 10 | pub config_file: Cow<'n, PathBuf>, 11 | } 12 | 13 | pub type CoreStartRes<'a> = R<'a, ()>; 14 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/api/log.rs: -------------------------------------------------------------------------------- 1 | use crate::api::R; 2 | use serde::{Deserialize, Serialize}; 3 | use std::borrow::Cow; 4 | 5 | pub const LOGS_RETRIEVE_ENDPOINT: &str = "/logs/retrieve"; 6 | pub const LOGS_INSPECT_ENDPOINT: &str = "/logs/inspect"; 7 | 8 | // TODO: more health check fields 9 | #[derive(Debug, Serialize, Deserialize, Clone)] 10 | pub struct LogsResBody<'a> { 11 | pub logs: Vec>, 12 | } 13 | 14 | pub type LogsRes<'a> = R<'a, LogsResBody<'a>>; 15 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "default:automergeMinor", 6 | "default:prConcurrentLimit10", 7 | "default:prHourlyLimitNone", 8 | "default:preserveSemverRanges", 9 | "default:rebaseStalePrs", 10 | "group:monorepos" 11 | ], 12 | "packageRules": [ 13 | { 14 | "matchManagers": [ 15 | "cargo" 16 | ], 17 | "rangeStrategy": "update-lockfile", 18 | "platformAutomerge": false 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/routing/core/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::{Router, routing::post}; 2 | use nyanpasu_ipc::api::core::{ 3 | restart::CORE_RESTART_ENDPOINT, start::CORE_START_ENDPOINT, stop::CORE_STOP_ENDPOINT, 4 | }; 5 | 6 | use super::AppState; 7 | 8 | pub mod restart; 9 | pub mod start; 10 | pub mod stop; 11 | 12 | pub fn setup() -> Router { 13 | Router::new() 14 | .route(CORE_START_ENDPOINT, post(start::start)) 15 | .route(CORE_STOP_ENDPOINT, post(stop::stop)) 16 | .route(CORE_RESTART_ENDPOINT, post(restart::restart)) 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | 3 | # EditorConfig is awesome: https://editorconfig.org 4 | 5 | # top-most EditorConfig file 6 | root = true 7 | 8 | # Unix-style newlines with a newline ending every file 9 | [*] 10 | end_of_line = lf 11 | insert_final_newline = true 12 | 13 | # Matches multiple files with brace expansion notation 14 | # Set default charset 15 | [*.{rs,toml}] 16 | charset = utf-8 17 | 18 | [*.yml] 19 | indent_style = space 20 | indent_size = 2 21 | 22 | 23 | # 4 space indentation 24 | [*.rs] 25 | indent_style = space 26 | indent_size = 4 27 | 28 | # Tab indentation (no size specified) 29 | [Makefile] 30 | indent_style = tab 31 | 32 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/routing/core/stop.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use axum::{Json, extract::State, http::StatusCode}; 4 | use nyanpasu_ipc::api::{RBuilder, core::stop::CoreStopRes}; 5 | 6 | use crate::server::routing::AppState; 7 | 8 | pub async fn stop(State(state): State) -> (StatusCode, Json>) { 9 | let res = state.core_manager.stop().await; 10 | match res { 11 | Ok(_) => (StatusCode::OK, Json(RBuilder::success(()))), 12 | Err(e) => ( 13 | StatusCode::INTERNAL_SERVER_ERROR, 14 | Json(RBuilder::other_error(Cow::Owned(e.to_string()))), 15 | ), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/routing/core/restart.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use axum::{Json, extract::State, http::StatusCode}; 4 | use nyanpasu_ipc::api::{RBuilder, core::restart::CoreRestartRes}; 5 | 6 | use crate::server::routing::AppState; 7 | 8 | pub async fn restart(State(state): State) -> (StatusCode, Json>) { 9 | let res = state.core_manager.restart().await; 10 | match res { 11 | Ok(_) => (StatusCode::OK, Json(RBuilder::success(()))), 12 | Err(e) => ( 13 | StatusCode::INTERNAL_SERVER_ERROR, 14 | Json(RBuilder::other_error(Cow::Owned(e.to_string()))), 15 | ), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/consts.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, sync::OnceLock}; 2 | 3 | pub struct RuntimeInfos { 4 | pub service_data_dir: PathBuf, 5 | pub service_config_dir: PathBuf, 6 | pub nyanpasu_config_dir: PathBuf, 7 | pub nyanpasu_data_dir: PathBuf, 8 | pub nyanpasu_app_dir: PathBuf, 9 | } 10 | static INSTANCE: OnceLock = OnceLock::new(); 11 | 12 | impl RuntimeInfos { 13 | pub fn global() -> &'static RuntimeInfos { 14 | INSTANCE.get().unwrap() // RUNTIME_INFOS should access in the server command, or it will panic 15 | } 16 | 17 | pub fn set_infos(runtime_infos: RuntimeInfos) { 18 | let _ = INSTANCE.set(runtime_infos); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/scripts/get-version.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "jsr:@std/toml"; 2 | import { parseArgs } from "jsr:@std/cli/parse-args"; 3 | 4 | const flags = parseArgs(Deno.args, { 5 | string: ["path"], 6 | }); 7 | 8 | if (!flags.path) { 9 | console.error("path is required"); 10 | Deno.exit(1); 11 | } 12 | 13 | const file = await Deno.readTextFile(flags.path); 14 | const toml = parse(file); 15 | 16 | if (!toml.package || typeof toml.package !== "object") { 17 | console.error("package is not found in the file"); 18 | Deno.exit(1); 19 | } 20 | 21 | const version = (toml.package as { version?: string }).version; 22 | 23 | if (!version) { 24 | console.error("version is not found in the file"); 25 | Deno.exit(1); 26 | } 27 | 28 | console.log(version); 29 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/types/status.rs: -------------------------------------------------------------------------------- 1 | use crate::api::status::StatusResBody; 2 | use serde::{Deserialize, Serialize}; 3 | use std::borrow::Cow; 4 | 5 | #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, Eq)] 6 | #[serde(rename_all = "snake_case")] 7 | #[cfg_attr(feature = "specta", derive(specta::Type))] 8 | pub enum ServiceStatus { 9 | NotInstalled, 10 | Stopped, 11 | Running, 12 | } 13 | 14 | #[derive(Debug, Serialize, Deserialize)] 15 | #[cfg_attr(feature = "specta", derive(specta::Type))] 16 | pub struct StatusInfo<'n> { 17 | pub name: Cow<'n, str>, // The client program name 18 | pub version: Cow<'n, str>, // The client program version 19 | pub status: ServiceStatus, 20 | pub server: Option>, 21 | } 22 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | 3 | [build.env] 4 | # Docker in docker 5 | volumes = ["/var/run/docker.sock=/var/run/docker.sock"] 6 | 7 | [target.x86_64-unknown-linux-gnu] 8 | image = "ghcr.io/cross-rs/x86_64-unknown-linux-gnu:main" 9 | 10 | [target.i686-unknown-linux-gnu] 11 | image = "ghcr.io/cross-rs/i686-unknown-linux-gnu:main" 12 | 13 | [target.aarch64-unknown-linux-gnu] 14 | image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:main" 15 | 16 | [target.armv7-unknown-linux-gnueabi] 17 | image = "ghcr.io/cross-rs/armv7-unknown-linux-gnueabi:main" 18 | 19 | [target.armv7-unknown-linux-gnueabihf] 20 | image = "ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:main" 21 | 22 | [target.armv7-unknown-linux-musleabihf] 23 | image = "ghcr.io/cross-rs/armv7-unknown-linux-musleabihf:main" 24 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/api/ws/events.rs: -------------------------------------------------------------------------------- 1 | use indexmap::IndexMap; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::api::status::CoreState; 5 | 6 | pub const EVENT_URI: &str = "/ws/events"; 7 | 8 | #[derive(Debug, Clone, Serialize, Deserialize)] 9 | pub struct TraceLog { 10 | pub timestamp: String, 11 | pub level: String, 12 | pub message: String, 13 | pub target: String, 14 | pub fields: IndexMap, 15 | } 16 | 17 | #[derive(Debug, Serialize, Deserialize, Clone)] 18 | pub enum Event { 19 | Log(TraceLog), 20 | CoreStateChanged(CoreState), 21 | } 22 | 23 | impl Event { 24 | pub fn new_log(log: TraceLog) -> Self { 25 | Self::Log(log) 26 | } 27 | 28 | pub fn new_core_state_changed(state: CoreState) -> Self { 29 | Self::CoreStateChanged(state) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/scripts/get-version.rs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S cargo +nightly -Zscript 2 | --- 3 | [package] 4 | edition = "2021" 5 | [dependencies] 6 | clap = { version = "4", features = ["derive"] } 7 | toml = "0.8" 8 | --- 9 | 10 | use clap::Parser; 11 | use toml::Table; 12 | 13 | #[derive(Parser, Debug)] 14 | #[clap(version)] 15 | struct Args { 16 | #[clap(short, long, help = "cargo file path")] 17 | path: std::path::PathBuf, 18 | } 19 | 20 | fn main() { 21 | let args = Args::parse(); 22 | if !args.path.exists() { 23 | panic!("cargo file not found"); 24 | } 25 | let content = std::fs::read_to_string(&args.path).unwrap(); 26 | let table: Table = content.parse().unwrap(); 27 | let version = table.get("package").unwrap().as_table().unwrap().get("version").unwrap(); 28 | println!("{}", version.as_str().unwrap()); 29 | } 30 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/routing/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::Router; 2 | use tracing_attributes::instrument; 3 | use ws::WsState; 4 | 5 | use super::CoreManager; 6 | 7 | pub mod core; 8 | pub mod logs; 9 | pub mod network; 10 | pub mod status; 11 | pub mod ws; 12 | 13 | #[derive(Clone)] 14 | pub struct AppState { 15 | pub core_manager: CoreManager, 16 | pub ws_state: WsState, 17 | } 18 | 19 | #[instrument(skip(state))] 20 | pub fn create_router(state: AppState) -> Router { 21 | tracing::info!("Applying routes..."); 22 | let tracing_layer = tower_http::trace::TraceLayer::new_for_http(); 23 | Router::new() 24 | .merge(status::setup()) 25 | .merge(core::setup()) 26 | .merge(logs::setup()) 27 | .merge(network::setup()) 28 | .merge(ws::setup()) 29 | .with_state(state) 30 | .layer(tracing_layer) 31 | } 32 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/routing/logs.rs: -------------------------------------------------------------------------------- 1 | use axum::{Json, Router, http::StatusCode, routing::get}; 2 | use nyanpasu_ipc::api::{ 3 | RBuilder, 4 | log::{LOGS_INSPECT_ENDPOINT, LOGS_RETRIEVE_ENDPOINT, LogsRes, LogsResBody}, 5 | }; 6 | 7 | pub fn setup() -> Router 8 | where 9 | S: Clone + Send + Sync + 'static, 10 | { 11 | Router::new() 12 | .route(LOGS_RETRIEVE_ENDPOINT, get(retrieve_logs)) 13 | .route(LOGS_INSPECT_ENDPOINT, get(inspect_logs)) 14 | } 15 | 16 | pub async fn retrieve_logs() -> (StatusCode, Json>) { 17 | let logs = crate::server::logger::Logger::global().retrieve_logs(); 18 | let res = RBuilder::success(LogsResBody { logs }); 19 | (StatusCode::OK, Json(res)) 20 | } 21 | 22 | pub async fn inspect_logs() -> (StatusCode, Json>) { 23 | let logs = crate::server::logger::Logger::global().inspect_logs(); 24 | let res = RBuilder::success(LogsResBody { logs }); 25 | (StatusCode::OK, Json(res)) 26 | } 27 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/routing/core/start.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use axum::{Json, extract::State, http::StatusCode}; 4 | use nyanpasu_ipc::api::{ 5 | RBuilder, 6 | core::start::{CoreStartReq, CoreStartRes}, 7 | }; 8 | 9 | use crate::server::routing::AppState; 10 | 11 | pub async fn start( 12 | State(state): State, 13 | Json(payload): Json>, 14 | ) -> (StatusCode, Json>) { 15 | let res = state 16 | .core_manager 17 | .start( 18 | &payload.core_type, 19 | camino::Utf8Path::from_path(&payload.config_file) 20 | .expect("failed to convert config_file to Utf8Path"), 21 | ) 22 | .await; 23 | 24 | match res { 25 | Ok(_) => (StatusCode::OK, Json(RBuilder::success(()))), 26 | Err(e) => ( 27 | StatusCode::INTERNAL_SERVER_ERROR, 28 | Json(RBuilder::other_error(Cow::Owned(e.to_string()))), 29 | ), 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /nyanpasu_service/src/utils/dirs.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::consts; 4 | 5 | const LOGS_DIR_NAME: &str = "logs"; 6 | const PID_FILE_NAME: &str = "service.pid"; 7 | 8 | const CORE_PID_FILE_NAME: &str = "core.pid"; 9 | 10 | pub fn service_logs_dir() -> PathBuf { 11 | nyanpasu_utils::dirs::suggest_service_data_dir(consts::APP_NAME).join(LOGS_DIR_NAME) 12 | } 13 | 14 | pub fn service_data_dir() -> PathBuf { 15 | nyanpasu_utils::dirs::suggest_service_data_dir(consts::APP_NAME) 16 | } 17 | 18 | pub fn service_config_dir() -> PathBuf { 19 | nyanpasu_utils::dirs::suggest_service_config_dir(consts::APP_NAME).unwrap() 20 | } 21 | 22 | /// Service server PID file 23 | pub fn service_pid_file() -> PathBuf { 24 | nyanpasu_utils::dirs::suggest_service_data_dir(consts::APP_NAME).join(PID_FILE_NAME) 25 | } 26 | 27 | /// Service owned core PID file 28 | pub fn service_core_pid_file() -> PathBuf { 29 | nyanpasu_utils::dirs::suggest_service_data_dir(consts::APP_NAME).join(CORE_PID_FILE_NAME) 30 | } 31 | -------------------------------------------------------------------------------- /nyanpasu_service/src/consts.rs: -------------------------------------------------------------------------------- 1 | use constcat::concat; 2 | 3 | pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); 4 | pub const APP_NAME: &str = env!("CARGO_PKG_NAME"); 5 | pub const SERVICE_LABEL: &str = concat!("moe.elaina.", APP_NAME); 6 | 7 | // Build info 8 | pub const COMMIT_HASH: &str = env!("COMMIT_HASH"); 9 | pub const COMMIT_AUTHOR: &str = env!("COMMIT_AUTHOR"); 10 | pub const COMMIT_DATE: &str = env!("COMMIT_DATE"); 11 | pub const BUILD_DATE: &str = env!("BUILD_DATE"); 12 | pub const BUILD_PROFILE: &str = env!("BUILD_PROFILE"); 13 | pub const BUILD_PLATFORM: &str = env!("BUILD_PLATFORM"); 14 | pub const RUSTC_VERSION: &str = env!("RUSTC_VERSION"); 15 | pub const LLVM_VERSION: &str = env!("LLVM_VERSION"); 16 | 17 | pub enum ExitCode { 18 | Normal = 0, 19 | PermissionDenied = 64, 20 | ServiceNotInstalled = 100, 21 | ServiceAlreadyInstalled = 101, 22 | ServiceAlreadyStopped = 102, 23 | ServiceAlreadyRunning = 103, 24 | Other = 1, 25 | } 26 | 27 | impl std::process::Termination for ExitCode { 28 | fn report(self) -> std::process::ExitCode { 29 | std::process::ExitCode::from(self as u8) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/routing/status.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use axum::{Json, Router, extract::State, http::StatusCode, routing::get}; 4 | 5 | use nyanpasu_ipc::api::{ 6 | RBuilder, 7 | status::{RuntimeInfos, STATUS_ENDPOINT, StatusRes, StatusResBody}, 8 | }; 9 | 10 | use super::AppState; 11 | 12 | pub fn setup() -> Router { 13 | let router = Router::new(); 14 | router.route(STATUS_ENDPOINT, get(status)) 15 | } 16 | 17 | pub async fn status(State(state): State) -> (StatusCode, Json>) { 18 | let status = state.core_manager.status().await; 19 | let runtime_infos = crate::server::consts::RuntimeInfos::global(); 20 | let res = RBuilder::success(StatusResBody { 21 | version: Cow::Borrowed(crate::consts::APP_VERSION), 22 | core_infos: status, 23 | runtime_infos: RuntimeInfos { 24 | service_data_dir: Cow::Borrowed(&runtime_infos.service_data_dir), 25 | service_config_dir: Cow::Borrowed(&runtime_infos.service_config_dir), 26 | nyanpasu_config_dir: Cow::Borrowed(&runtime_infos.nyanpasu_config_dir), 27 | nyanpasu_data_dir: Cow::Borrowed(&runtime_infos.nyanpasu_data_dir), 28 | }, 29 | }); 30 | 31 | (StatusCode::OK, Json(res)) 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nyanapsu Service 2 | 3 | A Service for Nyanapsu to make it easier to operate the privileged actions. 4 | 5 | ## Relations 6 | 7 | ![relation](./.github/assets/nyanpasu-service.drawio.svg) 8 | 9 | This project includes two crates: 10 | 11 | * `nyanpasu-ipc` a ipc bridge crate between the service and the client. It provide a `create_server` fn to hold a axum server, and provide a `shortcuts` mod for swift client rpc call. 12 | * It use `named_pipe` in windows, and `unix_socket` in unix-like system. 13 | * When install service, it should collect the users info (sid in windows, username in unix) for security. 14 | * Grant ACL to the pipe 15 | * When installing, add user to `nyanpasu` group, and grant the group to the pipe. 16 | * `nyanpasu-service` it the main entrance of the service, it provide a control plane to manage the service, and provide a `rpc` subcommand to test the service. 17 | 18 | 19 | ## Development 20 | 21 | Run with development preference: 22 | 23 | ```shell 24 | cargo debug-run 25 | ``` 26 | 27 | Build with development preference: 28 | 29 | ```shell 30 | cargo debug-build 31 | ``` 32 | 33 | View the service info: 34 | 35 | ```shell 36 | ./nyanpasu-service status # service status and health check(if running) 37 | ./nyanpasu-service version # build info only 38 | ``` 39 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["nyanpasu_service", "nyanpasu_ipc"] 4 | 5 | [workspace.package] 6 | edition = "2024" 7 | license = "GPL-3.0" 8 | authors = ["zzzgydi", "LibNyanpasu"] 9 | repository = "https://github.com/LibNyanpasu/clash-nyanpasu-service.git" 10 | 11 | [workspace.dependencies] 12 | nyanpasu-utils = { git = "https://github.com/LibNyanpasu/nyanpasu-utils.git", default-features = false } 13 | axum = "0.8" 14 | anyhow = "1" 15 | thiserror = "2" 16 | parking_lot = "0.12" 17 | simd-json = "0.16" 18 | tokio = { version = "1", features = ["full"] } 19 | tokio-util = { version = "0.7", features = ["rt"] } 20 | serde = { version = "1", features = ["derive"] } 21 | clap = { version = "4", features = ["derive"] } 22 | tracing = "0.1" 23 | tracing-attributes = "0.1" 24 | tracing-futures = "0.2" 25 | tracing-subscriber = { version = "0.3", features = [ 26 | "env-filter", 27 | "json", 28 | "parking_lot", 29 | ] } 30 | tracing-error = "0.2" 31 | tracing-log = { version = "0.2" } 32 | tracing-appender = { version = "0.2", features = ["parking_lot"] } 33 | futures = "0.3.30" 34 | futures-util = "0.3" 35 | chrono = { version = "0.4", features = ["serde"] } 36 | windows = { version = "0.61", features = ["Win32_Security"] } 37 | 38 | [profile.release] 39 | panic = "unwind" 40 | codegen-units = 1 41 | lto = true 42 | opt-level = "s" 43 | -------------------------------------------------------------------------------- /nyanpasu_service/src/cmds/start.rs: -------------------------------------------------------------------------------- 1 | use std::thread; 2 | 3 | use service_manager::{ServiceLabel, ServiceStartCtx, ServiceStatus, ServiceStatusCtx}; 4 | 5 | use crate::consts::SERVICE_LABEL; 6 | 7 | use super::CommandError; 8 | 9 | pub fn start() -> Result<(), CommandError> { 10 | let label: ServiceLabel = SERVICE_LABEL.parse()?; 11 | let manager = crate::utils::get_service_manager()?; 12 | let status = manager.status(ServiceStatusCtx { 13 | label: label.clone(), 14 | })?; 15 | match status { 16 | ServiceStatus::NotInstalled => { 17 | return Err(CommandError::ServiceNotInstalled); 18 | } 19 | ServiceStatus::Stopped(_) => { 20 | manager.start(ServiceStartCtx { 21 | label: label.clone(), 22 | })?; 23 | } 24 | ServiceStatus::Running => { 25 | tracing::info!("service already running, nothing to do"); 26 | return Err(CommandError::ServiceAlreadyRunning); 27 | } 28 | } 29 | thread::sleep(std::time::Duration::from_secs(3)); 30 | // check if the service is running 31 | let status = manager.status(ServiceStatusCtx { 32 | label: label.clone(), 33 | })?; 34 | if status != ServiceStatus::Running { 35 | return Err(CommandError::Other(anyhow::anyhow!( 36 | "service start failed, status: {:?}", 37 | status 38 | ))); 39 | } 40 | 41 | Ok(()) 42 | } 43 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/api/status.rs: -------------------------------------------------------------------------------- 1 | use crate::api::R; 2 | use serde::{Deserialize, Serialize}; 3 | use std::{borrow::Cow, path::PathBuf}; 4 | 5 | pub const STATUS_ENDPOINT: &str = "/status"; 6 | 7 | #[derive(Debug, Clone, Serialize, Deserialize)] 8 | #[cfg_attr(feature = "specta", derive(specta::Type))] 9 | pub enum CoreState { 10 | Running, 11 | Stopped(Option), 12 | } 13 | 14 | impl Default for CoreState { 15 | fn default() -> Self { 16 | Self::Stopped(None) 17 | } 18 | } 19 | 20 | #[derive(Debug, Clone, Serialize, Deserialize)] 21 | #[cfg_attr(feature = "specta", derive(specta::Type))] 22 | pub struct CoreInfos { 23 | pub r#type: Option, 24 | pub state: CoreState, 25 | pub state_changed_at: i64, 26 | pub config_path: Option, 27 | } 28 | 29 | #[derive(Debug, Clone, Serialize, Deserialize)] 30 | #[cfg_attr(feature = "specta", derive(specta::Type))] 31 | pub struct RuntimeInfos<'a> { 32 | pub service_data_dir: Cow<'a, PathBuf>, 33 | pub service_config_dir: Cow<'a, PathBuf>, 34 | pub nyanpasu_config_dir: Cow<'a, PathBuf>, 35 | pub nyanpasu_data_dir: Cow<'a, PathBuf>, 36 | } 37 | 38 | // TODO: more health check fields 39 | #[derive(Debug, Serialize, Deserialize, Clone)] 40 | #[cfg_attr(feature = "specta", derive(specta::Type))] 41 | pub struct StatusResBody<'a> { 42 | pub version: Cow<'a, str>, 43 | pub core_infos: CoreInfos, 44 | pub runtime_infos: RuntimeInfos<'a>, 45 | } 46 | 47 | pub type StatusRes<'a> = R<'a, StatusResBody<'a>>; 48 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | use interprocess::local_socket::{GenericFilePath, Name, ToFsName}; 2 | 3 | #[cfg(windows)] 4 | pub mod acl; 5 | pub mod os; 6 | 7 | #[inline] 8 | pub(crate) fn get_name_string(placeholder: &str) -> String { 9 | if cfg!(windows) { 10 | format!("\\\\.\\pipe\\{placeholder}") 11 | } else { 12 | format!("/var/run/{placeholder}.sock") 13 | } 14 | } 15 | 16 | pub(crate) fn get_name<'n>(placeholder: &str) -> Result, std::io::Error> { 17 | // TODO: support generic namespaced while I have clear understanding how to change the user group 18 | // if GenericNamespaced::is_supported() { 19 | // return if cfg!(windows) { 20 | // Ok(placeholder.to_string().to_ns_name::()?) 21 | // } else { 22 | // Ok(format!("{placeholder}.sock").to_ns_name::()?) 23 | // }; 24 | // } 25 | let name = get_name_string(placeholder); 26 | name.to_fs_name::() 27 | } 28 | 29 | #[cfg(unix)] 30 | pub(crate) async fn remove_socket_if_exists(placeholder: &str) -> Result<(), std::io::Error> { 31 | use std::path::PathBuf; 32 | 33 | let path: PathBuf = PathBuf::from(format!("/var/run/{placeholder}.sock")); 34 | if tokio::fs::metadata(&path).await.is_ok() { 35 | tokio::fs::remove_file(&path).await?; 36 | } 37 | Ok(()) 38 | } 39 | 40 | /// Get the current millisecond timestamp 41 | pub fn get_current_ts() -> i64 { 42 | std::time::SystemTime::now() 43 | .duration_since(std::time::UNIX_EPOCH) 44 | .unwrap() 45 | .as_millis() as i64 46 | } 47 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/utils/os.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_variables)] 2 | use std::io::Result; 3 | 4 | use tracing_attributes::instrument; 5 | 6 | pub const NYANPASU_USER_GROUP: &str = "nyanpasu"; 7 | 8 | #[instrument] 9 | pub(crate) fn change_socket_group(placeholder: &str) -> Result<()> { 10 | #[cfg(not(windows))] 11 | { 12 | use std::{ 13 | io::{Error as IoError, ErrorKind}, 14 | process::Command, 15 | }; 16 | let output = Command::new("chown") 17 | .arg(format!("root:{}", NYANPASU_USER_GROUP)) 18 | .arg(format!("/var/run/{placeholder}.sock")) 19 | .output()?; 20 | tracing::debug!("output: {:?}", output); 21 | if !output.status.success() { 22 | return Err(IoError::new( 23 | ErrorKind::Other, 24 | "failed to change socket group", 25 | )); 26 | } 27 | } 28 | Ok(()) 29 | } 30 | 31 | #[instrument] 32 | pub(crate) fn change_socket_mode(placeholder: &str) -> Result<()> { 33 | #[cfg(not(windows))] 34 | { 35 | use std::{ 36 | io::{Error as IoError, ErrorKind}, 37 | process::Command, 38 | }; 39 | let output = Command::new("chmod") 40 | .arg("664") 41 | .arg(format!("/var/run/{placeholder}.sock")) 42 | .output()?; 43 | tracing::debug!("output: {:?}", output); 44 | if !output.status.success() { 45 | return Err(IoError::new( 46 | ErrorKind::Other, 47 | "failed to change socket mode", 48 | )); 49 | } 50 | } 51 | Ok(()) 52 | } 53 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/client/wrapper.rs: -------------------------------------------------------------------------------- 1 | use futures_util::stream::Stream; 2 | use http_body_util::BodyDataStream; 3 | use hyper::body::Body; 4 | use pin_project_lite::pin_project; 5 | use std::task::Poll; 6 | 7 | pin_project! { 8 | #[derive(Clone, Copy, Debug)] 9 | pub(super) struct ResponseBodyStreamWrapper 10 | { 11 | #[pin] 12 | inner: S, 13 | } 14 | } 15 | 16 | impl Stream for ResponseBodyStreamWrapper> 17 | where 18 | B: Body, 19 | B::Error: Into, 20 | { 21 | type Item = Result; 22 | 23 | fn poll_next( 24 | self: std::pin::Pin<&mut Self>, 25 | cx: &mut std::task::Context<'_>, 26 | ) -> std::task::Poll> { 27 | let mut this = self.project(); 28 | match futures_util::StreamExt::poll_next_unpin(&mut this.inner, cx) { 29 | Poll::Ready(Some(Ok(bytes))) => Poll::Ready(Some(Ok(bytes))), 30 | Poll::Ready(Some(Err(e))) => { 31 | let io_err = std::io::Error::other(e.into()); 32 | Poll::Ready(Some(Err(io_err))) 33 | } 34 | Poll::Ready(None) => Poll::Ready(None), 35 | Poll::Pending => Poll::Pending, 36 | } 37 | } 38 | } 39 | 40 | pub(super) trait BodyDataStreamExt { 41 | fn into_stream_wrapper(self) -> ResponseBodyStreamWrapper 42 | where 43 | Self: Sized; 44 | } 45 | 46 | impl BodyDataStreamExt for BodyDataStream 47 | where 48 | S: Body, 49 | { 50 | fn into_stream_wrapper(self) -> ResponseBodyStreamWrapper> { 51 | ResponseBodyStreamWrapper { inner: self } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /nyanpasu_service/src/cmds/restart.rs: -------------------------------------------------------------------------------- 1 | use service_manager::ServiceLabel; 2 | 3 | use crate::consts::SERVICE_LABEL; 4 | 5 | use super::CommandError; 6 | 7 | pub fn restart() -> Result<(), CommandError> { 8 | let label: ServiceLabel = SERVICE_LABEL.parse()?; 9 | let manager = crate::utils::get_service_manager()?; 10 | let status = manager.status(service_manager::ServiceStatusCtx { 11 | label: label.clone(), 12 | })?; 13 | match status { 14 | service_manager::ServiceStatus::NotInstalled => { 15 | return Err(CommandError::ServiceNotInstalled); 16 | } 17 | service_manager::ServiceStatus::Stopped(_) => { 18 | tracing::info!("service already stopped, starting it..."); 19 | manager.start(service_manager::ServiceStartCtx { 20 | label: label.clone(), 21 | })?; 22 | } 23 | service_manager::ServiceStatus::Running => { 24 | tracing::info!("service is running, stopping it..."); 25 | manager.stop(service_manager::ServiceStopCtx { 26 | label: label.clone(), 27 | })?; 28 | std::thread::sleep(std::time::Duration::from_secs(3)); // wait for the service to stop 29 | manager.start(service_manager::ServiceStartCtx { 30 | label: label.clone(), 31 | })?; 32 | } 33 | } 34 | std::thread::sleep(std::time::Duration::from_secs(3)); 35 | // check if the service is running 36 | let status = manager.status(service_manager::ServiceStatusCtx { 37 | label: label.clone(), 38 | })?; 39 | if status != service_manager::ServiceStatus::Running { 40 | return Err(CommandError::Other(anyhow::anyhow!( 41 | "service restart failed, status: {:?}", 42 | status 43 | ))); 44 | } 45 | Ok(()) 46 | } 47 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/routing/network.rs: -------------------------------------------------------------------------------- 1 | use axum::{Json, Router, http::StatusCode, routing::post}; 2 | use nyanpasu_ipc::api::{ 3 | RBuilder, 4 | network::set_dns::{NETWORK_SET_DNS_ENDPOINT, NetworkSetDnsReq, NetworkSetDnsRes}, 5 | }; 6 | use std::borrow::Cow; 7 | 8 | #[cfg(target_os = "macos")] 9 | use nyanpasu_utils::network::macos::{get_default_network_hardware_port, set_dns}; 10 | 11 | pub fn setup() -> Router 12 | where 13 | S: Clone + Send + Sync + 'static, 14 | { 15 | Router::new().route(NETWORK_SET_DNS_ENDPOINT, post(network)) 16 | } 17 | 18 | #[cfg(target_os = "macos")] 19 | pub async fn network( 20 | Json(mut req): Json>, 21 | ) -> (StatusCode, Json>) { 22 | let default_interface = match get_default_network_hardware_port() { 23 | Ok(interface) => interface, 24 | Err(e) => { 25 | return ( 26 | StatusCode::INTERNAL_SERVER_ERROR, 27 | Json(RBuilder::other_error(Cow::Owned(e.to_string()))), 28 | ); 29 | } 30 | }; 31 | let dns_servers = req 32 | .dns_servers 33 | .take() 34 | .map(|v| v.into_iter().map(|v| v.into_owned()).collect::>()); 35 | match set_dns(&default_interface, dns_servers) { 36 | Ok(_) => (StatusCode::OK, Json(RBuilder::success(()))), 37 | Err(e) => ( 38 | StatusCode::INTERNAL_SERVER_ERROR, 39 | Json(RBuilder::other_error(Cow::Owned(e.to_string()))), 40 | ), 41 | } 42 | } 43 | 44 | #[cfg(not(target_os = "macos"))] 45 | pub async fn network( 46 | Json(_req): Json>, 47 | ) -> (StatusCode, Json>) { 48 | ( 49 | StatusCode::NOT_IMPLEMENTED, 50 | Json(RBuilder::other_error(Cow::Borrowed("Not implemented"))), 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /nyanpasu_ipc/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nyanpasu-ipc" 3 | version = "1.4.1" 4 | edition = "2024" 5 | 6 | [features] 7 | server = ["dep:axum", "dep:axum-extra", "dep:tower", "dep:widestring"] 8 | client = ["dep:axum"] 9 | specta = ["dep:specta", "nyanpasu-utils/specta"] 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [dependencies] 14 | nyanpasu-utils = { workspace = true, default-features = false, features = [ 15 | "core_manager", 16 | "serde", 17 | ] } 18 | tracing = { workspace = true } 19 | tracing-attributes = { workspace = true } 20 | # tracing-futures = { workspace = true } 21 | tokio = { workspace = true } 22 | tokio-util = { workspace = true, features = ["io-util"] } 23 | thiserror = { workspace = true } 24 | anyhow = { workspace = true } 25 | serde = { workspace = true } 26 | interprocess = { version = "2", features = ["tokio"] } 27 | axum = { version = "0.8", features = ["ws"], optional = true } 28 | axum-extra = { version = "0.12", features = ["typed-header"], optional = true } 29 | tower = { version = "0.5", features = ["util"], optional = true } 30 | # tower-http = { version = "0.5", features = ["fs", "trace"], optional = true } 31 | widestring = { version = "1", optional = true } 32 | hyper = { version = "1", features = ["full"] } 33 | http-body-util = "0.1" 34 | hyper-util = { version = "0.1", features = ["full"] } 35 | simd-json = { workspace = true } 36 | futures = { workspace = true } 37 | futures-util = { workspace = true } 38 | pin-project-lite = "0.2" 39 | # tokio-tungstenite = "0.23" # Websocket impl 40 | derive_builder = "0.20" 41 | specta = { version = "^2.0.0-rc.22", features = ["derive"], optional = true } 42 | 43 | indexmap = { version = "2", features = ["serde"] } 44 | serde_json = "1" # for serde_json::Value 45 | 46 | [target.'cfg(windows)'.dependencies] 47 | windows = { workspace = true } 48 | -------------------------------------------------------------------------------- /nyanpasu_ipc/scripts/validate_acl.ps1: -------------------------------------------------------------------------------- 1 | # validate_sddl.ps1 2 | param( 3 | [Parameter(Mandatory = $true)] 4 | [string]$SddlString 5 | ) 6 | 7 | try { 8 | # 尝试将 SDDL 转换为安全描述符对象 9 | $sd = ConvertFrom-SddlString -Sddl $SddlString 10 | 11 | Write-Host "SDDL 验证成功!" -ForegroundColor Green 12 | Write-Host "`n所有者 (Owner):" -ForegroundColor Yellow 13 | Write-Host $sd.Owner 14 | 15 | Write-Host "`n组 (Group):" -ForegroundColor Yellow 16 | Write-Host $sd.Group 17 | 18 | Write-Host "`n自由访问控制列表 (DACL):" -ForegroundColor Yellow 19 | $sd.DiscretionaryAcl | ForEach-Object { 20 | # Write-Host " - 标识: $($_.IdentityReference)" 21 | # Write-Host " 权限: $($_.FileSystemRights)" 22 | # Write-Host " 类型: $($_.AccessControlType)" 23 | # Write-Host " 继承: $($_.IsInherited)" 24 | Write-Host ($obj | Format-List | Out-String) 25 | Write-Host "" 26 | } 27 | Write-Host "共 $($sd.DiscretionaryAcl.Count) 个 DACL 项" 28 | Write-Host "" 29 | 30 | Write-Host "`n系统访问控制列表 (SACL):" -ForegroundColor Yellow 31 | $sd.SystemAcl | ForEach-Object { 32 | Write-Host ($obj | Format-Table | Out-String) 33 | Write-Host "" 34 | } 35 | 36 | 37 | Write-Host "共 $($sd.SystemAcl.Count) 个 SACL 项" 38 | Write-Host "" 39 | 40 | 41 | 42 | Write-Host "`n安全描述符:" -ForegroundColor Yellow 43 | Write-Host ($sd | Format-List | Out-String) 44 | 45 | 46 | # 将 SDDL 应用到临时文件以进一步验证 47 | $tempFile = [System.IO.Path]::GetTempFileName() 48 | $acl = Get-Acl $tempFile 49 | $acl.SetSecurityDescriptorSddlForm($SddlString) 50 | Set-Acl -Path $tempFile -AclObject $acl 51 | 52 | Write-Host "SDDL 成功应用到文件!" -ForegroundColor Green 53 | 54 | # 清理 55 | Remove-Item $tempFile -Force 56 | 57 | exit 0 58 | } 59 | catch { 60 | Write-Host "SDDL 验证失败!" -ForegroundColor Red 61 | Write-Host "错误: $_" -ForegroundColor Red 62 | exit 1 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | # Trigger the workflow on push or pull request, 5 | # but only for the main branch 6 | push: 7 | branches: 8 | - main 9 | - dev 10 | # Replace pull_request with pull_request_target if you 11 | # plan to use this action with forks, see the Limitations section 12 | pull_request: 13 | branches: 14 | - main 15 | - dev 16 | 17 | # Down scope as necessary via https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token 18 | permissions: 19 | checks: write 20 | contents: write 21 | 22 | jobs: 23 | run-linters: 24 | strategy: 25 | matrix: 26 | os: [ubuntu-latest, macos-latest, windows-latest] 27 | fail-fast: false 28 | name: Run linters 29 | runs-on: ${{ matrix.os }} 30 | 31 | steps: 32 | - name: Set git to use LF 33 | run: | 34 | git config --global core.autocrlf false 35 | git config --global core.eol lf 36 | - name: Check out Git repository 37 | uses: actions/checkout@v4 38 | - name: Install Rust nightly toolchain 39 | run: | 40 | rustup toolchain install -c clippy -c rustfmt --profile minimal nightly 41 | rustup default nightly 42 | - uses: Swatinem/rust-cache@v2 43 | name: Setup Rust cache 44 | with: 45 | shared-key: ${{ matrix.os }}-ci 46 | save-if: ${{ github.ref == 'refs/heads/main' }} 47 | # Install your linters here 48 | - name: Clippy 49 | run: cargo clippy --all-targets --all-features 50 | - name: Run linters 51 | uses: wearerequired/lint-action@master 52 | with: 53 | rustfmt: true 54 | clippy: false 55 | auto_fix: true 56 | commit_message: "chore: apply linting fixes with ${linter}" 57 | git_name: "github-actions[bot]" 58 | git_email: "41898282+github-actions[bot]@users.noreply.github.com" 59 | -------------------------------------------------------------------------------- /nyanpasu_service/build.rs: -------------------------------------------------------------------------------- 1 | use chrono::prelude::*; 2 | use rustc_version::version_meta; 3 | use std::{env, process::Command}; 4 | 5 | fn main() { 6 | // Git Information 7 | let output = Command::new("git") 8 | .args([ 9 | "show", 10 | "--pretty=format:'%H,%cn,%cI'", 11 | "--no-patch", 12 | "--no-notes", 13 | ]) 14 | .output() 15 | .unwrap(); 16 | let command_args: Vec = String::from_utf8(output.stdout) 17 | .unwrap() 18 | .replace('\'', "") 19 | .split(',') 20 | .map(String::from) 21 | .collect(); 22 | println!("cargo:rustc-env=COMMIT_HASH={}", command_args[0]); 23 | println!("cargo:rustc-env=COMMIT_AUTHOR={}", command_args[1]); 24 | let commit_date = DateTime::parse_from_rfc3339(command_args[2].as_str()) 25 | .unwrap() 26 | .with_timezone(&Utc) 27 | .to_rfc3339_opts(SecondsFormat::Millis, true); 28 | println!("cargo:rustc-env=COMMIT_DATE={commit_date}"); 29 | 30 | // Build Date 31 | let build_date = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); 32 | println!("cargo:rustc-env=BUILD_DATE={build_date}"); 33 | 34 | // Build Profile 35 | println!( 36 | "cargo:rustc-env=BUILD_PROFILE={}", 37 | match env::var("PROFILE").unwrap().as_str() { 38 | "release" => "Release", 39 | "debug" => "Debug", 40 | _ => "Unknown", 41 | } 42 | ); 43 | // Build Platform 44 | println!( 45 | "cargo:rustc-env=BUILD_PLATFORM={}", 46 | env::var("TARGET").unwrap() 47 | ); 48 | // Rustc Version & LLVM Version 49 | let rustc_version = version_meta().unwrap(); 50 | println!( 51 | "cargo:rustc-env=RUSTC_VERSION={}", 52 | rustc_version.short_version_string 53 | ); 54 | println!( 55 | "cargo:rustc-env=LLVM_VERSION={}", 56 | match rustc_version.llvm_version { 57 | Some(v) => v.to_string(), 58 | None => "Unknown".to_string(), 59 | } 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /nyanpasu_service/src/cmds/uninstall.rs: -------------------------------------------------------------------------------- 1 | use std::{process::Stdio, thread}; 2 | 3 | use service_manager::{ServiceLabel, ServiceStatus, ServiceStatusCtx, ServiceUninstallCtx}; 4 | 5 | use crate::consts::SERVICE_LABEL; 6 | 7 | use super::CommandError; 8 | 9 | pub fn uninstall() -> Result<(), CommandError> { 10 | let label: ServiceLabel = SERVICE_LABEL.parse()?; 11 | let manager = crate::utils::get_service_manager()?; 12 | let status = manager.status(ServiceStatusCtx { 13 | label: label.clone(), 14 | })?; 15 | match status { 16 | ServiceStatus::NotInstalled => { 17 | tracing::info!("service not installed, nothing to do"); 18 | return Err(CommandError::ServiceNotInstalled); 19 | } 20 | ServiceStatus::Stopped(_) => { 21 | tracing::info!("service already stopped, so we can uninstall it directly"); 22 | manager.uninstall(ServiceUninstallCtx { 23 | label: label.clone(), 24 | })?; 25 | } 26 | ServiceStatus::Running => { 27 | tracing::info!("Service is running, we need to stop it first"); 28 | std::process::Command::new(std::env::current_exe()?) 29 | .arg("stop") 30 | .stdout(Stdio::piped()) 31 | .stderr(Stdio::piped()) 32 | .status() 33 | .inspect_err(|e| tracing::error!("failed to stop service: {:?}", e)) 34 | .map_err(CommandError::Io)? 35 | .exit_ok() 36 | .inspect_err(|e| tracing::error!("failed to stop service: {:?}", e)) 37 | .map_err(|e| CommandError::Other(e.into()))?; 38 | thread::sleep(std::time::Duration::from_secs(5)); // wait for the service to stop 39 | manager.uninstall(ServiceUninstallCtx { 40 | label: label.clone(), 41 | })?; 42 | } 43 | } 44 | tracing::info!("confirming service is uninstalled..."); 45 | let status = manager.status(ServiceStatusCtx { label })?; 46 | if status != ServiceStatus::NotInstalled { 47 | return Err(CommandError::Other(anyhow::anyhow!( 48 | "service uninstall failed, status: {:?}", 49 | status 50 | ))); 51 | } 52 | Ok(()) 53 | } 54 | -------------------------------------------------------------------------------- /nyanpasu_service/src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(error_generic_member_access)] 2 | #![feature(exit_status_error)] 3 | 4 | mod cmds; 5 | pub mod consts; 6 | mod logging; 7 | mod server; 8 | mod utils; 9 | 10 | #[cfg(windows)] 11 | mod win_service; 12 | 13 | use consts::ExitCode; 14 | use nyanpasu_utils::runtime::block_on; 15 | use tracing::error; 16 | use utils::{os::register_ctrlc_handler, register_panic_hook}; 17 | 18 | pub async fn handler() -> ExitCode { 19 | crate::utils::deadlock_detection(); 20 | let result = cmds::process().await; 21 | match result { 22 | Ok(_) => ExitCode::Normal, 23 | Err(cmds::CommandError::PermissionDenied) => { 24 | eprintln!("Permission denied, please run as administrator or root"); 25 | ExitCode::PermissionDenied 26 | } 27 | Err(cmds::CommandError::ServiceNotInstalled) => { 28 | eprintln!("Service not installed"); 29 | ExitCode::ServiceNotInstalled 30 | } 31 | Err(cmds::CommandError::ServiceAlreadyInstalled) => { 32 | eprintln!("Service already installed"); 33 | ExitCode::ServiceAlreadyInstalled 34 | } 35 | Err(cmds::CommandError::ServiceAlreadyStopped) => { 36 | eprintln!("Service already stopped"); 37 | ExitCode::ServiceAlreadyStopped 38 | } 39 | Err(cmds::CommandError::ServiceAlreadyRunning) => { 40 | eprintln!("Service already running"); 41 | ExitCode::ServiceAlreadyRunning 42 | } 43 | Err(e) => { 44 | error!("Error: {:#?}", e); 45 | ExitCode::Other 46 | } 47 | } 48 | } 49 | 50 | fn main() -> ExitCode { 51 | let mut rx = register_ctrlc_handler(); 52 | register_panic_hook(); 53 | #[cfg(windows)] 54 | { 55 | let args = std::env::args_os().any(|arg| &arg == "--service"); 56 | if args { 57 | crate::win_service::run().unwrap(); 58 | return ExitCode::Normal; 59 | } 60 | } 61 | 62 | block_on(async { 63 | tokio::select! { 64 | biased; 65 | Some(_) = rx.recv() => { 66 | ExitCode::Normal 67 | } 68 | exit_code = handler() => { 69 | exit_code 70 | } 71 | } 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /nyanpasu_service/src/cmds/update.rs: -------------------------------------------------------------------------------- 1 | use nyanpasu_ipc::client::shortcuts; 2 | use semver::Version; 3 | use tokio::task::spawn_blocking; 4 | 5 | use crate::consts::{APP_NAME, APP_VERSION}; 6 | 7 | use super::CommandError; 8 | 9 | pub async fn update() -> Result<(), CommandError> { 10 | tracing::info!("Checking for updates..."); 11 | let service_data_dir = crate::utils::dirs::service_data_dir(); 12 | tracing::info!("Service data dir: {:?}", service_data_dir); 13 | tracing::info!("Client version: {}", APP_VERSION); 14 | let service_binary = 15 | service_data_dir.join(format!("{}{}", APP_NAME, std::env::consts::EXE_SUFFIX)); 16 | if !service_binary.exists() { 17 | tracing::info!("Service binary not found, copying from current binary directly..."); 18 | tokio::fs::copy(std::env::current_exe()?, &service_binary).await?; 19 | return Ok(()); 20 | } 21 | let client_version = Version::parse(APP_VERSION).unwrap(); 22 | tracing::info!("Get server version..."); 23 | let client = shortcuts::Client::service_default(); 24 | let status = client.status().await.ok(); 25 | match status { 26 | None => { 27 | // server is stopped or not installed 28 | tracing::info!("Server is stopped or not installed, replacing the binary directly..."); 29 | tokio::fs::copy(std::env::current_exe()?, &service_binary).await?; 30 | } 31 | Some(status) => { 32 | let server_version = Version::parse(&status.version).unwrap(); 33 | if client_version > server_version { 34 | tracing::info!("Client version is newer than server version, prepare updating..."); 35 | tracing::info!("Stopping the service before updating..."); 36 | spawn_blocking(super::stop::stop).await??; // stop the service before updating 37 | tracing::info!("Copying the binary..."); 38 | tokio::fs::copy(std::env::current_exe()?, &service_binary).await?; 39 | tracing::info!("Service binary updated, starting the service..."); 40 | spawn_blocking(super::start::start).await??; // start the service after updating 41 | } else { 42 | tracing::info!("Client version is the same as server version, no need to update."); 43 | } 44 | } 45 | } 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /nyanpasu_service/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use service_manager::ServiceManager; 3 | use tracing_panic::panic_hook; 4 | 5 | #[cfg(windows)] 6 | pub mod acl; 7 | pub mod dirs; 8 | pub mod os; 9 | 10 | pub fn must_check_elevation() -> bool { 11 | #[cfg(windows)] 12 | { 13 | use check_elevation::is_elevated; 14 | is_elevated().unwrap() 15 | } 16 | #[cfg(not(windows))] 17 | { 18 | use whoami::username; 19 | username() == "root" 20 | } 21 | } 22 | 23 | pub fn get_service_manager() -> Result, anyhow::Error> { 24 | let manager = ::native()?; 25 | if !manager.available().context( 26 | "service manager is not available, please make sure you are running as root or administrator", 27 | )? { 28 | anyhow::bail!("service manager not available"); 29 | } 30 | Ok(manager) 31 | } 32 | 33 | pub fn deadlock_detection() { 34 | #[cfg(feature = "deadlock_detection")] 35 | { 36 | // only for #[cfg] 37 | use parking_lot::deadlock; 38 | use std::{thread, time::Duration}; 39 | 40 | // Create a background thread which checks for deadlocks every 10s 41 | thread::spawn(move || { 42 | loop { 43 | thread::sleep(Duration::from_secs(10)); 44 | let deadlocks = deadlock::check_deadlock(); 45 | if deadlocks.is_empty() { 46 | continue; 47 | } 48 | 49 | eprintln!("{} deadlocks detected", deadlocks.len()); 50 | tracing::error!("{} deadlocks detected", deadlocks.len()); 51 | for (i, threads) in deadlocks.iter().enumerate() { 52 | eprintln!("Deadlock #{}", i); 53 | tracing::error!("Deadlock #{}", i); 54 | for t in threads { 55 | eprintln!("Thread Id {:#?}", t.thread_id()); 56 | eprintln!("{:#?}", t.backtrace()); 57 | tracing::error!("Thread Id {:#?}", t.thread_id()); 58 | tracing::error!("{:#?}", t.backtrace()); 59 | } 60 | } 61 | } 62 | }); 63 | } // only for #[cfg] 64 | } 65 | 66 | /// Register a panic hook to log the panic message and location, then exit the process. 67 | pub fn register_panic_hook() { 68 | std::panic::set_hook(Box::new(panic_hook)); 69 | } 70 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/routing/ws.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use super::AppState; 4 | use axum::{ 5 | Router, 6 | extract::{ 7 | FromRef, State, WebSocketUpgrade, 8 | ws::{Message, WebSocket}, 9 | }, 10 | response::Response, 11 | routing::any, 12 | }; 13 | use dashmap::DashMap; 14 | use futures_util::{SinkExt, StreamExt}; 15 | use nyanpasu_ipc::api::ws::events::{EVENT_URI, Event}; 16 | use tokio::sync::mpsc::Sender as MpscSender; 17 | 18 | type SocketId = usize; 19 | 20 | #[derive(Default, Clone)] 21 | pub struct WsState { 22 | pub events_subscribers: Arc>>, 23 | } 24 | 25 | impl WsState { 26 | pub async fn event_broadcast(&self, event: Event) { 27 | futures_util::future::join_all(self.events_subscribers.iter().map(|entry| { 28 | let tx = entry.value().clone(); 29 | let event = event.clone(); 30 | async move { 31 | if let Err(e) = tx.send(event).await { 32 | tracing::error!("Failed to send event: {:?}", e); 33 | } 34 | } 35 | })) 36 | .await; 37 | } 38 | } 39 | 40 | impl FromRef for WsState { 41 | fn from_ref(state: &AppState) -> Self { 42 | state.ws_state.clone() 43 | } 44 | } 45 | 46 | pub fn setup() -> Router { 47 | let router = Router::new(); 48 | router.route(EVENT_URI, any(ws_handler)) 49 | } 50 | 51 | async fn ws_handler(State(state): State, ws: WebSocketUpgrade) -> Response { 52 | ws.on_upgrade(|socket| handle_socket(socket, state)) 53 | } 54 | 55 | async fn handle_socket(socket: WebSocket, state: WsState) { 56 | let socket_id = state.events_subscribers.len() + 1; 57 | let (tx, mut rx) = tokio::sync::mpsc::channel(100); 58 | state.events_subscribers.insert(socket_id, tx); 59 | 60 | let (mut sink, mut stream) = socket.split(); 61 | 62 | let handler = async { 63 | while let Some(Ok(_)) = stream.next().await {} 64 | state.events_subscribers.remove(&socket_id); 65 | }; 66 | 67 | let sender = async { 68 | while let Some(event) = rx.recv().await { 69 | let Ok(event) = simd_json::to_vec(&event) else { 70 | tracing::error!("Failed to serialize event: {:?}", event); 71 | continue; 72 | }; 73 | let msg = Message::binary(event); 74 | if let Err(e) = sink.send(msg).await { 75 | tracing::error!("Failed to send event: {:?}", e); 76 | } 77 | } 78 | }; 79 | tokio::select! { 80 | _ = handler => (), 81 | _ = sender => (), 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /nyanpasu_service/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nyanpasu-service" 3 | version = "1.4.1" 4 | edition = "2024" 5 | authors = { workspace = true } 6 | license = { workspace = true } 7 | repository = { workspace = true } 8 | default-run = "nyanpasu-service" 9 | build = "build.rs" 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [features] 13 | debug = ["deadlock_detection", "tracing"] 14 | tracing = ["tokio/tracing", "dep:console-subscriber"] 15 | deadlock_detection = [ 16 | "parking_lot/deadlock_detection", 17 | "nyanpasu-utils/deadlock_detection", 18 | ] 19 | hardware-lock-elision = ["parking_lot/hardware-lock-elision"] 20 | 21 | [dependencies] 22 | nyanpasu-utils = { workspace = true, default-features = false, features = [ 23 | "dirs", 24 | "os", 25 | "network", 26 | ] } 27 | nyanpasu-ipc = { path = "../nyanpasu_ipc", default-features = false, features = [ 28 | "server", 29 | "client", 30 | ] } 31 | axum = { workspace = true, features = ["macros"] } 32 | dashmap = "6" 33 | tower-http = { version = "0.6", features = ["trace"] } 34 | tokio = { workspace = true, features = ["full"] } 35 | tokio-util = { workspace = true } 36 | futures-util = { workspace = true } 37 | clap = { version = "4", features = ["derive"] } 38 | serde = { workspace = true, features = ["derive"] } 39 | simd-json = { workspace = true } 40 | thiserror.workspace = true 41 | anyhow = { workspace = true } 42 | tracing.workspace = true 43 | tracing-appender.workspace = true 44 | tracing-log.workspace = true 45 | tracing-subscriber = { workspace = true, features = [ 46 | "env-filter", 47 | "json", 48 | "parking_lot", 49 | ] } 50 | tracing-attributes.workspace = true 51 | tracing-panic = "0.1" 52 | tracing-serde = "0.2" 53 | service-manager = "0.8" 54 | parking_lot = "0.12" 55 | constcat = "0.6.0" 56 | ctrlc = { version = "3", features = ["termination"] } 57 | semver = "1" 58 | bounded-vec-deque = "0.1.1" 59 | chrono = { workspace = true } 60 | supports-color = "3.0.2" 61 | colored = "3.0.0" 62 | timeago = "0.5" 63 | ansi-str = "0.9" 64 | console-subscriber = { version = "0.5", optional = true, features = [ 65 | "parking_lot", 66 | ] } 67 | oneshot = "0.1" 68 | indexmap = { version = "2", features = ["serde"] } 69 | serde_json = "1" 70 | camino = { version = "1.1", features = ["serde1"] } 71 | dunce = "1.0.5" 72 | sysinfo = "0.36.1" 73 | 74 | [build-dependencies] 75 | chrono = { workspace = true } 76 | rustc_version = "0.4" 77 | 78 | 79 | [target.'cfg(windows)'.dependencies] 80 | check_elevation = "0.2.4" 81 | windows-service = "0.8" 82 | windows = { workspace = true } 83 | 84 | [target.'cfg(unix)'.dependencies] 85 | whoami = "1" 86 | -------------------------------------------------------------------------------- /nyanpasu_service/src/cmds/status.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, time::Duration}; 2 | 3 | use super::CommandError; 4 | use crate::consts::{APP_NAME, APP_VERSION, SERVICE_LABEL}; 5 | use nyanpasu_ipc::{client::shortcuts::Client, types::StatusInfo}; 6 | use service_manager::{ServiceLabel, ServiceStatus, ServiceStatusCtx}; 7 | use tokio::time::timeout; 8 | 9 | #[derive(Debug, clap::Args)] 10 | pub struct StatusCommand { 11 | /// Output the result in JSON format 12 | #[clap(long, default_value = "false")] 13 | json: bool, 14 | 15 | /// Skip the service check 16 | #[clap(long, default_value = "false")] 17 | skip_service_check: bool, 18 | } 19 | 20 | // TODO: impl the health check if service is running 21 | // such as data dir, config dir, core status. 22 | pub async fn status(ctx: StatusCommand) -> Result<(), CommandError> { 23 | let label: ServiceLabel = SERVICE_LABEL.parse()?; 24 | let manager = crate::utils::get_service_manager()?; 25 | let status = if ctx.skip_service_check { 26 | ServiceStatus::Running 27 | } else { 28 | manager.status(ServiceStatusCtx { 29 | label: label.clone(), 30 | })? 31 | }; 32 | tracing::debug!("Note that the service original state is: {:?}", status); 33 | let client = Client::service_default(); 34 | let mut info = StatusInfo { 35 | name: Cow::Borrowed(APP_NAME), 36 | version: Cow::Borrowed(APP_VERSION), 37 | status: match status { 38 | ServiceStatus::NotInstalled => nyanpasu_ipc::types::ServiceStatus::NotInstalled, 39 | ServiceStatus::Stopped(_) => nyanpasu_ipc::types::ServiceStatus::Stopped, 40 | ServiceStatus::Running => nyanpasu_ipc::types::ServiceStatus::Running, 41 | }, 42 | server: None, 43 | }; 44 | if info.status == nyanpasu_ipc::types::ServiceStatus::Running { 45 | let server = match timeout(Duration::from_secs(3), client.status()).await { 46 | Ok(Ok(server)) => Some(server), 47 | Ok(Err(e)) => { 48 | tracing::debug!("failed to get server status: {}", e); 49 | info.status = nyanpasu_ipc::types::ServiceStatus::Stopped; 50 | None 51 | } 52 | Err(e) => { 53 | tracing::debug!("get server status timeout: {}", e); 54 | info.status = nyanpasu_ipc::types::ServiceStatus::Stopped; 55 | None 56 | } 57 | }; 58 | 59 | info = StatusInfo { server, ..info } 60 | } 61 | if ctx.json { 62 | println!("{}", simd_json::serde::to_string_pretty(&info)?); 63 | } else { 64 | println!("{info:#?}"); 65 | } 66 | Ok(()) 67 | } 68 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod consts; 2 | mod instance; 3 | mod logger; 4 | mod routing; 5 | 6 | pub use instance::CoreManagerService as CoreManager; 7 | pub use logger::Logger; 8 | use nyanpasu_ipc::{ 9 | SERVICE_PLACEHOLDER, 10 | api::ws::events::{Event as WsEvent, TraceLog}, 11 | server::create_server, 12 | }; 13 | use routing::{AppState, create_router}; 14 | use tokio_util::sync::CancellationToken; 15 | use tracing_attributes::instrument; 16 | 17 | use crate::server::routing::ws::WsState; 18 | 19 | #[instrument] 20 | pub async fn run( 21 | token: CancellationToken, 22 | #[cfg(windows)] sids: &[&str], 23 | #[cfg(not(windows))] sids: (), 24 | ) -> Result<(), anyhow::Error> { 25 | let (tx, mut rx) = tokio::sync::mpsc::channel(10); 26 | let core_manager = CoreManager::new_with_notify(tx, token.clone()); 27 | let state = AppState { 28 | core_manager, 29 | ws_state: WsState::default(), 30 | }; 31 | let ws_state = state.ws_state.clone(); 32 | tokio::spawn(async move { 33 | while let Some(state) = rx.recv().await { 34 | tracing::info!("State changed: {:?}", state); 35 | ws_state 36 | .event_broadcast(WsEvent::new_core_state_changed(state)) 37 | .await; 38 | } 39 | }); 40 | let ws_state = state.ws_state.clone(); 41 | let tokio_handle = tokio::runtime::Handle::current(); 42 | Logger::global().set_subscriber(Box::new(move |logging| { 43 | let ws_state = ws_state.clone(); 44 | tokio_handle.spawn(async move { 45 | ws_state 46 | .event_broadcast(WsEvent::new_log(TraceLog { 47 | timestamp: logging.timestamp, 48 | level: logging.level, 49 | message: logging 50 | .fields 51 | .get("message") 52 | .and_then(|v| v.as_str().map(|s| s.to_string())) 53 | .unwrap_or("".to_string()), 54 | target: logging 55 | .fields 56 | .get("target") 57 | .and_then(|v| v.as_str().map(|s| s.to_string())) 58 | .unwrap_or("".to_string()), 59 | fields: logging.fields, 60 | })) 61 | .await; 62 | }); 63 | })); 64 | 65 | let app = create_router(state); 66 | tracing::info!("Starting server..."); 67 | create_server( 68 | SERVICE_PLACEHOLDER, 69 | app, 70 | Some(async move { 71 | token.cancelled().await; 72 | }), 73 | sids, 74 | ) 75 | .await?; 76 | Ok(()) 77 | } 78 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod core; 2 | pub mod log; 3 | pub mod network; 4 | pub mod status; 5 | pub mod ws; 6 | 7 | use serde::{Deserialize, Serialize, de::DeserializeOwned}; 8 | use std::{borrow::Cow, fmt::Debug, io::Error as IoError}; 9 | 10 | #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq)] 11 | pub enum ResponseCode { 12 | #[default] 13 | Ok = 0, 14 | OtherError = -1, 15 | } 16 | 17 | /// ResponseCode message mapping 18 | impl ResponseCode { 19 | pub const fn msg(&self) -> &'static str { 20 | match self { 21 | Self::Ok => "ok", 22 | Self::OtherError => "other error", 23 | } 24 | } 25 | } 26 | 27 | /// The IPC Response body definition 28 | #[derive(Debug, Serialize, Deserialize, Clone, Builder)] 29 | #[builder(build_fn(validate = "Self::validate"))] 30 | #[serde(bound = "T: Serialize + DeserializeOwned")] 31 | pub struct R<'a, T: Serialize + DeserializeOwned + Debug> { 32 | pub code: ResponseCode, 33 | #[builder(default = "self.default_msg()")] 34 | pub msg: Cow<'a, str>, 35 | #[builder(setter(into, strip_option))] 36 | pub data: Option, 37 | #[builder(setter(skip), default = "self.default_ts()")] 38 | pub ts: i64, 39 | } 40 | 41 | impl R<'_, T> { 42 | pub fn ok(self) -> Result { 43 | if self.code == ResponseCode::Ok { 44 | Ok(self) 45 | } else { 46 | Err(IoError::other(format!( 47 | "Response code is not Ok: {self:#?}" 48 | ))) 49 | } 50 | } 51 | } 52 | 53 | impl<'a, T: Serialize + DeserializeOwned + Debug> RBuilder<'a, T> { 54 | fn default_ts(&self) -> i64 { 55 | crate::utils::get_current_ts() 56 | } 57 | 58 | fn default_msg(&self) -> Cow<'a, str> { 59 | Cow::Borrowed(if let Some(code) = self.code { 60 | code.msg() 61 | } else { 62 | ResponseCode::Ok.msg() 63 | }) 64 | } 65 | 66 | fn validate(&self) -> Result<(), String> { 67 | if self.code.is_none() { 68 | return Err("code is required".to_string()); 69 | } 70 | if self.msg.is_none() { 71 | return Err("msg is required".to_string()); 72 | } 73 | Ok(()) 74 | } 75 | 76 | pub fn other_error(msg: Cow<'a, str>) -> R<'a, T> { 77 | let code = ResponseCode::OtherError; 78 | R { 79 | code, 80 | msg, 81 | data: None, 82 | ts: crate::utils::get_current_ts(), 83 | } 84 | } 85 | 86 | pub fn success(data: T) -> R<'a, T> { 87 | let code = ResponseCode::Ok; 88 | R { 89 | code, 90 | msg: Cow::Borrowed(code.msg()), 91 | data: Some(data), 92 | ts: crate::utils::get_current_ts(), 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/logger.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::Cow, 3 | sync::{Arc, OnceLock}, 4 | }; 5 | 6 | use bounded_vec_deque::BoundedVecDeque; 7 | use indexmap::IndexMap; 8 | use parking_lot::Mutex; 9 | use tracing_subscriber::fmt::MakeWriter; 10 | 11 | pub type LoggerSubscriber = Box; 12 | 13 | pub struct Logger<'n> { 14 | buffer: Arc>>>, 15 | subscriber: Arc>, 16 | } 17 | 18 | #[derive(Debug, serde::Deserialize)] 19 | pub struct TracingLogging { 20 | pub level: String, 21 | pub timestamp: String, 22 | #[serde(flatten)] 23 | pub fields: IndexMap, 24 | } 25 | 26 | impl Clone for Logger<'_> { 27 | fn clone(&self) -> Self { 28 | Logger { 29 | buffer: self.buffer.clone(), 30 | subscriber: self.subscriber.clone(), 31 | } 32 | } 33 | } 34 | 35 | impl<'n> Logger<'n> { 36 | pub fn global() -> &'static Logger<'static> { 37 | static INSTANCE: OnceLock = OnceLock::new(); 38 | INSTANCE.get_or_init(|| Logger { 39 | buffer: Arc::new(Mutex::new(BoundedVecDeque::new(100))), 40 | subscriber: Arc::new(OnceLock::new()), 41 | }) 42 | } 43 | 44 | pub fn set_subscriber( 45 | &self, 46 | subscriber: Box, 47 | ) -> bool { 48 | self.subscriber.set(subscriber).is_ok() 49 | } 50 | 51 | /// Retrieve all logs in the buffer 52 | /// It should clear the buffer after retrieve 53 | pub fn retrieve_logs(&self) -> Vec> { 54 | let mut buffer = self.buffer.lock(); 55 | buffer.drain(..).collect() 56 | } 57 | 58 | /// Inspect all logs in the buffer 59 | /// It should not clear the buffer after inspect 60 | pub fn inspect_logs(&self) -> Vec> { 61 | let buffer = self.buffer.lock(); 62 | buffer.iter().cloned().collect() 63 | } 64 | } 65 | 66 | impl std::io::Write for Logger<'_> { 67 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 68 | let mut buffer = self.buffer.lock(); 69 | let msg = String::from_utf8_lossy(buf); 70 | if let Some(subscriber) = self.subscriber.get() 71 | && let Ok(logging) = serde_json::from_str::(&msg) 72 | { 73 | subscriber(logging); 74 | } 75 | buffer.push_back(Cow::Owned(msg.into_owned())); 76 | Ok(buf.len()) 77 | } 78 | 79 | fn flush(&mut self) -> std::io::Result<()> { 80 | Ok(()) 81 | } 82 | } 83 | 84 | impl<'a> MakeWriter<'a> for Logger<'static> { 85 | type Writer = Logger<'static>; 86 | 87 | fn make_writer(&'a self) -> Self::Writer { 88 | Logger { 89 | buffer: self.buffer.clone(), 90 | subscriber: self.subscriber.clone(), 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /nyanpasu_service/src/cmds/stop.rs: -------------------------------------------------------------------------------- 1 | use std::{thread, time::Duration}; 2 | 3 | use anyhow::Context; 4 | use service_manager::{ServiceLabel, ServiceStatus, ServiceStatusCtx, ServiceStopCtx}; 5 | 6 | use crate::consts::SERVICE_LABEL; 7 | 8 | use super::CommandError; 9 | 10 | pub fn stop() -> Result<(), CommandError> { 11 | let label: ServiceLabel = SERVICE_LABEL.parse()?; 12 | let manager = crate::utils::get_service_manager()?; 13 | let status = manager.status(ServiceStatusCtx { 14 | label: label.clone(), 15 | })?; 16 | match status { 17 | ServiceStatus::NotInstalled => { 18 | tracing::info!("service not installed, nothing to do"); 19 | return Err(CommandError::ServiceNotInstalled); 20 | } 21 | ServiceStatus::Stopped(_) => { 22 | tracing::info!("service already stopped"); 23 | return Err(CommandError::ServiceAlreadyStopped); 24 | } 25 | ServiceStatus::Running => { 26 | tracing::info!("service is running, stopping it..."); 27 | let rt = tokio::runtime::Runtime::new().unwrap(); 28 | rt.block_on(async { 29 | let label_ = label.clone(); 30 | let handle = tokio::task::spawn_blocking(move || { 31 | let manager = crate::utils::get_service_manager()?; 32 | manager.stop(ServiceStopCtx { label: label_ })?; 33 | anyhow::Ok(()) 34 | }); 35 | 36 | match tokio::time::timeout(Duration::from_secs(8), handle).await { 37 | Ok(res) => res.context("failed to join service stop task").flatten(), 38 | Err(e) => { 39 | tracing::error!("service stop timed out: {:?}, trying to kill it", e); 40 | let mut sys = sysinfo::System::new_all(); 41 | sys.refresh_all(); 42 | let pkg_name = env!("CARGO_PKG_NAME"); 43 | let current_pid = std::process::id(); 44 | tracing::info!("Try to find `{pkg_name}`..."); 45 | for (pid, process) in sys.processes() { 46 | if let Some(path) = process.cwd() 47 | && path.to_string_lossy().contains(pkg_name) 48 | && pid.as_u32() != current_pid 49 | { 50 | tracing::info!("killing process: {:?}", pid); 51 | process.kill(); 52 | } 53 | } 54 | Ok(()) 55 | } 56 | } 57 | })?; 58 | tracing::info!("service stopped"); 59 | } 60 | } 61 | thread::sleep(std::time::Duration::from_secs(3)); 62 | // check if the service is stopped 63 | let manager = crate::utils::get_service_manager()?; 64 | let status = manager.status(ServiceStatusCtx { 65 | label: label.clone(), 66 | })?; 67 | // macOS possibly returns ServiceStatus::NotInstalled 68 | if !matches!( 69 | status, 70 | ServiceStatus::Stopped(None) | ServiceStatus::NotInstalled 71 | ) { 72 | return Err(CommandError::Other(anyhow::anyhow!( 73 | "service stop failed, status: {:?}", 74 | status 75 | ))); 76 | } 77 | Ok(()) 78 | } 79 | -------------------------------------------------------------------------------- /nyanpasu_service/src/cmds/rpc/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, net::IpAddr}; 2 | 3 | /// This module is a shortcut for client rpc calls. 4 | /// It is useful for testing and debugging service rpc calls. 5 | use clap::Subcommand; 6 | use nyanpasu_ipc::{api::network::set_dns::NetworkSetDnsReq, client::shortcuts::Client}; 7 | 8 | fn core_type_parser(s: &str) -> Result { 9 | let mut s = s.to_string(); 10 | unsafe { 11 | simd_json::serde::from_slice(s.as_bytes_mut()) 12 | .map_err(|e| format!("Failed to parse core type: {e}")) 13 | } 14 | } 15 | 16 | #[derive(Debug, Subcommand)] 17 | pub enum RpcCommand { 18 | /// Start specific core with the given config file 19 | StartCore { 20 | /// The core type to start 21 | #[clap(long)] 22 | #[arg(value_parser = core_type_parser)] 23 | core_type: nyanpasu_utils::core::CoreType, 24 | 25 | /// The path to the core config fileW 26 | #[clap(long)] 27 | config_file: std::path::PathBuf, 28 | }, 29 | /// Stop the running core 30 | StopCore, 31 | /// Restart the running core 32 | RestartCore, 33 | /// Get the logs of the service 34 | InspectLogs, 35 | /// Set the dns servers 36 | SetDns { dns_servers: Option> }, 37 | } 38 | 39 | pub async fn rpc(commands: RpcCommand) -> Result<(), crate::cmds::CommandError> { 40 | // let client = Client::new().await?; 41 | match commands { 42 | RpcCommand::StartCore { 43 | core_type, 44 | config_file, 45 | } => { 46 | let client = Client::service_default(); 47 | 48 | let payload = nyanpasu_ipc::api::core::start::CoreStartReq { 49 | core_type: Cow::Borrowed(&core_type), 50 | config_file: Cow::Borrowed(&config_file), 51 | }; 52 | client 53 | .start_core(&payload) 54 | .await 55 | .map_err(|e| crate::cmds::CommandError::Other(e.into()))?; 56 | } 57 | RpcCommand::StopCore => { 58 | let client = Client::service_default(); 59 | client 60 | .stop_core() 61 | .await 62 | .map_err(|e| crate::cmds::CommandError::Other(e.into()))?; 63 | } 64 | RpcCommand::RestartCore => { 65 | let client = Client::service_default(); 66 | client 67 | .restart_core() 68 | .await 69 | .map_err(|e| crate::cmds::CommandError::Other(e.into()))?; 70 | } 71 | RpcCommand::InspectLogs => { 72 | let client = Client::service_default(); 73 | let logs = client 74 | .inspect_logs() 75 | .await 76 | .map_err(|e| crate::cmds::CommandError::Other(e.into()))?; 77 | for log in logs.logs { 78 | println!("{}", log.trim_matches('\n')); 79 | } 80 | } 81 | RpcCommand::SetDns { dns_servers } => { 82 | let client = Client::service_default(); 83 | client 84 | .set_dns(&NetworkSetDnsReq { 85 | dns_servers: dns_servers 86 | .as_ref() 87 | .map(|v| v.iter().map(Cow::Borrowed).collect()), 88 | }) 89 | .await 90 | .map_err(|e| crate::cmds::CommandError::Other(e.into()))?; 91 | } 92 | } 93 | Ok(()) 94 | } 95 | -------------------------------------------------------------------------------- /nyanpasu_service/src/cmds/server.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::BTreeMap, path::PathBuf, sync::OnceLock}; 2 | 3 | #[cfg(windows)] 4 | use anyhow::Context; 5 | use clap::Args; 6 | use tokio_util::sync::CancellationToken; 7 | use tracing_attributes::instrument; 8 | 9 | use crate::server::consts::RuntimeInfos; 10 | 11 | use super::CommandError; 12 | 13 | #[derive(Args, Debug, Clone)] 14 | pub struct ServerContext { 15 | /// nyanpasu config dir 16 | #[clap(long)] 17 | pub nyanpasu_config_dir: PathBuf, 18 | /// nyanpasu data dir 19 | #[clap(long)] 20 | pub nyanpasu_data_dir: PathBuf, 21 | /// The nyanpasu install directory, allowing to search the sidecar binary 22 | #[clap(long)] 23 | pub nyanpasu_app_dir: PathBuf, 24 | /// run as service 25 | #[clap(long, default_value = "false")] 26 | pub service: bool, 27 | } 28 | 29 | pub static SHUTDOWN_TOKEN: OnceLock = OnceLock::new(); 30 | 31 | pub async fn server_inner( 32 | ctx: ServerContext, 33 | token: CancellationToken, 34 | ) -> Result<(), CommandError> { 35 | nyanpasu_utils::os::kill_by_pid_file( 36 | crate::utils::dirs::service_pid_file(), 37 | // TODO: use common name 38 | Some(&["mihomo", "clash"]), 39 | ) 40 | .await?; 41 | tracing::info!("nyanpasu config dir: {:?}", ctx.nyanpasu_config_dir); 42 | tracing::info!("nyanpasu data dir: {:?}", ctx.nyanpasu_data_dir); 43 | 44 | // Print current envs 45 | let envs: BTreeMap = std::env::vars().collect(); 46 | tracing::info!(environments = ?envs, "collected current envs."); 47 | 48 | // check dirs accessibility 49 | let nyanpasu_config_dir = dunce::canonicalize(&ctx.nyanpasu_config_dir)?; 50 | let nyanpasu_data_dir = dunce::canonicalize(&ctx.nyanpasu_data_dir)?; 51 | let nyanpasu_app_dir = dunce::canonicalize(&ctx.nyanpasu_app_dir)?; 52 | 53 | let service_data_dir = crate::utils::dirs::service_data_dir(); 54 | let service_config_dir = crate::utils::dirs::service_config_dir(); 55 | tracing::info!("suggested service data dir: {:?}", service_data_dir); 56 | tracing::info!("suggested service config dir: {:?}", service_config_dir); 57 | 58 | if !service_data_dir.exists() { 59 | std::fs::create_dir_all(&service_data_dir)?; 60 | } 61 | if !service_config_dir.exists() { 62 | std::fs::create_dir_all(&service_config_dir)?; 63 | } 64 | 65 | // Write current process id to file 66 | if let Err(e) = nyanpasu_utils::os::create_pid_file( 67 | crate::utils::dirs::service_pid_file(), 68 | std::process::id(), 69 | ) 70 | .await 71 | { 72 | tracing::error!("create pid file error: {}", e); 73 | }; 74 | 75 | crate::server::consts::RuntimeInfos::set_infos(RuntimeInfos { 76 | service_data_dir, 77 | service_config_dir, 78 | nyanpasu_config_dir, 79 | nyanpasu_data_dir, 80 | nyanpasu_app_dir, 81 | }); 82 | 83 | #[cfg(windows)] 84 | let sids = crate::utils::acl::read_acl_file() 85 | .await 86 | .context("failed to read acl file")?; 87 | #[cfg(windows)] 88 | let sids_str = &sids.iter().map(|s| s.as_str()).collect::>(); 89 | #[cfg(not(windows))] 90 | let sids_str = (); 91 | 92 | #[cfg(windows)] 93 | tracing::info!(sids = ?sids_str, "Loaded acl file"); 94 | 95 | crate::server::run(token, sids_str).await?; 96 | Ok(()) 97 | } 98 | 99 | #[instrument] 100 | pub async fn server(ctx: ServerContext) -> Result<(), CommandError> { 101 | let token = CancellationToken::new(); 102 | SHUTDOWN_TOKEN.set(token.clone()).unwrap(); 103 | server_inner(ctx, token).await?; 104 | Ok(()) 105 | } 106 | -------------------------------------------------------------------------------- /nyanpasu_service/src/logging.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, anyhow}; 2 | use std::{fs, io::IsTerminal, sync::OnceLock}; 3 | use tracing::level_filters::LevelFilter; 4 | use tracing_appender::{ 5 | non_blocking::{NonBlocking, WorkerGuard}, 6 | rolling::Rotation, 7 | }; 8 | use tracing_log::log_tracer; 9 | use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt}; 10 | 11 | static GUARD: OnceLock = OnceLock::new(); 12 | 13 | fn get_file_appender(max_files: usize) -> Result<(NonBlocking, WorkerGuard)> { 14 | let log_dir = crate::utils::dirs::service_logs_dir(); 15 | let file_appender = tracing_appender::rolling::Builder::new() 16 | .filename_prefix("nyanpasu-service") 17 | .filename_suffix("app.log") 18 | .rotation(Rotation::DAILY) 19 | .max_log_files(max_files) 20 | .build(log_dir)?; 21 | Ok(tracing_appender::non_blocking(file_appender)) 22 | } 23 | 24 | /// initial instance global logger 25 | pub fn init(debug: bool, write_file: bool) -> anyhow::Result<()> { 26 | if write_file { 27 | let log_dir = crate::utils::dirs::service_logs_dir(); 28 | if !log_dir.exists() { 29 | let _ = fs::create_dir_all(&log_dir); 30 | } 31 | } 32 | let (log_level, log_max_files) = { 33 | ( 34 | { 35 | #[cfg(not(feature = "tracing"))] 36 | if debug { 37 | LevelFilter::DEBUG 38 | } else { 39 | LevelFilter::INFO 40 | } 41 | #[cfg(feature = "tracing")] 42 | LevelFilter::TRACE 43 | }, 44 | 7, 45 | ) 46 | }; 47 | let filter = EnvFilter::builder() 48 | .with_default_directive(log_level.into()) 49 | .from_env_lossy(); 50 | 51 | let terminal_layer = fmt::Layer::new() 52 | .with_ansi( 53 | std::io::stdout().is_terminal() 54 | && supports_color::on(supports_color::Stream::Stdout).is_some(), 55 | ) 56 | .compact() 57 | .with_target(false) 58 | .with_file(true) 59 | .with_line_number(true) 60 | .with_writer(std::io::stdout); 61 | 62 | let subscriber = tracing_subscriber::registry(); 63 | #[cfg(feature = "tracing")] 64 | let subscriber = subscriber.with(console_subscriber::spawn()); 65 | let subscriber = subscriber.with(filter).with(terminal_layer); 66 | let file_layer = if write_file { 67 | let (appender, _guard) = get_file_appender(log_max_files)?; 68 | let file_layer = fmt::layer() 69 | .json() 70 | .with_writer(appender) 71 | .with_line_number(true) 72 | .with_file(true); 73 | Some((file_layer, _guard)) 74 | } else { 75 | None 76 | }; 77 | match file_layer { 78 | Some((file_layer, _guard)) => { 79 | // TODO: 改善日记注册逻辑 80 | use crate::server::Logger; 81 | let logger_layer = fmt::layer() 82 | .json() 83 | .with_writer(Logger::global().clone()) 84 | .with_line_number(true) 85 | .with_file(true); 86 | let subscriber = subscriber.with(file_layer).with(logger_layer); 87 | log_tracer::LogTracer::init()?; 88 | tracing::subscriber::set_global_default(subscriber) 89 | .map_err(|x| anyhow!("setup logging error: {}", x))?; 90 | GUARD.set(_guard).ok(); 91 | } 92 | None => { 93 | log_tracer::LogTracer::init()?; 94 | tracing::subscriber::set_global_default(subscriber) 95 | .map_err(|x| anyhow!("setup logging error: {}", x))?; 96 | } 97 | }; 98 | 99 | Ok(()) 100 | } 101 | -------------------------------------------------------------------------------- /nyanpasu_service/src/utils/acl.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use camino::{Utf8Path, Utf8PathBuf}; 3 | 4 | use tokio::{ 5 | fs::{File, OpenOptions}, 6 | io::{AsyncReadExt, AsyncWriteExt}, 7 | }; 8 | use windows::{ 9 | Win32::{ 10 | Foundation::*, 11 | Security::{ 12 | Authorization::{ 13 | ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, 14 | }, 15 | DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SetFileSecurityW, 16 | }, 17 | }, 18 | core::{Free, PCWSTR}, 19 | }; 20 | 21 | const ACL_FILE_NAME: &str = "acl.list"; 22 | 23 | fn get_acl_path() -> Utf8PathBuf { 24 | let service_config_dir = crate::utils::dirs::service_config_dir(); 25 | Utf8PathBuf::from_path_buf(service_config_dir) 26 | .expect("failed to convert service config dir to UTF8PathBuf") 27 | .join(ACL_FILE_NAME) 28 | } 29 | 30 | fn set_file_acl_from_sddl(file: &Utf8Path, sddl: &str) -> anyhow::Result<()> { 31 | let file_path_wide: Vec = file.to_string().encode_utf16().chain(Some(0)).collect(); 32 | let sddl_wide: Vec = sddl.encode_utf16().chain(Some(0)).collect(); 33 | 34 | let mut security_descriptor_ptr = PSECURITY_DESCRIPTOR::default(); 35 | let mut sd_size = 0; 36 | 37 | unsafe { 38 | ConvertStringSecurityDescriptorToSecurityDescriptorW( 39 | PCWSTR::from_raw(sddl_wide.as_ptr()), 40 | SDDL_REVISION_1, 41 | &mut security_descriptor_ptr, 42 | Some(&mut sd_size), 43 | ) 44 | .context("failed to convert sddl to security descriptor")?; 45 | } 46 | 47 | unsafe { 48 | let result = SetFileSecurityW( 49 | PCWSTR::from_raw(file_path_wide.as_ptr()), 50 | DACL_SECURITY_INFORMATION, 51 | security_descriptor_ptr, 52 | ); 53 | 54 | HLOCAL(security_descriptor_ptr.0).free(); 55 | 56 | if !result.as_bool() { 57 | anyhow::bail!("failed to set file acl"); 58 | } 59 | } 60 | 61 | Ok(()) 62 | } 63 | 64 | pub async fn create_acl_file() -> Result<(), anyhow::Error> { 65 | let acl_path = get_acl_path(); 66 | if acl_path.exists() { 67 | return Ok(()); 68 | } 69 | let sddl = 70 | nyanpasu_ipc::utils::acl::generate_windows_security_descriptor::<&str>(&[], None, None) 71 | .context("failed to generate sddl")?; 72 | File::create(&acl_path).await?; 73 | set_file_acl_from_sddl(&acl_path, &sddl)?; 74 | Ok(()) 75 | } 76 | 77 | pub async fn read_acl_file() -> Result, anyhow::Error> { 78 | let acl_path = get_acl_path(); 79 | if !acl_path.exists() { 80 | return Ok(vec![]); 81 | } 82 | let mut file = File::open(&acl_path) 83 | .await 84 | .context("failed to open acl file")?; 85 | let mut s = String::with_capacity(4096); 86 | file.read_to_string(&mut s) 87 | .await 88 | .context("failed to read acl file")?; 89 | let lines = s 90 | .lines() 91 | .filter_map(|line| { 92 | let line = line.trim(); 93 | if line.starts_with("S-") { 94 | Some(line.to_string()) 95 | } else { 96 | None 97 | } 98 | }) 99 | .collect::>(); 100 | Ok(lines) 101 | } 102 | 103 | pub async fn write_acl_file>(list: &[T]) -> Result<(), anyhow::Error> { 104 | let list = list.iter().map(|x| x.as_ref()).collect::>(); 105 | let acl_path = get_acl_path(); 106 | let mut file = OpenOptions::new() 107 | .write(true) 108 | .truncate(true) 109 | .open(&acl_path) 110 | .await 111 | .context("failed to open acl file")?; 112 | file.write_all(list.join("\n").as_bytes()) 113 | .await 114 | .context("failed to write acl file")?; 115 | Ok(()) 116 | } 117 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/client/mod.rs: -------------------------------------------------------------------------------- 1 | use http_body_util::BodyExt; 2 | use hyper::{ 3 | Response as HyperResponse, 4 | body::{Body, Incoming}, 5 | http::Request, 6 | }; 7 | use hyper_util::rt::TokioIo; 8 | use simd_json::Buffers; 9 | use std::error::Error as StdError; 10 | use tokio::io::AsyncReadExt; 11 | 12 | use interprocess::local_socket::tokio::{Stream, prelude::*}; 13 | 14 | pub mod shortcuts; 15 | mod wrapper; 16 | use wrapper::BodyDataStreamExt; 17 | 18 | use crate::api::R; 19 | 20 | #[derive(Debug, thiserror::Error)] 21 | pub enum ClientError<'a> { 22 | #[error("An IO error occurred: {0}")] 23 | Io(#[from] std::io::Error), 24 | #[error("A network error occurred: {0}")] 25 | Hyper(#[from] hyper::Error), 26 | #[error("An error occurred while perform HTTP: {0}")] 27 | Http(#[from] hyper::http::Error), 28 | #[error("An error occurred: {0}")] 29 | ParseFailed(#[from] simd_json::Error), 30 | #[error("An server error respond: {0:?}")] 31 | ServerResponseFailed(R<'a, Option<()>>), 32 | #[error("An error occurred: {0}")] 33 | Other(#[from] anyhow::Error), 34 | } 35 | 36 | pub struct Response { 37 | response: HyperResponse, 38 | } 39 | 40 | pub async fn send_request( 41 | placeholder: &str, 42 | request: Request, 43 | ) -> Result> 44 | where 45 | R: Body + 'static + Send, 46 | R::Data: Send, 47 | R::Error: Into>, 48 | { 49 | let name = crate::utils::get_name(placeholder)?; 50 | let conn = Stream::connect(name).await?; 51 | let io = TokioIo::new(conn); 52 | let (mut sender, conn) = 53 | hyper::client::conn::http1::handshake::, R>(io).await?; 54 | tokio::task::spawn(async move { 55 | if let Err(err) = conn.await { 56 | tracing::error!("An error occurred: {:#?}", err); 57 | } 58 | }); 59 | 60 | let response = sender.send_request(request).await?; 61 | 62 | if response.status().is_client_error() || response.status().is_server_error() { 63 | // if server respond 500. It is also might be custom error respond, so that, let we have a try to parse to body 64 | if response.status() == 500 { 65 | let res = Response { response }; 66 | let r = res.cast_body::>>().await?; 67 | return Err(ClientError::ServerResponseFailed(r)); 68 | } 69 | return Err(ClientError::Other(anyhow::anyhow!( 70 | "Received an error response: {:#?}", 71 | response 72 | ))); 73 | } 74 | Ok(Response { response }) 75 | } 76 | 77 | impl Response { 78 | pub fn get_ref(&self) -> &HyperResponse { 79 | &self.response 80 | } 81 | /// use simd_json to cast the body of the response to a specific type 82 | pub async fn cast_body<'a, T>(self) -> Result> 83 | where 84 | T: for<'de> serde::Deserialize<'de>, 85 | { 86 | let content_length = self.response.headers().get(hyper::header::CONTENT_LENGTH); 87 | let content_length = content_length 88 | .and_then(|v| v.to_str().ok()) 89 | .and_then(|v| v.parse::().ok()) 90 | .unwrap_or(0); 91 | if content_length == 0 { 92 | return Err(ClientError::Other(anyhow::anyhow!( 93 | "No content in response" 94 | ))); 95 | } 96 | let mut buf = Vec::with_capacity(content_length); 97 | let stream = self.response.into_data_stream().into_stream_wrapper(); 98 | let mut reader = tokio_util::io::StreamReader::new(stream); 99 | let n = reader.read_to_end(&mut buf).await?; 100 | if n != content_length { 101 | return Err(ClientError::Other(anyhow::anyhow!( 102 | "Failed to read the entire response" 103 | ))); 104 | } 105 | let mut buffers = Buffers::default(); 106 | Ok(simd_json::serde::from_slice_with_buffers( 107 | &mut buf, 108 | &mut buffers, 109 | )?) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/client/shortcuts.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, sync::OnceLock}; 2 | 3 | use axum::body::Body; 4 | use hyper::{Request, header::CONTENT_TYPE}; 5 | 6 | use crate::{SERVICE_PLACEHOLDER, api}; 7 | 8 | use super::{ClientError, send_request}; 9 | 10 | use std::result::Result as StdResult; 11 | 12 | pub struct Client<'a>(Cow<'a, str>); 13 | 14 | type Result<'a, T, E = ClientError<'a>> = StdResult; 15 | 16 | impl<'a> Client<'a> { 17 | pub fn new(placeholder: &'a str) -> Self { 18 | Self(Cow::Borrowed(placeholder)) 19 | } 20 | 21 | pub fn service_default() -> &'static Client<'static> { 22 | static CLIENT: OnceLock> = OnceLock::new(); 23 | CLIENT.get_or_init(|| Client::new(SERVICE_PLACEHOLDER)) 24 | } 25 | 26 | pub async fn status(&self) -> Result<'_, api::status::StatusResBody<'_>> { 27 | let request = Request::get(api::status::STATUS_ENDPOINT).body(Body::empty())?; 28 | let response = send_request(&self.0, request) 29 | .await? 30 | .cast_body::>() 31 | .await? 32 | .ok()?; 33 | let data = response.data.unwrap(); 34 | Ok(data) 35 | } 36 | 37 | pub async fn start_core(&self, payload: &api::core::start::CoreStartReq<'_>) -> Result<'_, ()> { 38 | let payload = simd_json::serde::to_string(payload)?; 39 | let request = Request::post(api::core::start::CORE_START_ENDPOINT) 40 | .header(CONTENT_TYPE, "application/json") 41 | .body(Body::from(payload))?; 42 | let response = send_request(&self.0, request) 43 | .await? 44 | .cast_body::() 45 | .await?; 46 | response.ok()?; 47 | Ok(()) 48 | } 49 | 50 | pub async fn stop_core(&self) -> Result<'_, ()> { 51 | let request = Request::post(api::core::stop::CORE_STOP_ENDPOINT).body(Body::empty())?; 52 | let response = send_request(&self.0, request) 53 | .await? 54 | .cast_body::() 55 | .await?; 56 | response.ok()?; 57 | Ok(()) 58 | } 59 | 60 | pub async fn restart_core(&self) -> Result<'_, ()> { 61 | let request = 62 | Request::post(api::core::restart::CORE_RESTART_ENDPOINT).body(Body::empty())?; 63 | let response = send_request(&self.0, request) 64 | .await? 65 | .cast_body::() 66 | .await?; 67 | response.ok()?; 68 | Ok(()) 69 | } 70 | 71 | pub async fn inspect_logs(&self) -> Result<'_, api::log::LogsResBody<'_>> { 72 | let request = Request::get(api::log::LOGS_INSPECT_ENDPOINT).body(Body::empty())?; 73 | let response = send_request(&self.0, request) 74 | .await? 75 | .cast_body::>() 76 | .await? 77 | .ok()?; 78 | let data = response.data.unwrap(); 79 | Ok(data) 80 | } 81 | 82 | pub async fn retrieve_logs(&self) -> Result<'_, api::log::LogsResBody<'_>> { 83 | let request = Request::get(api::log::LOGS_RETRIEVE_ENDPOINT).body(Body::empty())?; 84 | let response = send_request(&self.0, request) 85 | .await? 86 | .cast_body::>() 87 | .await? 88 | .ok()?; 89 | let data = response.data.unwrap(); 90 | Ok(data) 91 | } 92 | 93 | pub async fn set_dns( 94 | &self, 95 | payload: &api::network::set_dns::NetworkSetDnsReq<'_>, 96 | ) -> Result<'_, ()> { 97 | let payload = simd_json::serde::to_string(payload)?; 98 | let request = Request::post(api::network::set_dns::NETWORK_SET_DNS_ENDPOINT) 99 | .header(CONTENT_TYPE, "application/json") 100 | .body(Body::from(payload))?; 101 | let response = send_request(&self.0, request) 102 | .await? 103 | .cast_body::() 104 | .await?; 105 | response.ok()?; 106 | Ok(()) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /nyanpasu_service/src/utils/os/user.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | #[cfg(not(windows))] 3 | use nyanpasu_ipc::utils::os::NYANPASU_USER_GROUP; 4 | use tracing_attributes::instrument; 5 | 6 | #[instrument] 7 | pub fn is_nyanpasu_group_exists() -> bool { 8 | #[cfg(windows)] 9 | { 10 | false 11 | } 12 | #[cfg(target_os = "linux")] 13 | { 14 | use std::process::Command; 15 | let output = Command::new("getent") 16 | .arg("group") 17 | .arg(NYANPASU_USER_GROUP) 18 | .output() 19 | .expect("failed to execute process"); 20 | tracing::debug!("output: {:?}", output); 21 | output.status.success() 22 | } 23 | #[cfg(target_os = "macos")] 24 | { 25 | use std::process::Command; 26 | let output = Command::new("dseditgroup") 27 | .arg("-o") 28 | .arg("read") 29 | .arg(NYANPASU_USER_GROUP) 30 | .output() 31 | .expect("failed to execute process"); 32 | tracing::debug!("output: {:?}", output); 33 | output.status.success() 34 | } 35 | } 36 | 37 | #[instrument] 38 | pub fn create_nyanpasu_group() -> Result<(), anyhow::Error> { 39 | #[cfg(windows)] 40 | { 41 | Ok(()) 42 | } 43 | #[cfg(target_os = "linux")] 44 | { 45 | use std::process::Command; 46 | let output = Command::new("groupadd") 47 | .arg(NYANPASU_USER_GROUP) 48 | .output() 49 | .expect("failed to execute process"); 50 | tracing::debug!("output: {:?}", output); 51 | if !output.status.success() { 52 | anyhow::bail!("failed to create nyanpasu group"); 53 | } 54 | Ok(()) 55 | } 56 | #[cfg(target_os = "macos")] 57 | { 58 | use std::process::Command; 59 | let output = Command::new("dseditgroup") 60 | .arg("-o") 61 | .arg("create") 62 | .arg(NYANPASU_USER_GROUP) 63 | .output() 64 | .expect("failed to execute process"); 65 | tracing::debug!("output: {:?}", output); 66 | if !output.status.success() { 67 | anyhow::bail!("failed to create nyanpasu group"); 68 | } 69 | Ok(()) 70 | } 71 | } 72 | 73 | #[instrument] 74 | pub fn is_user_in_nyanpasu_group(username: &str) -> bool { 75 | #[cfg(windows)] 76 | { 77 | false 78 | } 79 | #[cfg(target_os = "linux")] 80 | { 81 | use std::process::Command; 82 | let output = Command::new("id") 83 | .arg("-nG") 84 | .arg(username) 85 | .output() 86 | .expect("failed to execute process"); 87 | let output = String::from_utf8_lossy(&output.stdout); 88 | tracing::debug!("output: {:?}", output); 89 | output.contains(NYANPASU_USER_GROUP) 90 | } 91 | #[cfg(target_os = "macos")] 92 | { 93 | use std::process::Command; 94 | let output = Command::new("dseditgroup") 95 | .arg("-o") 96 | .arg("read") 97 | .arg(NYANPASU_USER_GROUP) 98 | .output() 99 | .expect("failed to execute process"); 100 | tracing::debug!("output: {:?}", output); 101 | if output.status.success() { 102 | let output = String::from_utf8_lossy(&output.stdout); 103 | tracing::debug!("output: {:?}", output); 104 | output.contains(username) 105 | } else { 106 | false 107 | } 108 | } 109 | } 110 | 111 | #[instrument] 112 | pub fn add_user_to_nyanpasu_group(username: &str) -> Result<(), anyhow::Error> { 113 | #[cfg(windows)] 114 | { 115 | Ok(()) 116 | } 117 | #[cfg(target_os = "linux")] 118 | { 119 | use std::process::Command; 120 | let output = Command::new("usermod") 121 | .arg("-aG") 122 | .arg(NYANPASU_USER_GROUP) 123 | .arg(username) 124 | .output() 125 | .expect("failed to execute process"); 126 | tracing::debug!("output: {:?}", output); 127 | if !output.status.success() { 128 | anyhow::bail!("failed to add user to nyanpasu group"); 129 | } 130 | Ok(()) 131 | } 132 | #[cfg(target_os = "macos")] 133 | { 134 | use std::process::Command; 135 | let output = Command::new("dseditgroup") 136 | .arg("-o") 137 | .arg("edit") 138 | .arg("-a") 139 | .arg(username) 140 | .arg("-t") 141 | .arg("user") 142 | .arg(NYANPASU_USER_GROUP) 143 | .output() 144 | .expect("failed to execute process"); 145 | if !output.status.success() { 146 | anyhow::bail!("failed to add user to nyanpasu group"); 147 | } 148 | Ok(()) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /nyanpasu_service/src/win_service.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | use std::{ffi::OsString, io::Result, time::Duration}; 3 | 4 | use nyanpasu_utils::runtime::block_on; 5 | use windows_service::{ 6 | define_windows_service, 7 | service::{ 8 | ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, 9 | ServiceType, 10 | }, 11 | service_control_handler::{self, ServiceControlHandlerResult, ServiceStatusHandle}, 12 | service_dispatcher, 13 | }; 14 | 15 | use crate::consts::SERVICE_LABEL; 16 | 17 | const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; 18 | 19 | pub fn run() -> Result<()> { 20 | service_dispatcher::start(SERVICE_LABEL, ffi_service_main).map_err(std::io::Error::other) 21 | } 22 | 23 | define_windows_service!(ffi_service_main, service_main); 24 | 25 | pub fn service_main(args: Vec) { 26 | if let Err(e) = run_service(args) { 27 | panic!("Error starting service: {e:?}"); 28 | } 29 | } 30 | 31 | struct ServiceHandleGuard(ServiceStatusHandle); 32 | impl Drop for ServiceHandleGuard { 33 | fn drop(&mut self) { 34 | let _ = self.0.set_service_status(ServiceStatus { 35 | current_state: ServiceState::Stopped, 36 | controls_accepted: ServiceControlAccept::empty(), 37 | exit_code: ServiceExitCode::Win32(0), 38 | checkpoint: 0, 39 | wait_hint: Duration::default(), 40 | process_id: None, 41 | service_type: SERVICE_TYPE, 42 | }); 43 | } 44 | } 45 | 46 | fn set_stop_pending(status_handle: &ServiceStatusHandle) -> windows_service::Result<()> { 47 | let next_status = ServiceStatus { 48 | service_type: SERVICE_TYPE, 49 | current_state: ServiceState::StopPending, 50 | exit_code: ServiceExitCode::Win32(0), 51 | checkpoint: 1, 52 | wait_hint: Duration::from_secs(15), 53 | process_id: Some(std::process::id()), 54 | controls_accepted: ServiceControlAccept::empty(), 55 | }; 56 | status_handle.set_service_status(next_status)?; 57 | Ok(()) 58 | } 59 | 60 | pub fn run_service(_arguments: Vec) -> windows_service::Result<()> { 61 | let shutdown_token = tokio_util::sync::CancellationToken::new(); 62 | let shutdown_token_clone = shutdown_token.clone(); 63 | let event_handler = move |control_event| -> ServiceControlHandlerResult { 64 | match control_event { 65 | ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, 66 | ServiceControl::Stop => { 67 | tracing::info!("Received stop event. shutting down..."); 68 | shutdown_token_clone.cancel(); 69 | ServiceControlHandlerResult::NoError 70 | } 71 | ServiceControl::Preshutdown => { 72 | tracing::info!("Received shutdown event. shutting down..."); 73 | shutdown_token_clone.cancel(); 74 | ServiceControlHandlerResult::NoError 75 | } 76 | _ => ServiceControlHandlerResult::NotImplemented, 77 | } 78 | }; 79 | // Register system service event handler 80 | let status_handle = service_control_handler::register(SERVICE_LABEL, event_handler)?; 81 | 82 | let pid = std::process::id(); 83 | let next_status = ServiceStatus { 84 | // Should match the one from system service registry 85 | service_type: SERVICE_TYPE, 86 | // The new state 87 | current_state: ServiceState::Running, 88 | // Accept stop events when running 89 | controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::PRESHUTDOWN, 90 | // Used to report an error when starting or stopping only, otherwise must be zero 91 | exit_code: ServiceExitCode::Win32(0), 92 | // Only used for pending states, otherwise must be zero 93 | checkpoint: 0, 94 | // Only used for pending states, otherwise must be zero 95 | wait_hint: Duration::default(), 96 | process_id: Some(pid), 97 | }; 98 | 99 | // Tell the system that the service is running now 100 | status_handle.set_service_status(next_status)?; 101 | 102 | let guard = ServiceHandleGuard(status_handle); 103 | let handle = std::thread::spawn(move || { 104 | block_on(crate::handler()); 105 | }); 106 | 107 | // Wait for shutdown signal 108 | block_on(shutdown_token.cancelled()); 109 | 110 | // Give the service 15 seconds to stop 111 | set_stop_pending(&status_handle)?; 112 | 113 | // cancel the server handle 114 | if let Some(token) = crate::cmds::SERVER_SHUTDOWN_TOKEN.get() { 115 | tracing::info!("Cancelling server shutdown token"); 116 | token.cancel(); 117 | } 118 | handle.join().unwrap(); 119 | 120 | tracing::info!("Service stopped."); 121 | 122 | // drop the guard to set the service status to stopped 123 | drop(guard); 124 | Ok(()) 125 | } 126 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/server/mod.rs: -------------------------------------------------------------------------------- 1 | use std::result::Result as StdResult; 2 | 3 | use axum::Router; 4 | use interprocess::local_socket::{ 5 | GenericFilePath, ListenerNonblockingMode, ListenerOptions, 6 | tokio::{Listener, Stream as InterProcessStream, prelude::*}, 7 | }; 8 | #[cfg(unix)] 9 | use interprocess::os::unix::local_socket::ListenerOptionsExt; 10 | #[cfg(windows)] 11 | use interprocess::os::windows::{ 12 | local_socket::ListenerOptionsExt, security_descriptor::SecurityDescriptor, 13 | }; 14 | use thiserror::Error; 15 | use tracing_attributes::instrument; 16 | 17 | type Result = StdResult; 18 | 19 | #[derive(Debug, Error)] 20 | pub enum ServerError { 21 | #[error("IO error: {0}")] 22 | Io(#[from] std::io::Error), 23 | #[error("Other error: {0}")] 24 | Other(#[from] anyhow::Error), 25 | } 26 | 27 | pub struct InterProcessListener(Listener, String); 28 | 29 | /// copy from axum::serve::listener.rs 30 | async fn handle_accept_error(e: std::io::Error) { 31 | if is_connection_error(&e) { 32 | return; 33 | } 34 | 35 | // [From `hyper::Server` in 0.14](https://github.com/hyperium/hyper/blob/v0.14.27/src/server/tcp.rs#L186) 36 | // 37 | // > A possible scenario is that the process has hit the max open files 38 | // > allowed, and so trying to accept a new connection will fail with 39 | // > `EMFILE`. In some cases, it's preferable to just wait for some time, if 40 | // > the application will likely close some files (or connections), and try 41 | // > to accept the connection again. If this option is `true`, the error 42 | // > will be logged at the `error` level, since it is still a big deal, 43 | // > and then the listener will sleep for 1 second. 44 | // 45 | // hyper allowed customizing this but axum does not. 46 | tracing::error!("accept error: {e}"); 47 | tokio::time::sleep(std::time::Duration::from_secs(1)).await; 48 | } 49 | 50 | fn is_connection_error(e: &std::io::Error) -> bool { 51 | matches!( 52 | e.kind(), 53 | std::io::ErrorKind::ConnectionRefused 54 | | std::io::ErrorKind::ConnectionAborted 55 | | std::io::ErrorKind::ConnectionReset 56 | ) 57 | } 58 | 59 | impl axum::serve::Listener for InterProcessListener { 60 | type Io = InterProcessStream; 61 | // FIXME: it should be supported by upstream, or waiting for upstream got supported listener trait 62 | type Addr = String; 63 | 64 | async fn accept(&mut self) -> (Self::Io, Self::Addr) { 65 | loop { 66 | match self.0.accept().await { 67 | Ok(stream) => return (stream, self.1.clone()), 68 | Err(e) => handle_accept_error(e).await, 69 | } 70 | } 71 | } 72 | 73 | #[inline] 74 | fn local_addr(&self) -> tokio::io::Result { 75 | Ok(self.1.clone()) 76 | } 77 | } 78 | 79 | #[instrument(skip(with_graceful_shutdown))] 80 | pub async fn create_server( 81 | placeholder: &str, 82 | app: Router, 83 | with_graceful_shutdown: Option + Send + 'static>, 84 | #[cfg(windows)] sids: &[&str], 85 | #[cfg(not(windows))] sids: (), 86 | ) -> Result<()> { 87 | let name_str = crate::utils::get_name_string(placeholder); 88 | let name = name_str.as_str().to_fs_name::()?; 89 | #[cfg(unix)] 90 | { 91 | crate::utils::remove_socket_if_exists(placeholder).await?; 92 | } 93 | tracing::debug!("socket name: {:?}", name); 94 | let options = ListenerOptions::new() 95 | .name(name) 96 | .nonblocking(ListenerNonblockingMode::Both); 97 | #[cfg(windows)] 98 | let options = { 99 | use anyhow::Context; 100 | use widestring::U16CString; 101 | let sdsf = crate::utils::acl::generate_windows_security_descriptor(sids, None, None) 102 | .context("failed to generate sdsf")?; 103 | let sdsf = U16CString::from_str(&sdsf).context("failed to convert sdsf to u16cstring")?; 104 | let sw = SecurityDescriptor::deserialize(&sdsf)?; 105 | options.security_descriptor(sw) 106 | }; 107 | // allow owner and group to read and write 108 | #[cfg(unix)] 109 | let options = options.mode({ 110 | #[cfg(target_os = "linux")] 111 | { 112 | 0o664 as u32 113 | } 114 | #[cfg(not(target_os = "linux"))] 115 | { 116 | 0o664 as u16 117 | } 118 | }); 119 | 120 | let listener = options.create_tokio()?; 121 | let listener = InterProcessListener(listener, name_str); 122 | // change the socket group 123 | tracing::debug!("changing socket group and permissions..."); 124 | crate::utils::os::change_socket_group(placeholder)?; 125 | crate::utils::os::change_socket_mode(placeholder)?; 126 | 127 | tracing::debug!("mounting service..."); 128 | let server = axum::serve(listener, app); 129 | match with_graceful_shutdown { 130 | Some(graceful_shutdown) => server.with_graceful_shutdown(graceful_shutdown).await?, 131 | None => server.await?, 132 | }; 133 | Ok(()) 134 | } 135 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build_unix: 8 | name: Build ${{ matrix.profile.target }} 9 | strategy: 10 | matrix: 11 | profile: 12 | - os: ubuntu-latest 13 | target: x86_64-unknown-linux-gnu 14 | - os: ubuntu-latest 15 | target: x86_64-unknown-linux-musl 16 | - os: ubuntu-latest 17 | target: i686-unknown-linux-gnu 18 | - os: ubuntu-latest 19 | target: i686-unknown-linux-musl 20 | - os: ubuntu-latest 21 | target: aarch64-unknown-linux-gnu 22 | - os: ubuntu-latest 23 | target: aarch64-unknown-linux-musl 24 | - os: ubuntu-latest 25 | target: armv7-unknown-linux-gnueabi 26 | - os: ubuntu-latest 27 | target: armv7-unknown-linux-musleabi 28 | - os: ubuntu-latest 29 | target: armv7-unknown-linux-musleabihf 30 | - os: ubuntu-latest 31 | target: armv7-unknown-linux-gnueabihf 32 | - os: macos-latest 33 | target: x86_64-apple-darwin 34 | - os: macos-latest 35 | target: aarch64-apple-darwin 36 | runs-on: ${{ matrix.profile.os }} 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v4 40 | with: 41 | token: ${{ secrets.GITHUB_TOKEN }} 42 | - name: Install Rust toolchain and target toolchain 43 | run: | 44 | rustup toolchain install nightly --profile minimal 45 | rustup target add ${{ matrix.profile.target }} --toolchain nightly 46 | - name: Install coreutils for macOS 47 | if: contains(matrix.profile.os, 'macos') 48 | run: brew install coreutils 49 | - name: Setup Cross 50 | if: contains(matrix.profile.target, 'musl') 51 | run: | 52 | curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash 53 | cargo binstall cross -y 54 | - uses: goto-bus-stop/setup-zig@v2 55 | name: Setup Zig 56 | if: contains(matrix.profile.target, 'musl') == false 57 | - name: Setup Cargo zigbuild 58 | if: contains(matrix.profile.target, 'musl') == false 59 | run: | 60 | curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash 61 | cargo binstall cargo-zigbuild -y 62 | - name: Build 63 | if: contains(matrix.profile.target, 'musl') == false 64 | run: cargo +nightly zigbuild --release --target ${{ matrix.profile.target }} 65 | - name: Build with Cross 66 | if: contains(matrix.profile.target, 'musl') 67 | run: cross +nightly build --release --target ${{ matrix.profile.target }} 68 | - name: Archive 69 | run: | 70 | mkdir -p target/release 71 | cp target/${{ matrix.profile.target }}/release/nyanpasu-service target/release/nyanpasu-service 72 | tar -czf nyanpasu-service-${{ matrix.profile.target }}.tar.gz target/release/nyanpasu-service 73 | - name: Calc the archive signature 74 | run: sha256sum nyanpasu-service-${{ matrix.profile.target }}.tar.gz > nyanpasu-service-${{ matrix.profile.target }}.tar.gz.sha256 75 | - name: Upload Artifacts 76 | uses: actions/upload-artifact@v4 77 | with: 78 | name: nyanpasu-service-${{ matrix.profile.target }} 79 | path: | 80 | nyanpasu-service-${{ matrix.profile.target }}.tar.gz 81 | nyanpasu-service-${{ matrix.profile.target }}.tar.gz.sha256 82 | 83 | release_windows: 84 | name: Build ${{ matrix.profile.target }} 85 | strategy: 86 | matrix: 87 | profile: 88 | - os: windows-latest 89 | target: x86_64-pc-windows-msvc 90 | - os: windows-latest 91 | target: aarch64-pc-windows-msvc 92 | - os: windows-latest 93 | target: i686-pc-windows-msvc 94 | runs-on: ${{ matrix.profile.os }} 95 | steps: 96 | - name: Checkout 97 | uses: actions/checkout@v4 98 | with: 99 | token: ${{ secrets.GITHUB_TOKEN }} 100 | - name: Install Rust toolchain and target toolchain 101 | run: rustup toolchain install nightly --profile minimal && rustup target add ${{ matrix.profile.target }} --toolchain nightly 102 | - name: Build 103 | run: cargo +nightly build --release --target ${{ matrix.profile.target }} 104 | - name: Archive 105 | run: | 106 | New-Item -ItemType Directory -Force -Path target\release 107 | Copy-Item target\${{ matrix.profile.target }}\release\nyanpasu-service.exe target\release\nyanpasu-service.exe 108 | Set-Location target\release 109 | Compress-Archive -Path nyanpasu-service.exe -DestinationPath ..\..\nyanpasu-service-${{ matrix.profile.target }}.zip 110 | Set-Location ..\.. 111 | - name: Calc the archive signature 112 | shell: pwsh 113 | run: Get-FileHash nyanpasu-service-${{ matrix.profile.target }}.zip -Algorithm SHA256 | Format-List > nyanpasu-service-${{ matrix.profile.target }}.zip.sha256 114 | - name: Upload Artifacts 115 | uses: actions/upload-artifact@v4 116 | with: 117 | name: nyanpasu-service-${{ matrix.profile.target }} 118 | path: | 119 | nyanpasu-service-${{ matrix.profile.target }}.zip 120 | nyanpasu-service-${{ matrix.profile.target }}.zip.sha256 121 | -------------------------------------------------------------------------------- /nyanpasu_service/src/cmds/install.rs: -------------------------------------------------------------------------------- 1 | use std::{env::current_exe, ffi::OsString, path::PathBuf}; 2 | 3 | use service_manager::{ServiceInstallCtx, ServiceLabel, ServiceStatus, ServiceStatusCtx}; 4 | 5 | use crate::consts::{APP_NAME, SERVICE_LABEL}; 6 | 7 | use super::CommandError; 8 | 9 | #[derive(Debug, clap::Args)] 10 | pub struct InstallCommand { 11 | /// The user who will run the service 12 | #[clap(long)] 13 | user: String, // Should manual specify because the runner should be administrator/root 14 | /// The nyanpasu data directory 15 | #[clap(long)] 16 | nyanpasu_data_dir: PathBuf, 17 | /// The nyanpasu config directory 18 | #[clap(long)] 19 | nyanpasu_config_dir: PathBuf, 20 | /// The nyanpasu install directory, allowing to search the sidecar binary 21 | #[clap(long)] 22 | nyanpasu_app_dir: PathBuf, 23 | } 24 | 25 | pub fn install(ctx: InstallCommand) -> Result<(), CommandError> { 26 | tracing::info!("nyanpasu data dir: {:?}", ctx.nyanpasu_data_dir); 27 | tracing::info!("nyanpasu config dir: {:?}", ctx.nyanpasu_config_dir); 28 | let label: ServiceLabel = SERVICE_LABEL.parse()?; 29 | let manager = crate::utils::get_service_manager()?; 30 | // before we install the service, we need to check if the service is already installed 31 | if !matches!( 32 | manager.status(ServiceStatusCtx { 33 | label: label.clone(), 34 | })?, 35 | ServiceStatus::NotInstalled 36 | ) { 37 | return Err(CommandError::ServiceAlreadyInstalled); 38 | } 39 | 40 | let service_data_dir = crate::utils::dirs::service_data_dir(); 41 | let service_config_dir = crate::utils::dirs::service_config_dir(); 42 | tracing::info!("suggested service data dir: {:?}", service_data_dir); 43 | tracing::info!("suggested service config dir: {:?}", service_config_dir); 44 | // copy nyanpasu service binary to the service data dir 45 | if !service_data_dir.exists() { 46 | std::fs::create_dir_all(&service_data_dir)?; 47 | } 48 | if !service_config_dir.exists() { 49 | std::fs::create_dir_all(&service_config_dir)?; 50 | } 51 | let binary_name = format!("{}{}", APP_NAME, std::env::consts::EXE_SUFFIX); 52 | #[cfg(not(target_os = "linux"))] 53 | let service_binary = service_data_dir.join(binary_name); 54 | #[cfg(target_os = "linux")] 55 | let service_binary = PathBuf::from("/usr/bin").join(binary_name); 56 | let current_binary = current_exe()?; 57 | // Prevent both src and target binary are the same 58 | // It possible happens when a app was installed by a linux package manager 59 | if current_binary != service_binary { 60 | tracing::info!("Copying service binary to: {:?}", service_binary); 61 | std::fs::copy(current_binary, &service_binary)?; 62 | } 63 | 64 | // create nyanpasu group to ensure share unix socket access 65 | #[cfg(not(windows))] 66 | { 67 | tracing::info!("checking nyanpasu group exists..."); 68 | if !crate::utils::os::user::is_nyanpasu_group_exists() { 69 | tracing::info!("nyanpasu group not exists, creating..."); 70 | crate::utils::os::user::create_nyanpasu_group()?; 71 | } 72 | tracing::info!("checking whether user is in nyanpasu group..."); 73 | if !crate::utils::os::user::is_user_in_nyanpasu_group(&ctx.user) { 74 | tracing::info!("adding user to nyanpasu group..."); 75 | crate::utils::os::user::add_user_to_nyanpasu_group(&ctx.user)?; 76 | } 77 | } 78 | tracing::info!("Working dir: {:?}", service_data_dir); 79 | let mut envs = Vec::new(); 80 | #[cfg(windows)] 81 | { 82 | let rt = tokio::runtime::Runtime::new().unwrap(); 83 | tracing::info!("Creating acl file..."); 84 | rt.block_on(crate::utils::acl::create_acl_file())?; 85 | tracing::info!("Reading acl file..."); 86 | let mut list = 87 | std::collections::BTreeSet::from_iter(rt.block_on(crate::utils::acl::read_acl_file())?); 88 | list.insert(ctx.user); 89 | let list = list.into_iter().collect::>(); 90 | tracing::info!(list = ?list, "Writing acl file..."); 91 | rt.block_on(crate::utils::acl::write_acl_file(list.as_slice()))?; 92 | } 93 | if let Ok(home) = std::env::var("HOME") { 94 | envs.push(("HOME".to_string(), home)); 95 | } 96 | tracing::info!("Installing service..."); 97 | manager.install(ServiceInstallCtx { 98 | label: label.clone(), 99 | program: service_binary, 100 | args: vec![ 101 | OsString::from("server"), 102 | OsString::from("--nyanpasu-data-dir"), 103 | ctx.nyanpasu_data_dir.into(), 104 | OsString::from("--nyanpasu-config-dir"), 105 | ctx.nyanpasu_config_dir.into(), 106 | OsString::from("--nyanpasu-app-dir"), 107 | ctx.nyanpasu_app_dir.into(), 108 | OsString::from("--service"), 109 | ], 110 | contents: None, 111 | username: None, // because we just need to run the service as root 112 | working_directory: Some(service_data_dir), 113 | environment: Some(envs), 114 | autostart: true, 115 | disable_restart_on_failure: false, 116 | })?; 117 | // Confirm the service is installed 118 | if matches!( 119 | manager.status(ServiceStatusCtx { label })?, 120 | ServiceStatus::NotInstalled 121 | ) { 122 | tracing::error!("Service install failed"); 123 | return Err(CommandError::Other(anyhow::anyhow!( 124 | "Service install failed" 125 | ))); 126 | } 127 | tracing::info!("Service installed"); 128 | Ok(()) 129 | } 130 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | [changelog] 5 | # changelog header 6 | header = """ 7 | # Changelog\n 8 | All notable changes to this project will be documented in this file.\n 9 | """ 10 | # template for the changelog body 11 | # https://keats.github.io/tera/docs/#introduction 12 | body = """ 13 | {% set whitespace = " " %} 14 | {% if version %}\ 15 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 16 | {% else %}\ 17 | ## [unreleased] 18 | {% endif %}\ 19 | {% for group, commits in commits | filter(attribute="breaking", value=true) | group_by(attribute="group") %} 20 | ### {{ group | upper_first }} 21 | {% for commit in commits | filter(attribute="scope") | sort(attribute="scope") %} 22 | - **{{ commit.scope | trim_end}}:**{{ whitespace }}{{ commit.message | upper_first | trim_end }}\ 23 | {% if commit.github.username %} by @{{ commit.github.username }} {% else %} by {{ commit.author.name }} {%- endif -%} 24 | {% if commit.github.pr_number %} in \ 25 | [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \ 26 | {%- endif %} 27 | {% endfor %}\ 28 | {% for commit in commits %}{% if not commit.scope %} 29 | - {{ commit.message | upper_first | trim_end }}\ 30 | {% if commit.github.username %} by @{{ commit.github.username }} {% else %} by {{ commit.author.name }} {%- endif -%} 31 | {% if commit.github.pr_number %} in \ 32 | [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \ 33 | {%- endif %} 34 | {% else %}{%- endif -%} 35 | {% endfor %} 36 | {% endfor %} 37 | {% for group, commits in commits | filter(attribute="breaking", value=false) | group_by(attribute="group") %} 38 | ### {{ group | upper_first }} 39 | {% for commit in commits | filter(attribute="scope") | sort(attribute="scope") %} 40 | - **{{ commit.scope | trim_end}}:**{{ whitespace }}{{ commit.message | upper_first | trim_end }}\ 41 | {% if commit.github.username %} by @{{ commit.github.username }} {% else %} by {{ commit.author.name }} {%- endif -%} 42 | {% if commit.github.pr_number %} in \ 43 | [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \ 44 | {%- endif %} 45 | {% endfor %}\ 46 | {% for commit in commits %}{% if not commit.scope %} 47 | - {{ commit.message | upper_first | trim_end }}\ 48 | {% if commit.github.username %} by @{{ commit.github.username }} {% else %} by {{ commit.author.name }} {%- endif -%} 49 | {% if commit.github.pr_number %} in \ 50 | [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \ 51 | {%- endif %} 52 | {% else %}{%- endif -%} 53 | {% endfor %} 54 | {% endfor %}\n 55 | 56 | {%- if github -%} 57 | 58 | ----------------- 59 | 60 | {% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} 61 | {% raw %}\n{% endraw -%} 62 | ## New Contributors 63 | {%- endif %}\ 64 | {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} 65 | * @{{ contributor.username }} made their first contribution 66 | {%- if contributor.pr_number %} in \ 67 | [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ 68 | {%- endif %} 69 | {%- endfor -%} 70 | 71 | {% if version %} 72 | {% if previous.version %} 73 | **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }} 74 | {% endif %} 75 | {% else -%} 76 | {% raw %}\n{% endraw %} 77 | {% endif %} 78 | {% endif %} 79 | 80 | {%- macro remote_url() -%} 81 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 82 | {%- endmacro -%} 83 | """ 84 | # template for the changelog footer 85 | footer = """""" 86 | # remove the leading and trailing whitespace from the templates 87 | trim = true 88 | 89 | [git] 90 | # parse the commits based on https://www.conventionalcommits.org 91 | conventional_commits = true 92 | # filter out the commits that are not conventional 93 | filter_unconventional = false 94 | # process each line of a commit as an individual commit 95 | split_commits = false 96 | # regex for parsing and grouping commits 97 | commit_parsers = [ 98 | { field = "author.name", pattern = "renovate\\[bot\\]", group = "Renovate", skip = true }, 99 | { field = "scope", pattern = "manifest", message = "^chore", skip = true }, 100 | { field = "breaking", pattern = "true", group = "💥 Breaking Changes" }, 101 | { message = "^feat", group = "✨ Features" }, 102 | { message = "^fix", group = "🐛 Bug Fixes" }, 103 | { message = "^doc", group = "📚 Documentation" }, 104 | { message = "^perf", group = "⚡ Performance Improvements" }, 105 | { message = "^refactor", group = "🔨 Refactor" }, 106 | { message = "^style", group = "💅 Styling" }, 107 | { message = "^test", group = "✅ Testing" }, 108 | { message = "^chore\\(release\\): prepare for", skip = true }, 109 | { message = "^chore: bump version", skip = true }, 110 | { message = "^chore", group = "🧹 Miscellaneous Tasks" }, 111 | { body = ".*security", group = "🛡️ Security" }, 112 | { body = ".*", group = "Other (unconventional)", skip = true }, 113 | ] 114 | # protect breaking changes from being skipped due to matching a skipping commit_parser 115 | protect_breaking_commits = false 116 | # filter out the commits that are not matched by commit parsers 117 | filter_commits = false 118 | # regex for matching git tags 119 | tag_pattern = "v[0-9].*" 120 | # regex for skipping tags 121 | skip_tags = "v0.1.0-beta.1" 122 | # regex for ignoring tags 123 | ignore_tags = "" 124 | # sort the tags topologically 125 | topo_order = false 126 | # sort the commits inside sections by oldest/newest order 127 | sort_commits = "newest" 128 | -------------------------------------------------------------------------------- /nyanpasu_service/src/cmds/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::logging; 2 | use clap::{Parser, Subcommand}; 3 | 4 | mod install; 5 | mod restart; 6 | mod rpc; 7 | mod server; 8 | mod start; 9 | mod status; 10 | mod stop; 11 | mod uninstall; 12 | mod update; 13 | 14 | pub use server::SHUTDOWN_TOKEN as SERVER_SHUTDOWN_TOKEN; 15 | 16 | /// Nyanpasu Service, a privileged service for managing the core service. 17 | /// 18 | /// The main entry point for the service, Other commands are the control plane for the service. 19 | /// 20 | /// rpc subcommands are shortcuts for client rpc calls, 21 | /// It is useful for testing and debugging service rpc calls. 22 | #[derive(Parser)] 23 | #[command(version, author, about, long_about, disable_version_flag = true)] 24 | struct Cli { 25 | /// Enable verbose logging 26 | #[clap(short = 'V', long, default_value = "false")] 27 | verbose: bool, 28 | 29 | /// Print the version 30 | #[clap(short, long, default_value = "false")] 31 | version: bool, 32 | 33 | #[command(subcommand)] 34 | command: Option, 35 | } 36 | 37 | #[derive(Subcommand)] 38 | enum Commands { 39 | /// Install the service 40 | Install(install::InstallCommand), 41 | /// Uninstall the service 42 | Uninstall, 43 | /// Start the service 44 | Start, 45 | /// Stop the service 46 | Stop, 47 | /// Restart the service 48 | Restart, 49 | /// Run the server. It should be called by the service manager. 50 | Server(server::ServerContext), // The main entry point for the service, other commands are the control plane for the service 51 | /// Get the status of the service 52 | Status(status::StatusCommand), 53 | /// Update the service 54 | Update, 55 | /// RPC commands, a shortcut for client rpc calls 56 | #[command(subcommand)] 57 | Rpc(rpc::RpcCommand), 58 | } 59 | 60 | #[derive(thiserror::Error, Debug)] 61 | pub enum CommandError { 62 | #[error("permission denied")] 63 | PermissionDenied, 64 | #[error("service not installed")] 65 | ServiceNotInstalled, 66 | #[error("service not running")] 67 | ServiceAlreadyInstalled, 68 | #[error("service not running")] 69 | ServiceAlreadyStopped, 70 | #[error("service already running")] 71 | ServiceAlreadyRunning, 72 | #[error("join error: {0}")] 73 | JoinError(#[from] tokio::task::JoinError), 74 | #[error("io error: {0}")] 75 | Io( 76 | #[from] 77 | #[backtrace] 78 | std::io::Error, 79 | ), 80 | #[error("serde error: {0}")] 81 | SimdError( 82 | #[from] 83 | #[backtrace] 84 | simd_json::Error, 85 | ), 86 | #[error(transparent)] 87 | Other(#[from] anyhow::Error), 88 | } 89 | 90 | pub async fn process() -> Result<(), CommandError> { 91 | let cli = Cli::parse(); 92 | if cli.version { 93 | print_version(); 94 | } 95 | 96 | if !matches!( 97 | cli.command, 98 | Some(Commands::Status(_)) | Some(Commands::Rpc(_)) | None 99 | ) && !crate::utils::must_check_elevation() 100 | { 101 | return Err(CommandError::PermissionDenied); 102 | } 103 | if matches!(cli.command, Some(Commands::Server(_))) { 104 | logging::init(cli.verbose, true)?; 105 | } else { 106 | logging::init(cli.verbose, false)?; 107 | } 108 | 109 | match cli.command { 110 | Some(Commands::Install(ctx)) => { 111 | Ok(tokio::task::spawn_blocking(move || install::install(ctx)).await??) 112 | } 113 | Some(Commands::Uninstall) => Ok(tokio::task::spawn_blocking(uninstall::uninstall).await??), 114 | Some(Commands::Start) => Ok(tokio::task::spawn_blocking(start::start).await??), 115 | Some(Commands::Stop) => Ok(tokio::task::spawn_blocking(stop::stop).await??), 116 | Some(Commands::Restart) => Ok(tokio::task::spawn_blocking(restart::restart).await??), 117 | Some(Commands::Server(ctx)) => { 118 | server::server(ctx).await?; 119 | Ok(()) 120 | } 121 | Some(Commands::Status(ctx)) => Ok(status::status(ctx).await?), 122 | Some(Commands::Update) => { 123 | update::update().await?; 124 | Ok(()) 125 | } 126 | Some(Commands::Rpc(ctx)) => { 127 | rpc::rpc(ctx).await?; 128 | Ok(()) 129 | } 130 | None => { 131 | eprintln!("No command specified"); 132 | Ok(()) 133 | } 134 | } 135 | } 136 | 137 | pub fn print_version() { 138 | use crate::consts::*; 139 | use ansi_str::AnsiStr; 140 | use chrono::{DateTime, Utc}; 141 | use colored::*; 142 | use timeago::Formatter; 143 | 144 | let now = Utc::now(); 145 | let formatter = Formatter::new(); 146 | let commit_time = 147 | formatter.convert_chrono(DateTime::parse_from_rfc3339(COMMIT_DATE).unwrap(), now); 148 | let commit_time_width = commit_time.len() + COMMIT_DATE.len() + 3; 149 | let build_time = 150 | formatter.convert_chrono(DateTime::parse_from_rfc3339(BUILD_DATE).unwrap(), now); 151 | let build_time_width = build_time.len() + BUILD_DATE.len() + 3; 152 | let commit_info_width = COMMIT_HASH.len() + COMMIT_AUTHOR.len() + 4; 153 | let col_width = commit_info_width 154 | .max(commit_time_width) 155 | .max(build_time_width) 156 | .max(BUILD_PLATFORM.len()) 157 | .max(RUSTC_VERSION.len()) 158 | .max(LLVM_VERSION.len()) 159 | + 2; 160 | let header_width = col_width + 16; 161 | println!( 162 | "{} v{} ({} Build)\n", 163 | APP_NAME, 164 | APP_VERSION, 165 | BUILD_PROFILE.yellow() 166 | ); 167 | println!("╭{:─^width$}╮", " Build Information ", width = header_width); 168 | 169 | let mut line = format!("{} by {}", COMMIT_HASH.green(), COMMIT_AUTHOR.blue()); 170 | let mut pad = col_width - line.ansi_strip().len(); 171 | println!("│{:>14}: {}{}│", "Commit Info", line, " ".repeat(pad)); 172 | 173 | line = format!("{} ({})", commit_time.red(), COMMIT_DATE.cyan()); 174 | pad = col_width - line.ansi_strip().len(); 175 | println!("│{:>14}: {}{}│", "Commit Time", line, " ".repeat(pad)); 176 | 177 | line = format!("{} ({})", build_time.red(), BUILD_DATE.cyan()); 178 | pad = col_width - line.ansi_strip().len(); 179 | println!("│{:>14}: {}{}│", "Build Time", line, " ".repeat(pad)); 180 | 181 | println!( 182 | "│{:>14}: {:14}: {:14}: {: Vec { 28 | OsStr::new(s).encode_wide().chain(Some(0)).collect() 29 | } 30 | 31 | /// 获取错误描述 32 | fn get_error_description(error_code: u32) -> &'static str { 33 | match error_code { 34 | 2 => "文件不存在 (ERROR_FILE_NOT_FOUND)", 35 | 5 => "访问被拒绝 (ERROR_ACCESS_DENIED)", 36 | 231 => "管道忙碌 (ERROR_PIPE_BUSY)", 37 | _ => "未知错误", 38 | } 39 | } 40 | 41 | /// 方法1: 使用 GetNamedSecurityInfo 直接获取 SDDL 42 | pub fn get_sddl_direct(pipe_name: &str) -> anyhow::Result { 43 | let pipe_path = format!("\\\\.\\pipe\\{pipe_name}"); 44 | 45 | println!("尝试直接获取管道 '{pipe_name}' 的 SDDL..."); 46 | println!("管道路径: {pipe_path}"); 47 | 48 | let pipe_path_wide = Self::to_wide_string(&pipe_path); 49 | 50 | let security_info = 51 | OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION; 52 | 53 | unsafe { 54 | let handle = CreateFileW( 55 | PCWSTR::from_raw(pipe_path_wide.as_ptr()), 56 | GENERIC_READ.0, // 只需要读权限来获取安全信息 57 | FILE_SHARE_READ | FILE_SHARE_WRITE, 58 | None, 59 | OPEN_EXISTING, 60 | windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES(0), 61 | None, 62 | ); 63 | 64 | let handle = match handle { 65 | Ok(h) if h != INVALID_HANDLE_VALUE => { 66 | println!("✓ 成功打开管道句柄"); 67 | h 68 | } 69 | Ok(_) => { 70 | return Err(anyhow::anyhow!("failed to create file: empty handle")); 71 | } 72 | Err(e) => { 73 | return Err(e).context("failed to create file"); 74 | } 75 | }; 76 | 77 | let mut security_descriptor_ptr = PSECURITY_DESCRIPTOR::default(); 78 | println!("正在获取安全描述符..."); 79 | 80 | let security_info = 81 | DACL_SECURITY_INFORMATION | OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION; 82 | 83 | // 调用 GetNamedSecurityInfo 84 | let result = GetSecurityInfo( 85 | handle, 86 | SE_KERNEL_OBJECT, // 命名管道被视为文件对象 87 | security_info, 88 | None, // psidOwner 89 | None, // psidGroup 90 | None, // ppDacl 91 | None, // ppSacl 92 | Some(&mut security_descriptor_ptr), // ppSecurityDescriptor 93 | ); 94 | 95 | if result.is_err() { 96 | let e = windows::core::Error::from_hresult(result.to_hresult()); 97 | return Err(anyhow::anyhow!( 98 | "GetSecurityInfo 失败: {:#x} - {} ", 99 | result.0, 100 | e, 101 | )); 102 | } 103 | 104 | if security_descriptor_ptr.is_invalid() { 105 | return Err(anyhow::anyhow!("获取的安全描述符无效")); 106 | } 107 | 108 | println!("✓ 成功获取安全描述符"); 109 | 110 | // 转换为 SDDL 111 | let mut sddl_string = PSTR::null(); 112 | ConvertSecurityDescriptorToStringSecurityDescriptorA( 113 | security_descriptor_ptr, 114 | SDDL_REVISION_1, 115 | security_info, 116 | &mut sddl_string, 117 | None, 118 | ) 119 | .context("failed to convert security descriptor to string")?; 120 | 121 | { 122 | // 释放安全描述符内存 123 | HLOCAL(security_descriptor_ptr.0).free(); 124 | } 125 | 126 | // 转换为 Rust 字符串 127 | let sddl = sddl_string.to_string()?; 128 | 129 | println!("✓ 成功获取 SDDL: {sddl}"); 130 | Ok(sddl) 131 | } 132 | } 133 | 134 | pub fn check_pipe_exists(pipe_name: &str) -> bool { 135 | use windows::Win32::Storage::FileSystem::{CreateFileW, OPEN_EXISTING}; 136 | 137 | let pipe_path = format!("\\\\.\\pipe\\{pipe_name}"); 138 | let pipe_path_wide = Self::to_wide_string(&pipe_path); 139 | 140 | unsafe { 141 | let handle = CreateFileW( 142 | PCWSTR::from_raw(pipe_path_wide.as_ptr()), 143 | 0, // 不需要任何访问权限,只是检查存在性 144 | FILE_SHARE_READ | FILE_SHARE_WRITE, 145 | None, 146 | OPEN_EXISTING, 147 | windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES(0), 148 | None, 149 | ); 150 | 151 | match handle { 152 | Ok(h) if h != INVALID_HANDLE_VALUE => { 153 | let _ = CloseHandle(h); 154 | true 155 | } 156 | _ => false, 157 | } 158 | } 159 | } 160 | 161 | /// 检查常见的系统管道 162 | pub fn scan_common_pipes() -> Result> { 163 | let common_pipes = vec![ 164 | "lsass", 165 | "winreg", 166 | "wkssvc", 167 | "trkwks", 168 | "srvsvc", 169 | "samr", 170 | "lsarpc", 171 | "netlogon", 172 | "spoolss", 173 | "atsvc", 174 | "browser", 175 | "keysvc", 176 | "protected_storage", 177 | "scerpc", 178 | ]; 179 | 180 | let mut accessible_pipes = Vec::new(); 181 | 182 | println!("\n=== 扫描常见系统管道 ==="); 183 | 184 | for pipe_name in common_pipes { 185 | print!("检查管道 '{pipe_name}' ... "); 186 | 187 | // 尝试方法1 188 | match Self::get_sddl_direct(pipe_name) { 189 | Ok(sddl) => { 190 | println!("✓ 可访问"); 191 | println!(" SDDL: {sddl}"); 192 | accessible_pipes.push(pipe_name.to_string()); 193 | } 194 | Err(e) => { 195 | eprintln!("✗ 不可访问: {e}"); 196 | } 197 | } 198 | } 199 | 200 | Ok(accessible_pipes) 201 | } 202 | } 203 | 204 | /// 检查是否以管理员身份运行 205 | #[cfg(windows)] 206 | fn is_running_as_admin() -> bool { 207 | use windows::Win32::{ 208 | Foundation::TRUE, 209 | Security::{GetTokenInformation, TokenElevation}, 210 | System::Threading::{GetCurrentProcess, OpenProcessToken}, 211 | }; 212 | 213 | unsafe { 214 | let mut token = HANDLE::default(); 215 | if OpenProcessToken( 216 | GetCurrentProcess(), 217 | windows::Win32::Security::TOKEN_QUERY, 218 | &mut token, 219 | ) 220 | .is_err() 221 | { 222 | return false; 223 | } 224 | 225 | let mut elevation = windows::Win32::Security::TOKEN_ELEVATION { TokenIsElevated: 0 }; 226 | let mut return_length = 0u32; 227 | 228 | if GetTokenInformation( 229 | token, 230 | TokenElevation, 231 | Some(&mut elevation as *mut _ as *mut std::ffi::c_void), 232 | std::mem::size_of::() as u32, 233 | &mut return_length, 234 | ) 235 | .is_ok() 236 | { 237 | let _ = CloseHandle(token); 238 | return elevation.TokenIsElevated == TRUE.0 as u32; 239 | } 240 | 241 | let _ = CloseHandle(token); 242 | false 243 | } 244 | } 245 | 246 | #[cfg(windows)] 247 | fn main() -> Result<()> { 248 | println!("=== Rust 命名管道 ACL 获取工具 ==="); 249 | println!("使用 windows crate"); 250 | 251 | // 检查管理员权限 252 | let is_admin = is_running_as_admin(); 253 | println!("管理员权限: {}", if is_admin { "✓" } else { "✗" }); 254 | 255 | if !is_admin { 256 | println!("⚠️ 建议以管理员身份运行以获得更好的结果"); 257 | } 258 | 259 | println!(); 260 | 261 | // 测试特定管道 262 | let test_pipes = vec!["nyanpasu_ipc"]; 263 | 264 | for pipe_name in test_pipes { 265 | println!("--- 测试管道: {pipe_name} ---"); 266 | if !NamedPipeACL::check_pipe_exists(pipe_name) { 267 | println!("✗ 管道不存在"); 268 | continue; 269 | } 270 | 271 | // 方法1: 直接获取 272 | println!("方法1 (GetNamedSecurityInfo):"); 273 | match NamedPipeACL::get_sddl_direct(pipe_name) { 274 | Ok(sddl) => println!("✓ SDDL: {sddl}"), 275 | Err(e) => println!("✗ 失败: {e}"), 276 | } 277 | 278 | println!(); 279 | } 280 | 281 | // // 扫描所有常见管道 282 | // match NamedPipeACL::scan_common_pipes() { 283 | // Ok(accessible) => { 284 | // println!("可访问的管道: {:?}", accessible); 285 | // } 286 | // Err(e) => { 287 | // println!("扫描失败: {}", e); 288 | // } 289 | // } 290 | 291 | Ok(()) 292 | } 293 | 294 | #[cfg(not(windows))] 295 | fn main() { 296 | panic!("not supported on non-windows platform"); 297 | } 298 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | versionType: 7 | type: choice 8 | description: "" 9 | required: true 10 | default: "patch" 11 | options: 12 | - major 13 | - minor 14 | - patch 15 | 16 | permissions: 17 | contents: write 18 | discussions: write 19 | 20 | jobs: 21 | publish: 22 | name: Publish ${{ inputs.versionType }} release 23 | outputs: 24 | version: ${{ steps.update-version.outputs.version }} 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | with: 30 | token: ${{ secrets.GITHUB_TOKEN }} 31 | fetch-depth: 0 32 | - name: Install Rust nightly toolchain 33 | run: | 34 | rustup toolchain install -c clippy -c rustfmt --profile minimal nightly 35 | rustup default nightly 36 | 37 | - name: Install cargo-binstall 38 | uses: cargo-bins/cargo-binstall@v1.16.4 39 | - name: Install cargo-edit 40 | run: | 41 | cargo binstall cargo-edit -y 42 | - name: Install git-cliff 43 | uses: taiki-e/install-action@git-cliff 44 | - uses: denoland/setup-deno@v2 45 | with: 46 | deno-version: v2.x 47 | - uses: Swatinem/rust-cache@v2 48 | name: Setup Rust cache 49 | with: 50 | shared-key: ${{ runner.os }}-ci 51 | save-if: ${{ github.ref == 'refs/heads/main' }} 52 | 53 | - id: update-version 54 | shell: bash 55 | name: Bump version 56 | # Use npm because yarn is for some reason not able to output only the version name 57 | run: | 58 | cargo set-version --workspace --bump ${{ inputs.versionType }} 59 | VERSION=$(deno run --allow-read .github/scripts/get-version.ts --path ./nyanpasu_service/Cargo.toml | head -n 1) 60 | echo "$VERSION" 61 | echo "version=$VERSION" >> $GITHUB_OUTPUT 62 | - name: Generate a changelog for the new version 63 | shell: bash 64 | id: build-changelog 65 | run: | 66 | touch /tmp/changelog.md 67 | git-cliff --config cliff.toml --verbose --strip header --unreleased --tag v${{ steps.update-version.outputs.version }} > /tmp/changelog.md 68 | if [ $? -eq 0 ]; then 69 | CONTENT=$(cat /tmp/changelog.md) 70 | cat /tmp/changelog.md | cat - ./CHANGELOGS.md > temp && mv temp ./CHANGELOGS.md 71 | { 72 | echo 'content<> $GITHUB_OUTPUT 76 | echo "version=${{ steps.update-version.outputs.version }}" >> $GITHUB_OUTPUT 77 | else 78 | echo "Failed to generate changelog" 79 | exit 1 80 | fi 81 | env: 82 | GITHUB_REPO: ${{ github.repository }} 83 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 84 | - name: Commit changes 85 | uses: stefanzweifel/git-auto-commit-action@v6 86 | with: 87 | commit_message: "chore: bump version to v${{ steps.update-version.outputs.version }}" 88 | commit_user_name: "github-actions[bot]" 89 | commit_user_email: "41898282+github-actions[bot]@users.noreply.github.com" 90 | tagging_message: "v${{ steps.update-version.outputs.version }}" 91 | - name: Release 92 | uses: softprops/action-gh-release@v2 93 | with: 94 | # draft: true 95 | body: ${{steps.build-changelog.outputs.content}} 96 | name: Nyanpasu Service v${{steps.update-version.outputs.version}} 97 | tag_name: "v${{ steps.update-version.outputs.version }}" 98 | # target_commitish: ${{ steps.tag.outputs.sha }} 99 | release_macos: 100 | needs: [publish] 101 | name: Release v${{ needs.publish.outputs.version }} for macOS 102 | strategy: 103 | matrix: 104 | profile: 105 | - os: macos-latest 106 | target: x86_64-apple-darwin 107 | - os: macos-latest 108 | target: aarch64-apple-darwin 109 | runs-on: ${{ matrix.profile.os }} 110 | steps: 111 | - name: Checkout 112 | uses: actions/checkout@v4 113 | with: 114 | token: ${{ secrets.GITHUB_TOKEN }} 115 | ref: v${{ needs.publish.outputs.version }} 116 | - name: Install coreutils for macOS 117 | run: brew install coreutils 118 | - name: Install Rust toolchain and target toolchain 119 | run: rustup toolchain install nightly --profile minimal && rustup target add ${{ matrix.profile.target }} --toolchain nightly 120 | - name: Build 121 | run: cargo +nightly build --release --target ${{ matrix.profile.target }} 122 | - name: Archive 123 | run: | 124 | mkdir -p target/release 125 | cp target/${{ matrix.profile.target }}/release/nyanpasu-service target/release/nyanpasu-service 126 | cd target/release 127 | tar -czf nyanpasu-service-${{ matrix.profile.target }}.tar.gz ./nyanpasu-service 128 | cd ../.. 129 | mv target/release/nyanpasu-service-${{ matrix.profile.target }}.tar.gz . 130 | - name: Calc the archive signature 131 | run: sha256sum nyanpasu-service-${{ matrix.profile.target }}.tar.gz > nyanpasu-service-${{ matrix.profile.target }}.tar.gz.sha256 132 | - name: Upload Release Asset 133 | uses: softprops/action-gh-release@v2 134 | with: 135 | tag_name: "v${{ needs.publish.outputs.version }}" 136 | files: | 137 | nyanpasu-service-${{ matrix.profile.target }}.tar.gz 138 | nyanpasu-service-${{ matrix.profile.target }}.tar.gz.sha256 139 | release_linux: 140 | needs: [publish] 141 | name: Release v${{ needs.publish.outputs.version }} for ${{ matrix.profile.target }} 142 | strategy: 143 | matrix: 144 | profile: 145 | - os: ubuntu-latest 146 | target: x86_64-unknown-linux-gnu 147 | - os: ubuntu-latest 148 | target: x86_64-unknown-linux-musl 149 | - os: ubuntu-latest 150 | target: i686-unknown-linux-gnu 151 | - os: ubuntu-latest 152 | target: i686-unknown-linux-musl 153 | - os: ubuntu-latest 154 | target: aarch64-unknown-linux-gnu 155 | - os: ubuntu-latest 156 | target: aarch64-unknown-linux-musl 157 | - os: ubuntu-latest 158 | target: armv7-unknown-linux-gnueabi 159 | - os: ubuntu-latest 160 | target: armv7-unknown-linux-musleabi 161 | - os: ubuntu-latest 162 | target: armv7-unknown-linux-musleabihf 163 | - os: ubuntu-latest 164 | target: armv7-unknown-linux-gnueabihf 165 | runs-on: ${{ matrix.profile.os }} 166 | steps: 167 | - name: Checkout 168 | uses: actions/checkout@v4 169 | with: 170 | token: ${{ secrets.GITHUB_TOKEN }} 171 | ref: v${{ needs.publish.outputs.version }} 172 | - name: Install Rust toolchain and target toolchain 173 | run: rustup toolchain install nightly --profile minimal && rustup target add ${{ matrix.profile.target }} --toolchain nightly 174 | - name: Setup Cross 175 | if: contains(matrix.profile.target, 'musl') 176 | run: | 177 | curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash 178 | cargo binstall cross -y 179 | - uses: goto-bus-stop/setup-zig@v2 180 | name: Setup Zig 181 | if: contains(matrix.profile.target, 'musl') == false 182 | - name: Setup Cargo zigbuild 183 | if: contains(matrix.profile.target, 'musl') == false 184 | run: | 185 | curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash 186 | cargo binstall cargo-zigbuild -y 187 | - name: Build 188 | if: contains(matrix.profile.target, 'musl') == false 189 | run: cargo +nightly zigbuild --release --target ${{ matrix.profile.target }} 190 | - name: Build with Cross 191 | if: contains(matrix.profile.target, 'musl') 192 | run: cross +nightly build --release --target ${{ matrix.profile.target }} 193 | - name: Archive 194 | run: | 195 | mkdir -p target/release 196 | cp target/${{ matrix.profile.target }}/release/nyanpasu-service target/release/nyanpasu-service 197 | cd target/release 198 | tar -czf nyanpasu-service-${{ matrix.profile.target }}.tar.gz ./nyanpasu-service 199 | cd ../.. 200 | mv target/release/nyanpasu-service-${{ matrix.profile.target }}.tar.gz . 201 | - name: Calc the archive signature 202 | run: sha256sum nyanpasu-service-${{ matrix.profile.target }}.tar.gz > nyanpasu-service-${{ matrix.profile.target }}.tar.gz.sha256 203 | - name: Upload Release Asset 204 | uses: softprops/action-gh-release@v2 205 | with: 206 | tag_name: "v${{ needs.publish.outputs.version }}" 207 | files: | 208 | nyanpasu-service-${{ matrix.profile.target }}.tar.gz 209 | nyanpasu-service-${{ matrix.profile.target }}.tar.gz.sha256 210 | release_windows: 211 | needs: [publish] 212 | name: Release v${{ needs.publish.outputs.version }} for ${{ matrix.profile.target }} 213 | strategy: 214 | matrix: 215 | profile: 216 | - os: windows-latest 217 | target: x86_64-pc-windows-msvc 218 | - os: windows-latest 219 | target: aarch64-pc-windows-msvc 220 | - os: windows-latest 221 | target: i686-pc-windows-msvc 222 | runs-on: ${{ matrix.profile.os }} 223 | steps: 224 | - name: Checkout 225 | uses: actions/checkout@v4 226 | with: 227 | token: ${{ secrets.GITHUB_TOKEN }} 228 | ref: v${{ needs.publish.outputs.version }} 229 | - name: Install Rust toolchain and target toolchain 230 | run: rustup toolchain install nightly --profile minimal && rustup target add ${{ matrix.profile.target }} --toolchain nightly 231 | - name: Build 232 | run: cargo +nightly build --release --target ${{ matrix.profile.target }} 233 | - name: Archive 234 | run: | 235 | New-Item -ItemType Directory -Force -Path target\release 236 | Copy-Item target\${{ matrix.profile.target }}\release\nyanpasu-service.exe target\release\nyanpasu-service.exe 237 | Set-Location target\release 238 | Compress-Archive -Path nyanpasu-service.exe -DestinationPath ..\..\nyanpasu-service-${{ matrix.profile.target }}.zip 239 | Set-Location ..\.. 240 | - name: Calc the archive signature 241 | shell: pwsh 242 | run: Get-FileHash nyanpasu-service-${{ matrix.profile.target }}.zip -Algorithm SHA256 | Format-List > nyanpasu-service-${{ matrix.profile.target }}.zip.sha256 243 | - name: Upload Release Asset 244 | uses: softprops/action-gh-release@v2 245 | with: 246 | tag_name: "v${{ needs.publish.outputs.version }}" 247 | files: | 248 | nyanpasu-service-${{ matrix.profile.target }}.zip 249 | nyanpasu-service-${{ matrix.profile.target }}.zip.sha256 250 | -------------------------------------------------------------------------------- /CHANGELOGS.md: -------------------------------------------------------------------------------- 1 | 2 | ## [1.4.1] - 2025-07-19 3 | 4 | 5 | ### 🐛 Bug Fixes 6 | 7 | - Create service config dir while service install by @greenhat616 8 | 9 | ----------------- 10 | 11 | 12 | 13 | **Full Changelog**: https://github.com/libnyanpasu/nyanpasu-service/compare/v1.4.0...v1.4.1 14 | 15 | 16 | 17 | 18 | ## [1.4.0] - 2025-07-18 19 | 20 | 21 | ### ✨ Features 22 | 23 | - **acl:** Finish windows acl by @greenhat616 24 | 25 | 26 | ### 🐛 Bug Fixes 27 | 28 | - **core:** Ws report panic and remove UNC prefix by @greenhat616 29 | 30 | - **service:** Add a force kill logic by @greenhat616 31 | 32 | - **service:** Use acl file directly by @greenhat616 33 | 34 | - Example by @greenhat616 35 | 36 | 37 | ### 🔨 Refactor 38 | 39 | - **instance:** Improve instance management by @greenhat616 40 | 41 | ----------------- 42 | 43 | 44 | 45 | **Full Changelog**: https://github.com/libnyanpasu/nyanpasu-service/compare/v1.3.1...v1.4.0 46 | 47 | 48 | 49 | 50 | ## [1.3.1] - 2025-05-20 51 | 52 | 53 | ### 🐛 Bug Fixes 54 | 55 | - Follow SAFE_PATHS windows styles by @greenhat616 56 | 57 | ----------------- 58 | 59 | 60 | 61 | **Full Changelog**: https://github.com/libnyanpasu/nyanpasu-service/compare/v1.3.0...v1.3.1 62 | 63 | 64 | 65 | 66 | ## [1.3.0] - 2025-05-20 67 | 68 | 69 | ### ✨ Features 70 | 71 | - Support SAFE_PATHS by @greenhat616 72 | 73 | 74 | ### 🐛 Bug Fixes 75 | 76 | - Ci by @greenhat616 77 | 78 | - Provide service stop pending ttl by @greenhat616 79 | 80 | ----------------- 81 | 82 | 83 | 84 | **Full Changelog**: https://github.com/libnyanpasu/nyanpasu-service/compare/v1.2.0...v1.3.0 85 | 86 | 87 | 88 | 89 | ## [1.2.0] - 2025-02-23 90 | 91 | 92 | ### ✨ Features 93 | 94 | - **ws:** Support log notify by @greenhat616 95 | 96 | - **ws:** Support core state changed notify by @greenhat616 97 | 98 | - Static crt for windows by @greenhat616 99 | 100 | - Ws events layer and refactor core manager handle to owned by app state by @greenhat616 101 | 102 | - Support win service gracefully shutdown by @greenhat616 103 | 104 | - Specta support by @greenhat616 105 | 106 | 107 | ### 🐛 Bug Fixes 108 | 109 | - Linter by @greenhat616 110 | 111 | 112 | ### 🔨 Refactor 113 | 114 | - Use deno script to get version by @greenhat616 115 | 116 | - Use axum listener trait and axum gracefully shutdown by @greenhat616 117 | 118 | ----------------- 119 | 120 | 121 | 122 | **Full Changelog**: https://github.com/libnyanpasu/nyanpasu-service/compare/v1.1.3...v1.2.0 123 | 124 | 125 | 126 | 127 | ## [1.1.3] - 2025-01-09 128 | 129 | 130 | ### 🐛 Bug Fixes 131 | 132 | - **server:** Use merge instead of nest for axum by @greenhat616 133 | 134 | - Clippy by @greenhat616 135 | 136 | ----------------- 137 | 138 | 139 | 140 | **Full Changelog**: https://github.com/libnyanpasu/nyanpasu-service/compare/v1.1.2...v1.1.3 141 | 142 | 143 | 144 | 145 | ## [1.1.2] - 2025-01-09 146 | 147 | 148 | ### 🐛 Bug Fixes 149 | 150 | - **macos:** Support set dns by @greenhat616 151 | 152 | - Lint by @greenhat616 153 | 154 | - Correct exit code usage by @greenhat616 155 | 156 | ----------------- 157 | 158 | 159 | 160 | **Full Changelog**: https://github.com/libnyanpasu/nyanpasu-service/compare/v1.1.1...v1.1.2 161 | 162 | 163 | 164 | 165 | ## [1.1.1] - 2025-01-08 166 | 167 | 168 | ### 🐛 Bug Fixes 169 | 170 | - Do not replace binary if src and dst are same by @greenhat616 171 | 172 | - Use /usr/bin on linux by @greenhat616 173 | 174 | - Bump axum to 0.8 by @greenhat616 175 | 176 | ----------------- 177 | 178 | 179 | 180 | **Full Changelog**: https://github.com/libnyanpasu/nyanpasu-service/compare/v1.1.0...v1.1.1 181 | 182 | 183 | 184 | 185 | ## [1.1.0] - 2024-12-27 186 | 187 | 188 | ### 🐛 Bug Fixes 189 | 190 | - **ci:** Try to fix release ci by @greenhat616 191 | 192 | - **lifecycle:** Make service exit gracefully by @greenhat616 193 | 194 | - **macos:** Fix a status check condition by @greenhat616 195 | 196 | ----------------- 197 | 198 | 199 | 200 | **Full Changelog**: https://github.com/libnyanpasu/nyanpasu-service/compare/v1.0.7...v1.1.0 201 | 202 | 203 | 204 | 205 | ## [1.0.7] - 2024-10-09 206 | 207 | 208 | ### ✨ Features 209 | 210 | - Support clash rs alpha by @greenhat616 211 | 212 | 213 | ### 🐛 Bug Fixes 214 | 215 | - Handle service shutdown signal by @greenhat616 216 | 217 | ----------------- 218 | 219 | 220 | 221 | **Full Changelog**: https://github.com/libnyanpasu/nyanpasu-service/compare/v1.0.6...v1.0.7 222 | 223 | 224 | 225 | 226 | ## [1.0.6] - 2024-09-10 227 | 228 | 229 | ### 🐛 Bug Fixes 230 | 231 | - Bump simd-json to fix x86 build by @greenhat616 232 | 233 | ----------------- 234 | 235 | 236 | 237 | **Full Changelog**: https://github.com/libnyanpasu/nyanpasu-service/compare/v1.0.5...v1.0.6 238 | 239 | 240 | 241 | ## [1.0.0] - 2024-07-24 242 | 243 | ### ✨ Features 244 | 245 | - Better version print by @greenhat616 246 | 247 | - Add a logs cmd to get server logs by @greenhat616 248 | 249 | - Add core rpc calls by @greenhat616 250 | 251 | - Add slef update command by @greenhat616 252 | 253 | - Cleanup socket file and cleanup zombie instance before startup by @greenhat616 254 | 255 | - Add deadlock detection and status skip-service-check flag by @greenhat616 256 | 257 | - Add status client rpc check by @greenhat616 258 | 259 | - Add acl for server by @greenhat616 260 | 261 | - Core restart & stop rpc api by @greenhat616 262 | 263 | - Core start rpc api by @greenhat616 264 | 265 | - Service server startup and status inspect rpc by @greenhat616 266 | 267 | - Status command by @greenhat616 268 | 269 | - Restart command by @greenhat616 270 | 271 | - Stop command by @greenhat616 272 | 273 | - Stop command by @greenhat616 274 | 275 | - Start command by @greenhat616 276 | 277 | - Unstall command by @greenhat616 278 | 279 | - Install command by @greenhat616 280 | 281 | - Add core manager util by @greenhat616 282 | 283 | - Draft http client ipc by @greenhat616 284 | 285 | - Draft server ipc by @greenhat616 286 | 287 | - Add config file by @zzzgydi 288 | 289 | - Update by @zzzgydi 290 | 291 | - Add install and uninstall bin by @zzzgydi 292 | 293 | 294 | ### 🐛 Bug Fixes 295 | 296 | - Publish ci by @greenhat616 297 | 298 | - Lint by @greenhat616 299 | 300 | - Macos user ops by @greenhat616 301 | 302 | - Lint by @greenhat616 303 | 304 | - Issues by @greenhat616 305 | 306 | - Ci by @greenhat616 307 | 308 | - Lint by @greenhat616 309 | 310 | - Ci by @greenhat616 311 | 312 | - Ci by @greenhat616 313 | 314 | - Refresh process table before kill process by @greenhat616 315 | 316 | - Check pid whether is exist before killing zombie server by @greenhat616 317 | 318 | - Publish version ctx by @greenhat616 319 | 320 | - Lint by @greenhat616 321 | 322 | - Rpc inspect logs by @greenhat616 323 | 324 | - Process service stop signal by @greenhat616 325 | 326 | - Missing PathBuf mod import by @keiko233 327 | 328 | - Lint by @greenhat616 329 | 330 | - Socket file permission is not changed by @greenhat616 331 | 332 | - Mark socket not execuable by @greenhat616 333 | 334 | - Lint by @greenhat616 335 | 336 | - Lint by @greenhat616 337 | 338 | - Mark unix socket group rw able by @greenhat616 339 | 340 | - State match by @greenhat616 341 | 342 | - Lint by @greenhat616 343 | 344 | - The status query for launchd by @greenhat616 345 | 346 | - Setup windows service manager by @greenhat616 347 | 348 | - Setup windows service manager by @greenhat616 349 | 350 | - Correct macOS group creation command in create_nyanpasu_group function by @keiko233 351 | 352 | - Logging guard is dropped too early by @greenhat616 353 | 354 | - Service manager encoding issue by @greenhat616 355 | 356 | - Lint by @greenhat616 357 | 358 | - Missing use by @greenhat616 359 | 360 | - Issue by @greenhat616 361 | 362 | - Correct server args by @greenhat616 363 | 364 | - Error handling in `check_and_create_nyanpasu_group` function by @keiko233 365 | 366 | - Correct return type for is_nyanpasu_group_exists function by @keiko233 367 | 368 | - Upstream status check by @greenhat616 369 | 370 | - Update dependencies by @greenhat616 371 | 372 | - Rename meta to mihomo and support clash-rs, mihomo-alpha by @greenhat616 373 | 374 | 375 | ### 📚 Documentation 376 | 377 | - Update readme by @greenhat616 378 | 379 | 380 | ### 🧹 Miscellaneous Tasks 381 | 382 | - Bump crates by @greenhat616 383 | 384 | - Apply linting fixes with rustfmt by @github-actions[bot] 385 | 386 | - Use tracing-panic to better capture panic info by @greenhat616 387 | 388 | - Add debug info for os operations by @greenhat616 389 | 390 | - Cleanup deps by @greenhat616 391 | 392 | - Enable tokio-console for debug by @greenhat616 393 | 394 | - Version info use table output by @greenhat616 395 | 396 | - Add editorconfig by @greenhat616 397 | 398 | - Fmt by @greenhat616 399 | 400 | - Add debug print by @greenhat616 401 | 402 | - Apply linting fixes with rustfmt by @github-actions[bot] 403 | 404 | - Add stop advice by @greenhat616 405 | 406 | - Update actions/checkout action to v4 (#24) by @renovate[bot] in [#24](https://github.com/LibNyanpasu/nyanpasu-service/pull/24) 407 | 408 | - Apply linting fixes with rustfmt by @github-actions[bot] 409 | 410 | - Apply linting fixes with rustfmt by @github-actions[bot] 411 | 412 | - Apply linting fixes with clippy by @github-actions[bot] 413 | 414 | - Apply linting fixes with rustfmt by @github-actions[bot] 415 | 416 | - Apply linting fixes with clippy by @github-actions[bot] 417 | 418 | - Commit workspace by @greenhat616 419 | 420 | - Draft api ctx definition by @greenhat616 421 | 422 | - Rename --debug to --verbose by @greenhat616 423 | 424 | - Commit workspace by @greenhat616 425 | 426 | - Commit workspace by @greenhat616 427 | 428 | - Update actions/checkout action to v4 (#3) by @renovate[bot] in [#3](https://github.com/LibNyanpasu/nyanpasu-service/pull/3) 429 | 430 | - Add renovate.json (#2) by @renovate[bot] in [#2](https://github.com/LibNyanpasu/nyanpasu-service/pull/2) 431 | 432 | - Code format by @zzzgydi 433 | 434 | - Ci by @zzzgydi 435 | 436 | - Init by @zzzgydi 437 | 438 | ----------------- 439 | 440 | 441 | 442 | ## New Contributors 443 | * @github-actions[bot] made their first contribution 444 | * @keiko233 made their first contribution 445 | * @renovate[bot] made their first contribution in [#24](https://github.com/LibNyanpasu/nyanpasu-service/pull/24) 446 | * @zzzgydi made their first contribution 447 | 448 | 449 | 450 | ## [1.0.1] - 2024-07-24 451 | 452 | ### 🐛 Bug Fixes 453 | 454 | - Replace dscl to dseditgroup by @keiko233 455 | 456 | - Update rust crate clap to v4.5.10 (#29) by @renovate[bot] in [#29](https://github.com/LibNyanpasu/nyanpasu-service/pull/29) 457 | 458 | 459 | ### 🧹 Miscellaneous Tasks 460 | 461 | - Up by @greenhat616 462 | 463 | - Update rust crate tokio to v1.39.1 (#30) by @renovate[bot] in [#30](https://github.com/LibNyanpasu/nyanpasu-service/pull/30) 464 | 465 | ----------------- 466 | 467 | 468 | 469 | **Full Changelog**: https://github.com/LibNyanpasu/nyanpasu-service/compare/v1.0.0...v1.0.1 470 | 471 | 472 | 473 | ## [1.0.2] - 2024-07-26 474 | 475 | ### 🐛 Bug Fixes 476 | 477 | - Update rust crate interprocess to 2.2.1 by @renovate[bot] in [#34](https://github.com/LibNyanpasu/nyanpasu-service/pull/34) 478 | 479 | 480 | ### 🧹 Miscellaneous Tasks 481 | 482 | - Update rust crate parking_lot to 0.12.3 by @renovate[bot] in [#33](https://github.com/LibNyanpasu/nyanpasu-service/pull/33) 483 | 484 | - Update rust crate clap to 4.5.10 by @renovate[bot] in [#32](https://github.com/LibNyanpasu/nyanpasu-service/pull/32) 485 | 486 | - Update rust crate axum to 0.7.5 by @renovate[bot] in [#31](https://github.com/LibNyanpasu/nyanpasu-service/pull/31) 487 | 488 | ----------------- 489 | 490 | 491 | 492 | **Full Changelog**: https://github.com/LibNyanpasu/nyanpasu-service/compare/v1.0.1...v1.0.2 493 | 494 | 495 | 496 | ## [1.0.3] - 2024-07-26 497 | 498 | ### ✨ Features 499 | 500 | - Support sidecar path search and share the status type with ui by @greenhat616 501 | 502 | ----------------- 503 | 504 | 505 | 506 | **Full Changelog**: https://github.com/LibNyanpasu/nyanpasu-service/compare/v1.0.2...v1.0.3 507 | 508 | 509 | 510 | ## [1.0.4] - 2024-07-28 511 | 512 | ### 🐛 Bug Fixes 513 | 514 | - Should start service after updated by @greenhat616 515 | 516 | 517 | ### 🔨 Refactor 518 | 519 | - Use atomic cell to hold flag and state, and add a recover core logic by @greenhat616 520 | 521 | 522 | ### 🧹 Miscellaneous Tasks 523 | 524 | - Sync latest nyanpasu-utils by @greenhat616 525 | 526 | ----------------- 527 | 528 | 529 | 530 | **Full Changelog**: https://github.com/LibNyanpasu/nyanpasu-service/compare/v1.0.3...v1.0.4 531 | 532 | 533 | 534 | ## [1.0.5] - 2024-07-28 535 | 536 | ### 🐛 Bug Fixes 537 | 538 | - Fetch status deadlock by @greenhat616 539 | 540 | - Up by @greenhat616 541 | 542 | 543 | ### 🧹 Miscellaneous Tasks 544 | 545 | - Add a error log for deadlock debug use by @greenhat616 546 | 547 | - Add a timeout seq for status by @greenhat616 548 | 549 | - Update rust crate clap to 4.5.11 by @renovate[bot] in [#35](https://github.com/LibNyanpasu/nyanpasu-service/pull/35) 550 | 551 | - Apply linting fixes with rustfmt by @github-actions[bot] 552 | 553 | - Mark start req as Cow by @greenhat616 554 | 555 | ----------------- 556 | 557 | 558 | 559 | **Full Changelog**: https://github.com/LibNyanpasu/nyanpasu-service/compare/v1.0.4...v1.0.5 560 | 561 | 562 | 563 | -------------------------------------------------------------------------------- /nyanpasu_ipc/src/utils/acl.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_variables)] 2 | use std::{ 3 | ffi::c_void, 4 | ops::{Deref, DerefMut}, 5 | }; 6 | 7 | use anyhow::{Context, Result}; 8 | use windows::{ 9 | Win32::{ 10 | Foundation::*, 11 | Security::{Authorization::*, *}, 12 | System::{SystemServices::*, Threading::*}, 13 | }, 14 | core::*, 15 | }; 16 | 17 | /// Administrators 18 | pub const ADMINISTRATORS_GROUP_SID: &str = "S-1-5-32-544"; 19 | /// SYSTEM 20 | pub const SYSTEM_SID: &str = "S-1-5-18"; 21 | /// AUTHENTICATED_USER 22 | pub const AUTHENTICATED_USER_SID: &str = "S-1-5-11"; 23 | /// EVERYONE, aka World 24 | pub const EVERYONE_SID: &str = "S-1-1-0"; 25 | 26 | struct OwnedPSID(PSID); 27 | 28 | impl AsRef for OwnedPSID { 29 | fn as_ref(&self) -> &PSID { 30 | &self.0 31 | } 32 | } 33 | 34 | impl Deref for OwnedPSID { 35 | type Target = PSID; 36 | fn deref(&self) -> &Self::Target { 37 | &self.0 38 | } 39 | } 40 | 41 | impl DerefMut for OwnedPSID { 42 | fn deref_mut(&mut self) -> &mut Self::Target { 43 | &mut self.0 44 | } 45 | } 46 | 47 | impl Drop for OwnedPSID { 48 | fn drop(&mut self) { 49 | let _ = unsafe { Owned::new(HLOCAL(self.0.0)) }; 50 | } 51 | } 52 | 53 | impl OwnedPSID { 54 | /// Create OwnedPSID from PSID 55 | unsafe fn from_psid(psid: PSID) -> Self { 56 | Self(psid) 57 | } 58 | 59 | /// Create OwnedPSID from SID string 60 | unsafe fn try_from_sid_str(sid_str: &str) -> windows::core::Result { 61 | let sid_hstring = HSTRING::from(sid_str); 62 | let mut psid = PSID::default(); 63 | unsafe { 64 | ConvertStringSidToSidW(&sid_hstring, &mut psid)?; 65 | Ok(Self::from_psid(psid)) 66 | } 67 | } 68 | } 69 | 70 | /// Internal common function: generate security descriptor with permissions 71 | /// 72 | /// # Parameters 73 | /// * `sids_and_permissions` - Array of SID strings and permission pairs 74 | /// * `owner` - Optional owner SID 75 | /// * `group` - Optional group SID 76 | /// 77 | /// # Returns 78 | /// Returns SDDL string 79 | #[cfg(windows)] 80 | fn generate_security_descriptor_internal>( 81 | sids_and_permissions: &[(&T, Option)], 82 | owner: Option<&str>, 83 | group: Option<&str>, 84 | ) -> Result { 85 | unsafe { 86 | let mut security_descriptor = SECURITY_DESCRIPTOR::default(); 87 | let security_descriptor_ptr = 88 | PSECURITY_DESCRIPTOR(&mut security_descriptor as *mut _ as *mut c_void); 89 | InitializeSecurityDescriptor(security_descriptor_ptr, SECURITY_DESCRIPTOR_REVISION) 90 | .context("failed to initialize security descriptor")?; 91 | 92 | let system_sid = OwnedPSID::try_from_sid_str(SYSTEM_SID) 93 | .context("failed to convert system sid string to psid")?; 94 | let admins_sid = OwnedPSID::try_from_sid_str(ADMINISTRATORS_GROUP_SID) 95 | .context("failed to convert admins sid string to psid")?; 96 | 97 | let owner_sid = match owner { 98 | Some(s) => OwnedPSID::try_from_sid_str(s) 99 | .context("failed to convert owner sid string to psid")?, 100 | None => admins_sid, 101 | }; 102 | let group_sid = match group { 103 | Some(s) => OwnedPSID::try_from_sid_str(s) 104 | .context("failed to convert group sid string to psid")?, 105 | None => system_sid, 106 | }; 107 | 108 | let sids = sids_and_permissions 109 | .iter() 110 | .map(|(sid, permissions)| { 111 | let owned_sid = OwnedPSID::try_from_sid_str(sid.as_ref()).context(format!( 112 | "failed to convert sid string to psid: {}", 113 | sid.as_ref() 114 | ))?; 115 | Ok((owned_sid, *permissions)) 116 | }) 117 | .collect::>>()?; 118 | 119 | let mut psids = Vec::with_capacity(sids.len() + 2); 120 | psids.push((*owner_sid.as_ref(), None)); 121 | psids.push((*group_sid.as_ref(), None)); 122 | psids.extend( 123 | sids.iter() 124 | .map(|(sid, permissions)| (*sid.as_ref(), *permissions)), 125 | ); 126 | 127 | // Calculate ACL size 128 | let acl_size = calculate_acl_size(psids.iter().map(|(sid, _)| sid).collect::>()) 129 | .context("failed to calculate acl size")?; 130 | 131 | // Create ACL 132 | let mut acl_buffer = vec![0u8; acl_size + 256]; 133 | let acl = acl_buffer.as_mut_ptr() as *mut ACL; 134 | InitializeAcl(acl, (acl_size + 256) as u32, ACL_REVISION) 135 | .context("failed to initialize acl")?; 136 | 137 | // Add SIDs to ACL 138 | add_sids_to_acl(&mut *acl, psids).context("failed to add sids to acl")?; 139 | 140 | // Set DACL 141 | SetSecurityDescriptorDacl(security_descriptor_ptr, true, Some(acl), false) 142 | .context("failed to set dacl to security descriptor")?; 143 | 144 | // Set owner and group 145 | set_owner_and_group( 146 | &mut security_descriptor, 147 | *owner_sid.as_ref(), 148 | *group_sid.as_ref(), 149 | ) 150 | .context("failed to set owner and group")?; 151 | 152 | // Convert to SDDL string 153 | let mut sddl_string = PWSTR::default(); 154 | ConvertSecurityDescriptorToStringSecurityDescriptorW( 155 | security_descriptor_ptr, 156 | SDDL_REVISION_1, 157 | DACL_SECURITY_INFORMATION | OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION, 158 | &mut sddl_string, 159 | None, 160 | ) 161 | .context("failed to convert security descriptor to sddl string")?; 162 | 163 | sddl_string 164 | .to_string() 165 | .context("failed to convert sddl string to string") 166 | } 167 | } 168 | 169 | /// Generate security descriptor using Windows native API 170 | /// 171 | /// # Parameters 172 | /// * `sids` - Array of SID strings 173 | /// * `owner` - Optional owner SID 174 | /// * `group` - Optional group SID 175 | /// 176 | /// # Returns 177 | /// Returns SDDL string 178 | #[cfg(windows)] 179 | pub fn generate_windows_security_descriptor + core::fmt::Debug>( 180 | sids: &[T], 181 | owner: Option<&str>, 182 | group: Option<&str>, 183 | ) -> Result { 184 | let sids_and_permissions: Vec<(&T, Option)> = sids.iter().map(|sid| (sid, None)).collect(); 185 | 186 | generate_security_descriptor_internal(&sids_and_permissions, owner, group) 187 | } 188 | 189 | /// Calculate the buffer size required for the ACL 190 | fn calculate_acl_size<'a, I>(sids: I) -> Result 191 | where 192 | I: IntoIterator, 193 | { 194 | let mut total_size = std::mem::size_of::(); 195 | 196 | // Reserve space for user SIDs 197 | for sid in sids { 198 | total_size += calculate_ace_size(*sid).context("failed to calculate ace size")?; 199 | } 200 | 201 | // Add some extra buffer just in case 202 | Ok(total_size + 256) 203 | } 204 | 205 | /// Calculate the size of a single ACE 206 | fn calculate_ace_size(sid: PSID) -> Result { 207 | unsafe { 208 | let sid_length = GetLengthSid(sid) as usize; 209 | // Size of ACCESS_ALLOWED_ACE structure + size of SID - 4 (because the structure already contains 4 bytes for SidStart) 210 | Ok(std::mem::size_of::() + sid_length - 4) 211 | } 212 | } 213 | 214 | struct AddSidToAcl { 215 | sid: PSID, 216 | permissions: Option, 217 | } 218 | 219 | impl From<(PSID, Option)> for AddSidToAcl { 220 | fn from((sid, permissions): (PSID, Option)) -> Self { 221 | Self { sid, permissions } 222 | } 223 | } 224 | 225 | impl From<&(PSID, Option)> for AddSidToAcl { 226 | fn from((sid, permissions): &(PSID, Option)) -> Self { 227 | Self { 228 | sid: *sid, 229 | permissions: *permissions, 230 | } 231 | } 232 | } 233 | 234 | impl From for AddSidToAcl { 235 | fn from(sid: PSID) -> Self { 236 | Self { 237 | sid, 238 | permissions: None, 239 | } 240 | } 241 | } 242 | 243 | impl From<&PSID> for AddSidToAcl { 244 | fn from(sid: &PSID) -> Self { 245 | Self { 246 | sid: *sid, 247 | permissions: None, 248 | } 249 | } 250 | } 251 | 252 | /// Add well-known SIDs to the ACL 253 | fn add_sids_to_acl(acl: &mut ACL, sids: I) -> Result<()> 254 | where 255 | I: IntoIterator, 256 | T: Into, 257 | { 258 | unsafe { 259 | for sid in sids { 260 | let AddSidToAcl { sid, permissions } = sid.into(); 261 | AddAccessAllowedAce(acl, ACL_REVISION, permissions.unwrap_or(GENERIC_ALL.0), sid) 262 | .context("failed to add access allowed ace")?; 263 | } 264 | 265 | Ok(()) 266 | } 267 | } 268 | 269 | /// Set the owner and group of the security descriptor 270 | unsafe fn set_owner_and_group( 271 | security_descriptor: &mut SECURITY_DESCRIPTOR, 272 | group: PSID, 273 | owner: PSID, 274 | ) -> Result<()> { 275 | unsafe { 276 | let security_descriptor_ptr = 277 | PSECURITY_DESCRIPTOR(security_descriptor as *mut _ as *mut c_void); 278 | 279 | SetSecurityDescriptorOwner(security_descriptor_ptr, Some(owner), false) 280 | .context("failed to set owner")?; 281 | 282 | SetSecurityDescriptorGroup(security_descriptor_ptr, Some(group), false) 283 | .context("failed to set group")?; 284 | 285 | // Note: Do not free the SIDs here, as the security descriptor is still using them. 286 | // They will be freed automatically when the security descriptor is destroyed. 287 | 288 | Ok(()) 289 | } 290 | } 291 | 292 | /// Generate a security descriptor with specific permissions using the Windows API 293 | pub fn generate_security_descriptor_with_permissions>( 294 | sids_and_permissions: &[(T, u32)], 295 | owner: Option<&str>, 296 | group: Option<&str>, 297 | ) -> Result { 298 | let sids_and_permissions_with_perms: Vec<(&T, Option)> = sids_and_permissions 299 | .iter() 300 | .map(|(sid, permissions)| (sid, Some(*permissions))) 301 | .collect(); 302 | 303 | generate_security_descriptor_internal(&sids_and_permissions_with_perms, owner, group) 304 | } 305 | 306 | /// Get the SID string for the current user 307 | pub fn get_current_user_sid_string() -> Result { 308 | unsafe { 309 | let mut token_handle = HANDLE::default(); 310 | OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle) 311 | .context("failed to open process token")?; 312 | 313 | let mut token_info_length = 0u32; 314 | let _ = GetTokenInformation(token_handle, TokenUser, None, 0, &mut token_info_length); 315 | 316 | let mut token_user_buffer = vec![0u8; token_info_length as usize]; 317 | GetTokenInformation( 318 | token_handle, 319 | TokenUser, 320 | Some(token_user_buffer.as_mut_ptr() as *mut _), 321 | token_info_length, 322 | &mut token_info_length, 323 | ) 324 | .context("failed to get token information")?; 325 | 326 | let token_user = &*(token_user_buffer.as_ptr() as *const TOKEN_USER); 327 | let user_sid = token_user.User.Sid; 328 | 329 | let mut sid_string = PWSTR::default(); 330 | ConvertSidToStringSidW(user_sid, &mut sid_string) 331 | .context("failed to convert sid to string")?; 332 | sid_string 333 | .to_string() 334 | .context("failed to convert sid string to string") 335 | } 336 | } 337 | 338 | /// Create a default security descriptor for a named pipe 339 | pub fn create_default_pipe_security_descriptor() -> Result { 340 | let current_user_sid = 341 | get_current_user_sid_string().context("failed to get current user sid")?; 342 | let sids = vec![current_user_sid]; 343 | generate_windows_security_descriptor(&sids, None, None) 344 | } 345 | 346 | #[cfg(test)] 347 | mod tests { 348 | use windows::Win32::Storage::FileSystem::*; 349 | 350 | use super::*; 351 | 352 | #[test] 353 | fn test_generate_with_api() { 354 | let sids = vec!["S-1-1-0".to_string(), "S-1-5-11".to_string()]; 355 | let result = generate_windows_security_descriptor(&sids, None, None).unwrap(); 356 | 357 | assert!(result.starts_with("O:") || result.starts_with("D:")); 358 | assert_eq!( 359 | result, 360 | "O:SYG:BAD:(A;;GA;;;BA)(A;;GA;;;SY)(A;;GA;;;WD)(A;;GA;;;AU)" 361 | ); 362 | println!("Generated SDDL: {result}"); 363 | } 364 | 365 | #[test] 366 | fn test_permissions_api() { 367 | let sids_and_perms = vec![ 368 | ("S-1-1-0".to_string(), FILE_GENERIC_READ.0), 369 | ( 370 | "S-1-5-11".to_string(), 371 | FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0, 372 | ), 373 | ]; 374 | 375 | let result = 376 | generate_security_descriptor_with_permissions(&sids_and_perms, None, None).unwrap(); 377 | assert_eq!( 378 | result, 379 | "O:SYG:BAD:(A;;GA;;;BA)(A;;GA;;;SY)(A;;FR;;;WD)(A;;0x12019f;;;AU)" 380 | ); 381 | println!("SDDL with custom permissions: {result}"); 382 | } 383 | 384 | #[test] 385 | fn test_calculate_acl_size() { 386 | let sids = unsafe { 387 | vec![ 388 | OwnedPSID::try_from_sid_str("S-1-1-0").unwrap(), 389 | OwnedPSID::try_from_sid_str("S-1-5-11").unwrap(), 390 | ] 391 | }; 392 | let size = calculate_acl_size(sids.iter().map(|s| s.as_ref())).unwrap(); 393 | assert!(size > 0); 394 | println!("Calculated ACL size: {size} bytes"); 395 | } 396 | 397 | #[test] 398 | fn test_default_pipe_security() { 399 | let current_user_sid = get_current_user_sid_string().unwrap(); 400 | assert!(!current_user_sid.is_empty() && current_user_sid.starts_with("S-1-5-")); 401 | let result = create_default_pipe_security_descriptor().unwrap(); 402 | assert!(!result.is_empty()); 403 | assert!(result.contains(current_user_sid.as_str())); 404 | println!("Default pipe SDDL: {result}"); 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /nyanpasu_service/src/server/instance.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use std::{ 4 | borrow::Cow, 5 | path::PathBuf, 6 | sync::{ 7 | Arc, 8 | atomic::{AtomicI64, Ordering}, 9 | }, 10 | }; 11 | 12 | use camino::{Utf8Path, Utf8PathBuf}; 13 | use nyanpasu_ipc::{api::status::CoreState, utils::get_current_ts}; 14 | use nyanpasu_utils::core::{ 15 | CommandEvent, CoreType, 16 | instance::{CoreInstance, CoreInstanceBuilder}, 17 | }; 18 | use tokio::{ 19 | spawn, 20 | sync::{Mutex, mpsc::Sender as MpscSender}, 21 | }; 22 | use tokio_util::{sync::CancellationToken, task::task_tracker::TaskTracker}; 23 | use tracing::instrument; 24 | 25 | use super::consts; 26 | 27 | struct CoreManager { 28 | instance: Arc, 29 | config_path: Utf8PathBuf, 30 | cancel_token: CancellationToken, 31 | tracker: Option, 32 | } 33 | 34 | const SIGKILL: i32 = 9; 35 | const SIGTERM: i32 = 15; 36 | 37 | #[derive(Clone)] 38 | pub struct CoreManagerService { 39 | manager: Arc>>, 40 | state_changed_at: Arc, 41 | state_changed_notify: Arc>>, 42 | cancel_token: CancellationToken, 43 | } 44 | 45 | impl CoreManagerService { 46 | pub fn new_with_notify(notify: MpscSender, cancel_token: CancellationToken) -> Self { 47 | Self { 48 | manager: Arc::new(Mutex::new(None)), 49 | state_changed_at: Arc::new(AtomicI64::new(0)), 50 | state_changed_notify: Arc::new(Some(notify)), 51 | cancel_token, 52 | } 53 | } 54 | 55 | pub fn new(cancel_token: CancellationToken) -> Self { 56 | Self { 57 | manager: Arc::new(Mutex::new(None)), 58 | state_changed_at: Arc::new(AtomicI64::new(0)), 59 | state_changed_notify: Arc::new(None), 60 | cancel_token, 61 | } 62 | } 63 | 64 | fn notify_state_changed(tx: Arc>>, state: CoreState) { 65 | tokio::spawn(async move { 66 | if let Some(notify) = tx.as_ref() { 67 | let _ = notify.send(state).await; 68 | } 69 | }); 70 | } 71 | 72 | fn state_(manager: Option<&CoreManager>) -> Cow<'static, CoreState> { 73 | match manager { 74 | None => Cow::Borrowed(&CoreState::Stopped(None)), 75 | Some(manager) => Cow::Owned(match manager.instance.state() { 76 | nyanpasu_utils::core::instance::CoreInstanceState::Running => CoreState::Running, 77 | nyanpasu_utils::core::instance::CoreInstanceState::Stopped => { 78 | CoreState::Stopped(None) 79 | } 80 | }), 81 | } 82 | } 83 | 84 | /// Get the state of the core instance 85 | pub async fn state<'a>(&self) -> Cow<'a, CoreState> { 86 | let manager = self.manager.lock().await; 87 | Self::state_(manager.as_ref()) 88 | } 89 | 90 | /// Get the status of the core instance 91 | pub async fn status(&self) -> nyanpasu_ipc::api::status::CoreInfos { 92 | let manager = self.manager.lock().await; 93 | let state_changed_at = self 94 | .state_changed_at 95 | .load(std::sync::atomic::Ordering::Relaxed); 96 | let state = Self::state_(manager.as_ref()).into_owned(); 97 | match *manager { 98 | Some(ref manager) => nyanpasu_ipc::api::status::CoreInfos { 99 | r#type: Some(manager.instance.core_type.clone()), 100 | state, 101 | state_changed_at, 102 | config_path: Some(manager.config_path.clone().into()), 103 | }, 104 | None => nyanpasu_ipc::api::status::CoreInfos { 105 | r#type: None, 106 | state, 107 | state_changed_at, 108 | config_path: None, 109 | }, 110 | } 111 | } 112 | 113 | #[allow(clippy::manual_async_fn)] 114 | fn recover_core(self, counter: usize) -> impl Future + Send + Sync + 'static { 115 | async move { 116 | tracing::info!("Try to recover the core instance"); 117 | if let Err(e) = self.restart().await { 118 | tracing::error!("Failed to recover the core instance: {}", e); 119 | tokio::time::sleep(std::time::Duration::from_secs(5)).await; 120 | if counter < 5 { 121 | Box::pin(self.recover_core(counter + 1)).await; 122 | } else { 123 | tracing::error!("Failed to recover the core instance after 5 times"); 124 | } 125 | } 126 | } 127 | } 128 | 129 | #[allow(clippy::too_many_arguments)] 130 | async fn handle_command_event( 131 | break_loop: &mut bool, 132 | err_buf: &mut Vec, 133 | state_changed_at: &AtomicI64, 134 | state_changed_notify: &Arc>>, 135 | tx: &MpscSender>, 136 | cancel_token: &CancellationToken, 137 | service_manager: CoreManagerService, 138 | event: CommandEvent, 139 | ) { 140 | match event { 141 | CommandEvent::Stdout(line) => { 142 | tracing::info!("{}", line); 143 | } 144 | CommandEvent::Stderr(line) => { 145 | tracing::error!("{}", line); 146 | err_buf.push(line); 147 | } 148 | CommandEvent::Error(e) => { 149 | tracing::error!("{}", e); 150 | let err = anyhow::anyhow!(format!("{}\n{}", e, err_buf.join("\n"))); 151 | let _ = tx.send(Err(err)).await; 152 | Self::notify_state_changed(state_changed_notify.clone(), CoreState::Stopped(None)); 153 | state_changed_at.store(get_current_ts(), Ordering::Relaxed); 154 | *break_loop = true; 155 | } 156 | CommandEvent::Terminated(status) => { 157 | tracing::info!("core terminated with status: {:?}", status); 158 | state_changed_at.store(get_current_ts(), Ordering::Relaxed); 159 | if status.code != Some(0) || !matches!(status.signal, Some(SIGKILL) | Some(SIGTERM)) 160 | { 161 | let err = anyhow::anyhow!(format!( 162 | "core terminated with status: {:?}\n{}", 163 | status, 164 | err_buf.join("\n") 165 | )); 166 | tracing::error!("{}\n{}", err, err_buf.join("\n")); 167 | Self::notify_state_changed( 168 | state_changed_notify.clone(), 169 | CoreState::Stopped(None), 170 | ); 171 | if tx.send(Err(err)).await.is_err() && !cancel_token.is_cancelled() { 172 | tokio::spawn(async move { 173 | service_manager.recover_core(0).await; 174 | }); 175 | } 176 | } 177 | *break_loop = true; 178 | } 179 | CommandEvent::DelayCheckpointPass => { 180 | tracing::debug!("delay checkpoint pass"); 181 | state_changed_at.store(get_current_ts(), Ordering::Relaxed); 182 | tx.send(Ok(())).await.unwrap(); 183 | } 184 | } 185 | } 186 | 187 | #[instrument(skip(self))] 188 | pub async fn start( 189 | &self, 190 | core_type: &CoreType, 191 | config_path: &Utf8Path, 192 | ) -> Result<(), anyhow::Error> { 193 | let mut manager = self.manager.lock().await; 194 | let state = Self::state_(manager.as_ref()); 195 | if matches!(state.as_ref(), CoreState::Running) { 196 | anyhow::bail!("core is already running"); 197 | } 198 | 199 | // check config_path 200 | let config_path = config_path.canonicalize_utf8()?; 201 | let config_path = 202 | Utf8PathBuf::from_path_buf(dunce::simplified(config_path.as_std_path()).to_path_buf()) 203 | .unwrap(); 204 | tokio::fs::metadata(&config_path).await?; // check if the file exists 205 | let infos = consts::RuntimeInfos::global(); 206 | let app_dir = infos.nyanpasu_data_dir.clone(); 207 | let binary_path = find_binary_path(core_type)?; 208 | let pid_path = crate::utils::dirs::service_core_pid_file(); 209 | let app_dir = Utf8PathBuf::from_path_buf(app_dir) 210 | .map_err(|_| anyhow::anyhow!("failed to convert app_dir to Utf8PathBuf"))?; 211 | let binary_path = Utf8PathBuf::from_path_buf(binary_path) 212 | .map_err(|_| anyhow::anyhow!("failed to convert binary_path to Utf8PathBuf"))?; 213 | let pid_path = Utf8PathBuf::from_path_buf(pid_path) 214 | .map_err(|_| anyhow::anyhow!("failed to convert pid_path to Utf8PathBuf"))?; 215 | tracing::info!( 216 | core_type = ?core_type, 217 | app_dir = %app_dir, 218 | binary_path = %binary_path, 219 | pid_path = %pid_path, 220 | config_path = %config_path, 221 | "Starting Core" 222 | ); 223 | let cancel_token = self.cancel_token.child_token(); 224 | let instance = CoreInstanceBuilder::default() 225 | .core_type(core_type.clone()) 226 | .app_dir(app_dir) 227 | .binary_path(binary_path) 228 | .config_path(config_path.clone()) 229 | .pid_path(pid_path) 230 | .build()?; 231 | let instance = Arc::new(instance); 232 | 233 | // start the core instance 234 | let state_changed_at = self.state_changed_at.clone(); 235 | let cancel_token_clone = cancel_token.clone(); 236 | let (tx, mut rx) = tokio::sync::mpsc::channel::>(1); // use mpsc channel just to avoid type moved error, though it never fails 237 | let service = self.clone(); 238 | let state_changed_notify = self.state_changed_notify.clone(); 239 | let instance_clone = instance.clone(); 240 | let tracker = TaskTracker::new(); 241 | tracker.spawn(async move { 242 | match instance_clone.run().await { 243 | Ok((_, mut rx)) => { 244 | let mut err_buf: Vec = Vec::with_capacity(6); 245 | let mut break_loop = false; 246 | 247 | while let Some(event) = rx.recv().await { 248 | Self::handle_command_event( 249 | &mut break_loop, 250 | &mut err_buf, 251 | &state_changed_at, 252 | &state_changed_notify, 253 | &tx, 254 | &cancel_token_clone, 255 | service.clone(), 256 | event, 257 | ) 258 | .await; 259 | if break_loop { 260 | break; 261 | } 262 | } 263 | } 264 | Err(err) => { 265 | spawn(async move { 266 | tx.send(Err(err.into())).await.unwrap(); 267 | }); 268 | } 269 | } 270 | }); 271 | // Create a task to check cancel token called 272 | let cancel_token_clone = cancel_token.clone(); 273 | let service = self.clone(); 274 | tracker.spawn(async move { 275 | cancel_token_clone.cancelled().await; 276 | if service.manager.try_lock().is_ok() { 277 | let _ = service.stop().await; 278 | } 279 | }); 280 | tracker.close(); 281 | rx.recv().await.unwrap()?; 282 | drop(rx); 283 | Self::notify_state_changed(self.state_changed_notify.clone(), CoreState::Running); 284 | *manager = Some(CoreManager { 285 | instance, 286 | config_path: config_path.to_path_buf(), 287 | cancel_token, 288 | tracker: Some(tracker), 289 | }); 290 | Ok(()) 291 | } 292 | 293 | pub async fn stop(&self) -> Result<(), anyhow::Error> { 294 | let mut manager = self.manager.lock().await; 295 | let state = Self::state_(manager.as_ref()); 296 | if matches!(state.as_ref(), CoreState::Stopped(_)) { 297 | anyhow::bail!("core is already stopped"); 298 | } 299 | 300 | if let Some(manager) = manager.as_mut() { 301 | manager.cancel_token.cancel(); 302 | manager.instance.kill().await?; 303 | if let Some(tracker) = manager.tracker.take() { 304 | tracker.wait().await; 305 | } 306 | } 307 | 308 | Self::notify_state_changed(self.state_changed_notify.clone(), CoreState::Stopped(None)); 309 | Ok(()) 310 | } 311 | 312 | pub async fn restart(&self) -> Result<(), anyhow::Error> { 313 | let mut manager_guard = self.manager.lock().await; 314 | let manager = manager_guard.take(); 315 | match manager { 316 | None => anyhow::bail!("core have not been started yet"), 317 | Some(manager) => { 318 | let state = Self::state_(Some(&manager)); 319 | if matches!(state.as_ref(), CoreState::Running) { 320 | self.stop().await?; 321 | } 322 | drop(manager_guard); 323 | self.start(&manager.instance.core_type, manager.config_path.as_path()) 324 | .await 325 | } 326 | } 327 | } 328 | } 329 | 330 | // TODO: support system path search via a config or flag 331 | /// Search the binary path of the core: Data Dir -> Sidecar Dir 332 | pub fn find_binary_path(core_type: &CoreType) -> std::io::Result { 333 | let infos = consts::RuntimeInfos::global(); 334 | let data_dir = &infos.nyanpasu_data_dir; 335 | let binary_path = data_dir.join(core_type.get_executable_name()); 336 | if binary_path.exists() { 337 | return Ok(binary_path); 338 | } 339 | let app_dir = &infos.nyanpasu_app_dir; 340 | let binary_path = app_dir.join(core_type.get_executable_name()); 341 | if binary_path.exists() { 342 | return Ok(binary_path); 343 | } 344 | Err(std::io::Error::new( 345 | std::io::ErrorKind::NotFound, 346 | format!("{} not found", core_type.get_executable_name()), 347 | )) 348 | } 349 | --------------------------------------------------------------------------------