├── .github └── workflows │ └── deploy.yml ├── Cargo.toml ├── README.md ├── Shuttle.toml └── main.rs /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Shuttle 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: 7 | schedule: 8 | - cron: '0 0 1,30 * *' # 每30天运行一次(UTC时间0点) 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Install Rust 18 | uses: dtolnay/rust-toolchain@stable 19 | with: 20 | toolchain: stable 21 | 22 | - name: Install specific version of Shuttle CLI 23 | run: | 24 | cargo install cargo-shuttle --version 0.52.0 --locked --force 25 | 26 | - name: Update Cargo dependencies 27 | run: | 28 | cargo update 29 | 30 | - name: Create Shuttle.toml 31 | run: | 32 | echo 'name = "shuttle-app"' > Shuttle.toml 33 | 34 | - name: Deploy to Shuttle 35 | env: 36 | SHUTTLE_API_KEY: ${{ secrets.SHUTTLE_API_KEY }} 37 | run: | 38 | cargo shuttle project create --name shuttle-app 39 | cargo shuttle deploy --name shuttle-app 40 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shuttle-app" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | axum = { version = "0.8.1", features = ["macros"] } 8 | shuttle-axum = "0.52.0" 9 | shuttle-runtime = "0.52.0" 10 | tokio = { version = "1.28.2", features = ["full"] } 11 | serde = { version = "1.0", features = ["derive"] } 12 | reqwest = "0.11.25" 13 | serde_json = "1.0" 14 | base64 = "0.21" 15 | regex = "1.5" 16 | 17 | [[bin]] 18 | name = "shuttle-app" 19 | path = "main.rs" 20 | 21 | [profile.release] 22 | strip = true 23 | lto = true 24 | codegen-units = 1 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shuttle 部署指南 2 | 3 | 本项目是一个基于 Shuttle 平台的自动化部署方案,通过 GitHub Actions 实现自动部署2go节点,每30天自动部署一次 4 | 5 | ## 部署流程 6 | 1. Fork 本项目 7 | 2. 注册 [Shuttle](https://www.shuttle.rs/) 账号并获取 API 密钥 8 | ![image](https://github.com/user-attachments/assets/68bf5dc6-8884-4ba6-b88b-b47b66878092) 9 | 10 | 3. 在 seettings---secrets ansd variables中的actions里设置环境变量:`SHUTTLE_API_KEY`(填入你的 Shuttle API 密钥) 11 | ![image](https://github.com/user-attachments/assets/d67ab79b-8d1d-437e-8c6b-786163e197a2) 12 | 13 | 4. 安装环境时间稍长,预计部署时间需要10分钟,请耐心等待,在actions中查看进度 14 | ## 订阅 15 | https://<你的域名>/sub 16 | -------------------------------------------------------------------------------- /Shuttle.toml: -------------------------------------------------------------------------------- 1 | name = "shuttle-app" 2 | 3 | -------------------------------------------------------------------------------- /main.rs: -------------------------------------------------------------------------------- 1 | use axum::{routing::get, Router, response::IntoResponse}; 2 | use regex::Regex; 3 | use serde_json::{json, Value}; 4 | use std::env; 5 | use std::fs::{self, File, read_to_string}; 6 | use std::io::Write; 7 | use std::path::Path; 8 | use std::process::Command; 9 | use tokio::time::{sleep, Duration}; 10 | use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; 11 | use base64::Engine as _; 12 | 13 | async fn hello_world() -> &'static str { 14 | "Hello, world!" 15 | } 16 | 17 | async fn setup_environment() { 18 | let env_vars = [ 19 | ("UUID", "66e5c8dd-3176-458e-8fb0-1ed91d2f9602"), 20 | ("NEZHA_SERVER", ""), // 哪吒v1填写形式:nezha.xxx.com:8008 // 哪吒v0填写形式:nezha.xxx.com 21 | ("NEZHA_PORT", ""), // 哪吒v1请留空此变量,哪吒v0的agent端口 22 | ("NEZHA_KEY", ""), // 哪吒v1的NZ-CLIENT_SECRET或哪吒v0的agent密钥 23 | ("ARGO_DOMAIN", ""), // argo固定隧道域名,留空将使用临时隧道 24 | ("ARGO_AUTH", ""), // argo固定隧道密钥,json或token,留空将使用临时隧道, 25 | ("ARGO_PORT", "8080"), // argo端口,使用固定隧道token,需要在cloudflare后台也设置端口为8080 26 | ("CFIP", "time.is"), // 优选域名或优选ip 27 | ("CFPORT", "443"), // 优选域名或优选ip对应的端口 28 | ("NAME", "Shuttle"), // 节点名称 29 | ("FILE_PATH", "./tmp"), // 运行目录,保持不变 30 | ("SUB_PATH", "sub"), // 获取节点订阅路径,分配的域名/sub 31 | ]; 32 | 33 | for (key, default_value) in env_vars { 34 | if env::var(key).is_err() { 35 | env::set_var(key, default_value); 36 | } 37 | } 38 | } 39 | 40 | async fn read_sub() -> impl IntoResponse { 41 | let file_path = env::var("FILE_PATH").unwrap_or_else(|_| "./tmp".to_string()); 42 | let sub_path = env::var("SUB_PATH").unwrap_or_else(|_| "sub".to_string()); 43 | match read_to_string(format!("{}/{}.txt", file_path, sub_path)) { 44 | Ok(content) => content, 45 | Err(_) => "Failed to read sub.txt".to_string(), 46 | } 47 | } 48 | 49 | async fn create_config_files() { 50 | let file_path = env::var("FILE_PATH").unwrap_or_else(|_| "./tmp".to_string()); 51 | let uuid = env::var("UUID").unwrap_or_default(); 52 | let argo_port = env::var("ARGO_PORT").unwrap_or_else(|_| "8080".to_string()); 53 | let argo_auth = env::var("ARGO_AUTH").unwrap_or_default(); 54 | let argo_domain = env::var("ARGO_DOMAIN").unwrap_or_default(); 55 | 56 | if !Path::new(&file_path).exists() { 57 | fs::create_dir_all(&file_path).expect("Failed to create directory"); 58 | } 59 | 60 | let old_files = ["boot.log", "sub.txt", "config.json", "tunnel.json", "tunnel.yml", "config.yaml"]; 61 | for file in old_files.iter() { 62 | let file_path = format!("{}/{}", file_path, file); 63 | let _ = fs::remove_file(file_path); 64 | } 65 | 66 | // Create Nezha v1 config if needed 67 | let nezha_server = env::var("NEZHA_SERVER").unwrap_or_default(); 68 | let nezha_key = env::var("NEZHA_KEY").unwrap_or_default(); 69 | let nezha_port = env::var("NEZHA_PORT").unwrap_or_default(); 70 | 71 | if !nezha_server.is_empty() && !nezha_key.is_empty() && nezha_port.is_empty() { 72 | let nezha_tls = match nezha_server.split(':').last().unwrap_or("") { 73 | "443" | "8443" | "2096" | "2087" | "2083" | "2053" => "true", 74 | _ => "false", 75 | }; 76 | 77 | let config_yaml = format!( 78 | r#"client_secret: {key} 79 | debug: false 80 | disable_auto_update: true 81 | disable_command_execute: false 82 | disable_force_update: true 83 | disable_nat: false 84 | disable_send_query: false 85 | gpu: false 86 | insecure_tls: false 87 | ip_report_period: 1800 88 | report_delay: 1 89 | server: {server} 90 | skip_connection_count: false 91 | skip_procs_count: false 92 | temperature: false 93 | tls: {tls} 94 | use_gitee_to_upgrade: false 95 | use_ipv6_country_code: false 96 | uuid: {uuid}"#, 97 | key = nezha_key, 98 | server = nezha_server, 99 | tls = nezha_tls, 100 | uuid = uuid 101 | ); 102 | 103 | fs::write(format!("{}/config.yaml", file_path), config_yaml) 104 | .expect("Failed to write config.yaml"); 105 | } 106 | 107 | if !argo_auth.is_empty() && !argo_domain.is_empty() { 108 | if argo_auth.contains("TunnelSecret") { 109 | fs::write(format!("{}/tunnel.json", file_path), &argo_auth) 110 | .expect("Failed to write tunnel.json"); 111 | 112 | let tunnel_id = { 113 | let re = Regex::new(r#""TunnelID":"([^"]+)""#).unwrap(); 114 | re.captures(&argo_auth) 115 | .and_then(|cap| cap.get(1)) 116 | .map(|m| m.as_str().to_string()) 117 | .unwrap_or_default() 118 | }; 119 | 120 | let tunnel_yml = format!( 121 | r#"tunnel: {} 122 | credentials-file: {}/tunnel.json 123 | protocol: http2 124 | 125 | ingress: 126 | - hostname: {} 127 | service: http://localhost:{} 128 | originRequest: 129 | noTLSVerify: true 130 | - service: http_status:404 131 | "#, 132 | tunnel_id, file_path, argo_domain, argo_port 133 | ); 134 | 135 | fs::write(format!("{}/tunnel.yml", file_path), tunnel_yml) 136 | .expect("Failed to write tunnel.yml"); 137 | } 138 | } 139 | 140 | let config = json!({ 141 | "log": { 142 | "access": "/dev/null", 143 | "error": "/dev/null", 144 | "loglevel": "none" 145 | }, 146 | "inbounds": [ 147 | { 148 | "port": argo_port.parse::().unwrap_or(8080), 149 | "protocol": "vless", 150 | "settings": { 151 | "clients": [ 152 | { 153 | "id": uuid, 154 | "flow": "xtls-rprx-vision" 155 | } 156 | ], 157 | "decryption": "none", 158 | "fallbacks": [ 159 | { "dest": 3001 }, 160 | { "path": "/vless-argo", "dest": 3002 }, 161 | { "path": "/vmess-argo", "dest": 3003 }, 162 | { "path": "/trojan-argo", "dest": 3004 } 163 | ] 164 | }, 165 | "streamSettings": { 166 | "network": "tcp" 167 | } 168 | }, 169 | { 170 | "port": 3001, 171 | "listen": "127.0.0.1", 172 | "protocol": "vless", 173 | "settings": { 174 | "clients": [{ "id": uuid }], 175 | "decryption": "none" 176 | }, 177 | "streamSettings": { 178 | "network": "ws", 179 | "security": "none" 180 | } 181 | }, 182 | { 183 | "port": 3002, 184 | "listen": "127.0.0.1", 185 | "protocol": "vless", 186 | "settings": { 187 | "clients": [{ "id": uuid, "level": 0 }], 188 | "decryption": "none" 189 | }, 190 | "streamSettings": { 191 | "network": "ws", 192 | "security": "none", 193 | "wsSettings": { 194 | "path": "/vless-argo" 195 | } 196 | }, 197 | "sniffing": { 198 | "enabled": true, 199 | "destOverride": ["http", "tls", "quic"], 200 | "metadataOnly": false 201 | } 202 | }, 203 | { 204 | "port": 3003, 205 | "listen": "127.0.0.1", 206 | "protocol": "vmess", 207 | "settings": { 208 | "clients": [{ "id": uuid, "alterId": 0 }] 209 | }, 210 | "streamSettings": { 211 | "network": "ws", 212 | "wsSettings": { 213 | "path": "/vmess-argo" 214 | } 215 | }, 216 | "sniffing": { 217 | "enabled": true, 218 | "destOverride": ["http", "tls", "quic"], 219 | "metadataOnly": false 220 | } 221 | }, 222 | { 223 | "port": 3004, 224 | "listen": "127.0.0.1", 225 | "protocol": "trojan", 226 | "settings": { 227 | "clients": [{ "password": uuid }] 228 | }, 229 | "streamSettings": { 230 | "network": "ws", 231 | "security": "none", 232 | "wsSettings": { 233 | "path": "/trojan-argo" 234 | } 235 | }, 236 | "sniffing": { 237 | "enabled": true, 238 | "destOverride": ["http", "tls", "quic"], 239 | "metadataOnly": false 240 | } 241 | } 242 | ], 243 | "outbounds": [ 244 | { 245 | "protocol": "freedom", 246 | "tag": "direct" 247 | }, 248 | { 249 | "protocol": "blackhole", 250 | "tag": "block" 251 | } 252 | ] 253 | }); 254 | 255 | let config_str = serde_json::to_string_pretty(&config).unwrap(); 256 | fs::write(format!("{}/config.json", file_path), config_str) 257 | .expect("Failed to write config.json"); 258 | } 259 | 260 | async fn download_files() { 261 | let file_path = env::var("FILE_PATH").unwrap_or_else(|_| "./tmp".to_string()); 262 | let arch = Command::new("uname") 263 | .arg("-m") 264 | .output() 265 | .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) 266 | .unwrap_or_default(); 267 | 268 | let nezha_server = env::var("NEZHA_SERVER").unwrap_or_default(); 269 | let nezha_port = env::var("NEZHA_PORT").unwrap_or_default(); 270 | let nezha_key = env::var("NEZHA_KEY").unwrap_or_default(); 271 | 272 | // Determine Nezha agent URL based on environment variables 273 | let nezha_agent_url = if !nezha_server.is_empty() && !nezha_key.is_empty() { 274 | if nezha_port.is_empty() { 275 | // Use v1 agent if port is not specified 276 | match arch.as_str() { 277 | "arm" | "arm64" | "aarch64" => "https://arm64.ssss.nyc.mn/v1", 278 | "amd64" | "x86_64" | "x86" => "https://amd64.ssss.nyc.mn/v1", 279 | _ => "", 280 | } 281 | } else { 282 | // Use regular agent if port is specified 283 | match arch.as_str() { 284 | "arm" | "arm64" | "aarch64" => "https://arm64.ssss.nyc.mn/agent", 285 | "amd64" | "x86_64" | "x86" => "https://amd64.ssss.nyc.mn/agent", 286 | _ => "", 287 | } 288 | } 289 | } else { 290 | "" 291 | }; 292 | 293 | let file_info = match arch.as_str() { 294 | "arm" | "arm64" | "aarch64" => vec![ 295 | ("https://amd64.ssss.nyc.mn/2go", "bot"), 296 | ("https://arm64.ssss.nyc.mn/web", "web"), 297 | (nezha_agent_url, if nezha_port.is_empty() { "php" } else { "npm" }), 298 | ], 299 | "amd64" | "x86_64" | "x86" => vec![ 300 | ("https://amd64.ssss.nyc.mn/2go", "bot"), 301 | ("https://amd64.ssss.nyc.mn/web", "web"), 302 | (nezha_agent_url, if nezha_port.is_empty() { "php" } else { "npm" }), 303 | ], 304 | _ => vec![], 305 | }; 306 | 307 | for (url, filename) in file_info { 308 | if url.is_empty() { 309 | continue; 310 | } 311 | let filepath = format!("{}/{}", file_path, filename); 312 | if !Path::new(&filepath).exists() { 313 | Command::new("curl") 314 | .args(["-L", "-sS", "-o", &filepath, url]) 315 | .status() 316 | .expect("Failed to download file"); 317 | 318 | Command::new("chmod") 319 | .args(["777", &filepath]) 320 | .status() 321 | .expect("Failed to set permissions"); 322 | } 323 | } 324 | } 325 | 326 | async fn run_services() { 327 | let file_path = env::var("FILE_PATH").unwrap_or_else(|_| "./tmp".to_string()); 328 | 329 | let nezha_server = env::var("NEZHA_SERVER").unwrap_or_default(); 330 | let nezha_port = env::var("NEZHA_PORT").unwrap_or_default(); 331 | let nezha_key = env::var("NEZHA_KEY").unwrap_or_default(); 332 | 333 | // Run Nezha agent based on version 334 | if !nezha_server.is_empty() && !nezha_key.is_empty() { 335 | if nezha_port.is_empty() { 336 | // Run v1 agent (php) 337 | if Path::new(&format!("{}/php", file_path)).exists() { 338 | Command::new(format!("{}/php", file_path)) 339 | .args(["-c", &format!("{}/config.yaml", file_path)]) 340 | .spawn() 341 | .expect("Failed to start php (Nezha v1)"); 342 | } 343 | } else { 344 | // Run regular agent (npm) 345 | if Path::new(&format!("{}/npm", file_path)).exists() { 346 | let tls_ports = ["443", "8443", "2096", "2087", "2083", "2053"]; 347 | let nezha_tls = if tls_ports.contains(&nezha_port.as_str()) { "--tls" } else { "" }; 348 | 349 | Command::new(format!("{}/npm", file_path)) 350 | .args(["-s", &format!("{}:{}", nezha_server, nezha_port), "-p", &nezha_key]) 351 | .arg(nezha_tls) 352 | .spawn() 353 | .expect("Failed to start npm"); 354 | } 355 | } 356 | } 357 | 358 | sleep(Duration::from_secs(2)).await; 359 | 360 | if Path::new(&format!("{}/web", file_path)).exists() { 361 | Command::new(format!("{}/web", file_path)) 362 | .args(["-c", &format!("{}/config.json", file_path)]) 363 | .spawn() 364 | .expect("Failed to start web"); 365 | } 366 | 367 | sleep(Duration::from_secs(2)).await; 368 | 369 | if Path::new(&format!("{}/bot", file_path)).exists() { 370 | let argo_auth = env::var("ARGO_AUTH").unwrap_or_default(); 371 | let argo_port = env::var("ARGO_PORT").unwrap_or_default(); 372 | 373 | let boot_log_path = format!("{}/boot.log", file_path); 374 | let tunnel_yml_path = format!("{}/tunnel.yml", file_path); 375 | let url = format!("http://localhost:{}", argo_port); 376 | 377 | let args = if argo_auth.len() >= 120 && argo_auth.len() <= 250 { 378 | vec!["tunnel", "--edge-ip-version", "auto", "--no-autoupdate", 379 | "--protocol", "http2", "run", "--token", &argo_auth] 380 | } else if argo_auth.contains("TunnelSecret") { 381 | vec!["tunnel", "--edge-ip-version", "auto", 382 | "--config", &tunnel_yml_path, "run"] 383 | } else { 384 | vec!["tunnel", "--edge-ip-version", "auto", "--no-autoupdate", 385 | "--protocol", "http2", "--logfile", &boot_log_path, 386 | "--loglevel", "info", "--url", &url] 387 | }; 388 | 389 | Command::new(format!("{}/bot", file_path)) 390 | .args(&args) 391 | .spawn() 392 | .expect("Failed to start bot"); 393 | } 394 | } 395 | 396 | async fn generate_links() { 397 | let file_path = env::var("FILE_PATH").unwrap_or_else(|_| "./tmp".to_string()); 398 | sleep(Duration::from_secs(6)).await; 399 | 400 | let argo_auth = env::var("ARGO_AUTH").unwrap_or_default(); 401 | let argo_domain = env::var("ARGO_DOMAIN").unwrap_or_default(); 402 | 403 | let argodomain = if !argo_auth.is_empty() { 404 | argo_domain 405 | } else { 406 | let boot_log = fs::read_to_string(format!("{}/boot.log", file_path)) 407 | .unwrap_or_default(); 408 | let re = Regex::new(r"https://([^/]+)\.trycloudflare\.com").unwrap(); 409 | re.captures(&boot_log) 410 | .and_then(|cap| cap.get(1)) 411 | .map(|m| format!("{}.trycloudflare.com", m.as_str())) 412 | .unwrap_or_default() 413 | }; 414 | 415 | println!("ArgoDomain: {}", argodomain); 416 | sleep(Duration::from_secs(2)).await; 417 | 418 | let isp = Command::new("curl") 419 | .args(["-s", "https://speed.cloudflare.com/meta"]) 420 | .output() 421 | .ok() 422 | .and_then(|output| { 423 | let output_str = String::from_utf8_lossy(&output.stdout); 424 | let v: Value = serde_json::from_str(&output_str).unwrap_or(json!({})); 425 | Some(format!("{}-{}", 426 | v["country"].as_str().unwrap_or(""), 427 | v["asOrganization"].as_str().unwrap_or("") 428 | ).replace(" ", "_")) 429 | }) 430 | .unwrap_or_default(); 431 | 432 | sleep(Duration::from_secs(2)).await; 433 | 434 | let uuid = env::var("UUID").unwrap_or_default(); 435 | let cfip = env::var("CFIP").unwrap_or_default(); 436 | let cfport = env::var("CFPORT").unwrap_or_default(); 437 | let name = env::var("NAME").unwrap_or_default(); 438 | 439 | let vmess_config = json!({ 440 | "v": "2", 441 | "ps": format!("{}-{}", name, isp), 442 | "add": cfip, 443 | "port": cfport, 444 | "id": uuid, 445 | "aid": "0", 446 | "scy": "none", 447 | "net": "ws", 448 | "type": "none", 449 | "host": argodomain, 450 | "path": "/vmess-argo?ed=2560", 451 | "tls": "tls", 452 | "sni": argodomain, 453 | "alpn": "", 454 | "fp": "chrome", 455 | }); 456 | 457 | let mut list_file = File::create(format!("{}/list.txt", file_path)) 458 | .expect("Failed to create list.txt"); 459 | 460 | writeln!(list_file, "vless://{}@{}:{}?encryption=none&security=tls&sni={}&type=ws&host={}&path=%2Fvless-argo%3Fed%3D2560#{}-{}", 461 | uuid, cfip, cfport, argodomain, argodomain, name, isp).unwrap(); 462 | 463 | writeln!(list_file, "\nvmess://{}", 464 | BASE64_STANDARD.encode(serde_json::to_string(&vmess_config).unwrap())).unwrap(); 465 | 466 | writeln!(list_file, "\ntrojan://{}@{}:{}?security=tls&sni={}&type=ws&host={}&path=%2Ftrojan-argo%3Fed%3D2560#{}-{}", 467 | uuid, cfip, cfport, argodomain, argodomain, name, isp).unwrap(); 468 | 469 | let list_content = fs::read_to_string(format!("{}/list.txt", file_path)) 470 | .expect("Failed to read list.txt"); 471 | let sub_content = BASE64_STANDARD.encode(list_content.as_bytes()); 472 | 473 | fs::write( 474 | format!("{}/sub.txt", file_path), 475 | &sub_content 476 | ).expect("Failed to write sub.txt"); 477 | 478 | println!("\n"); 479 | println!("{}", sub_content); 480 | 481 | for file in ["list.txt", "boot.log", "config.json", "tunnel.json", "tunnel.yml"].iter() { 482 | let _ = fs::remove_file(format!("{}/{}", file_path, file)); 483 | } 484 | } 485 | 486 | #[shuttle_runtime::main] 487 | async fn main() -> shuttle_axum::ShuttleAxum { 488 | setup_environment().await; 489 | create_config_files().await; 490 | download_files().await; 491 | run_services().await; 492 | generate_links().await; 493 | 494 | println!("App is running!"); 495 | 496 | let router = Router::new() 497 | .route("/", get(hello_world)) 498 | .route(&format!("/{}", env::var("SUB_PATH").unwrap_or_else(|_| "sub".to_string())), get(read_sub)); 499 | 500 | Ok(router.into()) 501 | } 502 | --------------------------------------------------------------------------------