diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..c3899d1 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +# libtor-src compiles Tor from C and needs to find OpenSSL. +# On macOS, Homebrew installs OpenSSL in a non-standard prefix, so we hint the +# configure script via CFLAGS/LDFLAGS. On Linux these paths don't exist and +# the flags are harmlessly ignored. +[env] +CFLAGS = "-I/opt/homebrew/opt/openssl@3/include" +LDFLAGS = "-L/opt/homebrew/opt/openssl@3/lib" diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index a4039ad..993e979 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -43,7 +43,7 @@ jobs: - name: Test container starts and responds run: | - docker run -d --rm --name test-container -p 3000:3000 \ + docker run -d --name test-container -p 3000:3000 \ -e DASHBOARD_HOST=0.0.0.0 -e DASHBOARD_ALLOW_REMOTE=true \ maker-dashboard:test # Wait for the server to be ready @@ -55,10 +55,11 @@ jobs: if [ "$i" -eq 30 ]; then echo "Server failed to start" docker logs test-container + docker rm -f test-container || true exit 1 fi sleep 1 done # Verify API responds curl -sf http://localhost:3000/api/makers | jq . - docker stop test-container || true + docker rm -f test-container || true diff --git a/Cargo.lock b/Cargo.lock index 91f81b8..962255c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,15 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "autotools" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf" +dependencies = [ + "cc", +] + [[package]] name = "axum" version = "0.8.8" @@ -505,7 +514,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -558,6 +567,35 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -701,6 +739,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -709,7 +756,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -731,7 +778,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.116", "unicode-xid", ] @@ -812,7 +859,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -905,6 +952,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -961,7 +1014,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -1458,6 +1511,64 @@ dependencies = [ "libc", ] +[[package]] +name = "libtor" +version = "47.13.0+0.4.7.x" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1be588c6a2f02b860a1c0e3b2a59edcb171058f8da71b8ca0ddd7bb40f102c5c" +dependencies = [ + "libtor-derive", + "libtor-sys", + "log", + "rand 0.8.5", + "sha1 0.6.1", +] + +[[package]] +name = "libtor-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177781b25e83853831c5af66320ceaf5e456e1b6d533426fcd9c7544b5543043" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "libtor-src" +version = "47.13.0+0.4.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e73bef51ecfbe7e63ce5cb8757ebc59d09dca6985da7f7470931ac22eab00719" +dependencies = [ + "fs_extra", +] + +[[package]] +name = "libtor-sys" +version = "47.13.0+0.4.7.x" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb0bc2cfc5d03851617d33508acc511e46f0c2b3cbc3cda85defcb50efa628bb" +dependencies = [ + "autotools", + "cc", + "libtor-src", + "libz-sys", + "openssl-sys", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1548,6 +1659,7 @@ dependencies = [ "dirs 6.0.0", "futures", "http-body-util", + "libtor", "log", "serde", "serde_cbor", @@ -1692,6 +1804,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-traits" version = "0.2.19" @@ -1742,7 +1860,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -1886,6 +2004,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1902,7 +2026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.116", ] [[package]] @@ -2111,7 +2235,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn", + "syn 2.0.116", "walkdir", ] @@ -2174,24 +2298,24 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] @@ -2208,9 +2332,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2399,7 +2523,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -2460,6 +2584,15 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da05c97445caa12d05e848c4a4fcbbea29e748ac28f7e80e9b010392063770" +dependencies = [ + "sha1_smol", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2471,6 +2604,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -2585,6 +2724,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.116" @@ -2610,7 +2760,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -2671,7 +2821,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -2682,7 +2832,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -2704,6 +2854,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2739,6 +2920,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -2752,7 +2934,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -2876,7 +3058,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -2931,7 +3113,7 @@ dependencies = [ "log", "native-tls", "rand 0.9.2", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "utf-8", ] @@ -3017,20 +3199,34 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.12.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64 0.22.1", + "cookie_store", "flate2", "log", - "once_cell", - "rustls 0.23.37", + "percent-encoding", + "rustls 0.23.40", "rustls-pki-types", "serde", "serde_json", - "url", - "webpki-roots 0.26.11", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.7", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", ] [[package]] @@ -3052,6 +3248,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3097,7 +3299,7 @@ checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3208,7 +3410,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.116", "wasm-bindgen-shared", ] @@ -3273,18 +3475,9 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -3353,7 +3546,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3364,7 +3557,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3595,7 +3788,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.116", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3611,7 +3804,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.116", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3678,7 +3871,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", "synstructure", ] @@ -3699,7 +3892,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] @@ -3719,7 +3912,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", "synstructure", ] @@ -3769,7 +3962,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.116", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8fa5f96..cb13be0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ integration-test = ["coinswap/integration-test"] [dependencies] axum = "0.8" -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] } tower-http = { version = "0.6", features = ["fs", "trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } @@ -26,15 +26,16 @@ thiserror = "2.0.18" futures = "0.3.32" clap = { version = "4.5.60", features = ["derive", "env"] } dirs = "6.0.0" +libtor = "47" [[test]] name = "api" path = "tests/api/mod.rs" [dev-dependencies] -ureq = { version = "2", features = ["json"] } bitcoind = { version = "0.36", features = [] } bitcoin = { version = "0.32" } log = "0.4" tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" +ureq = { version = "3", features = ["json"] } diff --git a/README.md b/README.md index f12f202..5fff271 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,6 @@ This starts a custom signet bitcoind, a Tor daemon, and the maker dashboard — When creating a maker, use: - RPC: `127.0.0.1:38332`, ZMQ: `tcp://127.0.0.1:28332` - RPC credentials: `user` / `password` -- Tor auth: `coinswap` - SOCKS port: `9050`, Control port: `9051` Other useful commands: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d68ad4b..252ab3a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -31,13 +31,11 @@ services: command: - -c - | - HASH=$$(tor --hash-password "coinswap" | grep "^16:") - cat > /tmp/torrc << EOF SocksPort 0.0.0.0:9050 ControlPort 0.0.0.0:9051 DataDirectory /var/lib/tor - HashedControlPassword $$HASH + CookieAuthentication 0 EOF echo "Starting Tor..." diff --git a/frontend/app/api.ts b/frontend/app/api.ts index 6c44941..a9023d1 100644 --- a/frontend/app/api.ts +++ b/frontend/app/api.ts @@ -346,6 +346,13 @@ export const fidelity = { // ─── Monitoring ─────────────────────────────────────────────────────────────── +export type TorSource = "system" | "host" | "docker"; + +export interface TorStatusInfo { + source: TorSource; + managed: boolean; +} + export const monitoring = { status: (id: string): Promise => get(`/makers/${id}/status`), torAddress: (id: string): Promise => get(`/makers/${id}/tor-address`), @@ -361,6 +368,7 @@ export const monitoring = { get(`/makers/${id}/rpc-status`), combinedLogs: (lines?: number): Promise => get(`/logs/combined${lines !== undefined ? `?lines=${lines}` : ""}`), + getTorStatus: (): Promise => get("/tor/status"), }; // ─── Bitcoind ───────────────────────────────────────────────────────────────── diff --git a/frontend/app/app.css b/frontend/app/app.css index c8105a1..baeb4b3 100644 --- a/frontend/app/app.css +++ b/frontend/app/app.css @@ -47,6 +47,17 @@ body { } } +@keyframes slide-in-right { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + @keyframes shimmer { from { background-position: -200% center; @@ -64,6 +75,10 @@ body { animation: slideInUp 0.35s ease-out both; } +@utility animate-slide-in-right { + animation: slide-in-right 0.35s ease-out both; +} + @utility animate-shimmer { background: linear-gradient( 90deg, diff --git a/frontend/app/components/Toast.tsx b/frontend/app/components/Toast.tsx new file mode 100644 index 0000000..267df41 --- /dev/null +++ b/frontend/app/components/Toast.tsx @@ -0,0 +1,32 @@ +import { useEffect, useEffectEvent, useState } from "react"; + +interface ToastProps { + message: string; + durationMs?: number; + onDismiss: () => void; +} + +export function Toast({ message, durationMs = 5000, onDismiss }: ToastProps) { + const [visible, setVisible] = useState(true); + const onDismissEvent = useEffectEvent(() => onDismiss()); + + useEffect(() => { + const hide = setTimeout(() => setVisible(false), durationMs - 400); + const dismiss = setTimeout(onDismissEvent, durationMs); + return () => { + clearTimeout(hide); + clearTimeout(dismiss); + }; + }, [durationMs]); + + if (!visible) return null; + + return ( +
+
+ + {message} +
+
+ ); +} diff --git a/frontend/app/main.tsx b/frontend/app/main.tsx index b94846f..b07c1ef 100644 --- a/frontend/app/main.tsx +++ b/frontend/app/main.tsx @@ -1,22 +1,51 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { StrictMode, useEffect, useState } from "react"; import "@/app.css"; import Home from "./routes/home"; import MakerDetails from "./routes/makerDetails"; import AddMaker from "./routes/addMaker"; import MakerSetup from "./routes/makersetup"; -import { StrictMode } from "react"; +import { Toast } from "./components/Toast"; +import { monitoring } from "./api"; + +function App() { + const [torToast, setTorToast] = useState(null); + + useEffect(() => { + if (sessionStorage.getItem("tor-toast-shown")) return; + monitoring + .getTorStatus() + .then(({ managed, source }) => { + if (managed) { + const label = source === "docker" ? "Docker" : "host binary"; + setTorToast(`Tor started via ${label}`); + sessionStorage.setItem("tor-toast-shown", "1"); + } + }) + .catch(() => {}); + }, []); + + return ( + <> + + + } /> + } /> + } /> + } /> + + + {torToast && ( + setTorToast(null)} /> + )} + + ); +} createRoot(document.getElementById("root")!).render( - - - } /> - } /> - } /> - } /> - - + , ); diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 4b12452..a0067d9 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./vite-env.d.ts","./vite.config.ts","./app/api.ts","./app/main.tsx","./app/components/bitcoindwidget.tsx","./app/components/nav.tsx","./app/routes/addmaker.tsx","./app/routes/home.tsx","./app/routes/makersetup.tsx","./app/routes/onboarding.tsx","./app/routes/makerdetails/components.tsx","./app/routes/makerdetails/dashboard.tsx","./app/routes/makerdetails/history.tsx","./app/routes/makerdetails/index.tsx","./app/routes/makerdetails/log.tsx","./app/routes/makerdetails/settings.tsx","./app/routes/makerdetails/types.ts","./app/routes/makerdetails/wallet.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./vite-env.d.ts","./vite.config.ts","./app/api.ts","./app/main.tsx","./app/components/bitcoindwidget.tsx","./app/components/nav.tsx","./app/components/toast.tsx","./app/routes/addmaker.tsx","./app/routes/home.tsx","./app/routes/makersetup.tsx","./app/routes/onboarding.tsx","./app/routes/makerdetails/components.tsx","./app/routes/makerdetails/dashboard.tsx","./app/routes/makerdetails/history.tsx","./app/routes/makerdetails/index.tsx","./app/routes/makerdetails/log.tsx","./app/routes/makerdetails/settings.tsx","./app/routes/makerdetails/types.ts","./app/routes/makerdetails/wallet.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/src/api/dto.rs b/src/api/dto.rs index ce21c34..b4d7869 100644 --- a/src/api/dto.rs +++ b/src/api/dto.rs @@ -403,3 +403,12 @@ pub struct BitcoindStatusInfo { /// True only when bitcoind was started by the dashboard (and can be stopped via /stop) pub managed: bool, } + +/// Tor connectivity status reported at startup +#[derive(Debug, Serialize, ToSchema)] +pub struct TorStatusInfo { + /// How tor was obtained: "system", "host", or "docker" + pub source: &'static str, + /// Whether the dashboard started/manages the tor process + pub managed: bool, +} diff --git a/src/api/makers.rs b/src/api/makers.rs index 5f86937..f98df25 100644 --- a/src/api/makers.rs +++ b/src/api/makers.rs @@ -136,12 +136,19 @@ async fn create_maker( Json(body): Json, ) -> (StatusCode, Json>) { let mut mgr = state.lock().await; - if mgr.has_maker(&body.id) { + let trimmed_id = body.id.trim().to_string(); + if trimmed_id.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse::err("Maker ID cannot be empty")), + ); + } + if mgr.has_maker(&trimmed_id) { return ( StatusCode::CONFLICT, Json(ApiResponse::err(format!( "Maker '{}' already exists", - body.id + trimmed_id ))), ); } @@ -207,10 +214,10 @@ async fn create_maker( } } - match mgr.create_maker(body.id.clone(), config) { + match mgr.create_maker(trimmed_id.clone(), config) { Ok(()) => ( StatusCode::CREATED, - Json(ApiResponse::ok(MakerInfo { id: body.id })), + Json(ApiResponse::ok(MakerInfo { id: trimmed_id })), ), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/api/mod.rs b/src/api/mod.rs index 6ca3790..e5b2e99 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -54,6 +54,7 @@ pub type AppState = Arc>; bitcoind::start, bitcoind::stop, onboarding::run_startup_check, + monitoring::get_tor_status, health_check, ), components(schemas( @@ -75,6 +76,7 @@ pub type AppState = Arc>; dto::StartBitcoindRequest, dto::BitcoindStatusInfo, dto::CombinedLogLine, + dto::TorStatusInfo, )), tags( (name = "makers", description = "Maker management"), diff --git a/src/api/monitoring.rs b/src/api/monitoring.rs index 01dc013..eb3f935 100644 --- a/src/api/monitoring.rs +++ b/src/api/monitoring.rs @@ -22,7 +22,7 @@ use crate::utils::log_writer::read_last_n_lines; use super::{ dto::{ ApiResponse, CombinedLogLine, MakerStatus, RpcStatusInfo, SwapHistoryDto, SwapReportDto, - UtxoInfo, + TorStatusInfo, UtxoInfo, }, AppState, }; @@ -39,6 +39,7 @@ pub fn routes() -> Router { .route("/makers/{id}/data-dir", get(get_data_dir)) .route("/makers/{id}/rpc-status", get(get_rpc_status)) .route("/logs/combined", get(get_combined_logs)) + .route("/tor/status", get(get_tor_status)) } /// Get operational status of a maker @@ -531,6 +532,22 @@ struct LogsQuery { lines: Option, } +#[utoipa::path( + get, + path = "/api/tor/status", + tag = "monitoring", + responses( + (status = 200, description = "Tor connectivity status", body = ApiResponse), + ) +)] +pub async fn get_tor_status(State(state): State) -> Json> { + let source = state.lock().await.tor_source(); + Json(ApiResponse::ok(TorStatusInfo { + managed: source != "system", + source, + })) +} + /// Get the Tor address of a maker #[utoipa::path( get, diff --git a/src/lib.rs b/src/lib.rs index 6dca059..3ad8621 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,4 +2,5 @@ pub mod api; pub mod maker_manager; pub mod middlewares; pub mod server; +pub mod tor_manager; pub mod utils; diff --git a/src/main.rs b/src/main.rs index 953e264..cac1f0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod maker_manager; mod middlewares; mod server; +mod tor_manager; mod utils; use clap::Parser; diff --git a/src/maker_manager/mod.rs b/src/maker_manager/mod.rs index 905547b..e81be36 100644 --- a/src/maker_manager/mod.rs +++ b/src/maker_manager/mod.rs @@ -7,6 +7,7 @@ use std::net::TcpListener; use std::path::PathBuf; use std::sync::Arc; +use crate::tor_manager::TorManager; use crate::utils::log_writer::MakerLogWriter; use anyhow::{anyhow, Result}; use coinswap::bitcoin::Network; @@ -100,6 +101,8 @@ pub struct MakerManager { bitcoind_process: Option, /// Network bitcoind was started on (e.g. "regtest", "signet") bitcoind_network: Option, + #[allow(dead_code)] + tor_manager: TorManager, } impl MakerManager { @@ -109,6 +112,23 @@ impl MakerManager { /// Creates a new MakerManager with persistence at the given config directory. /// Loads any previously saved maker configs and re-initializes them (but does NOT start servers). pub fn new(config_dir: PathBuf) -> Result { + let tor_manager = TorManager::detect_or_start(&config_dir).unwrap_or_else(|e| { + tracing::warn!( + "Tor could not be started: {}. Tor-dependent makers will fail to start.", + e + ); + TorManager::noop() + }); + Self::new_with_tor(config_dir, tor_manager) + } + + /// Creates a MakerManager without starting or detecting Tor. Use in tests only. + #[allow(dead_code)] + pub fn new_for_testing(config_dir: PathBuf) -> Result { + Self::new_with_tor(config_dir, TorManager::noop()) + } + + fn new_with_tor(config_dir: PathBuf, tor_manager: TorManager) -> Result { let persistence = PersistenceManager::new(config_dir.clone())?; let saved_configs = persistence.load()?; @@ -118,6 +138,7 @@ impl MakerManager { persistence, bitcoind_process: None, bitcoind_network: None, + tor_manager, }; // Restore previously registered makers (init only, not started) @@ -635,6 +656,11 @@ impl MakerManager { Some(child) } + /// Returns how Tor was obtained: "system", "host", or "docker" + pub fn tor_source(&self) -> &'static str { + self.tor_manager.source_label() + } + /// Returns `(running, network)` for the dashboard-managed bitcoind process. pub fn bitcoind_status(&mut self) -> (bool, Option) { if let Some(ref mut child) = self.bitcoind_process { @@ -710,7 +736,7 @@ mod tests { } std::fs::create_dir_all(&config_dir).unwrap(); - let manager = MakerManager::new(config_dir).unwrap(); + let manager = MakerManager::new_for_testing(config_dir).unwrap(); let network_listener = TcpListener::bind("127.0.0.1:0").unwrap(); let rpc_listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/src/server.rs b/src/server.rs index 8320dea..f912cd7 100644 --- a/src/server.rs +++ b/src/server.rs @@ -106,8 +106,34 @@ impl Server { listener, app.into_make_service_with_connect_info::(), ) + .with_graceful_shutdown(shutdown_signal()) .await?; + tracing::info!("Server stopped"); Ok(()) } } + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let sigterm = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let sigterm = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => tracing::info!("Received Ctrl+C, shutting down..."), + _ = sigterm => tracing::info!("Received SIGTERM, shutting down..."), + } +} diff --git a/src/tor_manager.rs b/src/tor_manager.rs new file mode 100644 index 0000000..471dcdd --- /dev/null +++ b/src/tor_manager.rs @@ -0,0 +1,178 @@ +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +const SOCKS_PORT: u16 = 9050; +const CONTROL_PORT: u16 = 9051; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(2); +const STARTUP_TIMEOUT: Duration = Duration::from_secs(60); +const POLL_INTERVAL: Duration = Duration::from_millis(500); + +enum TorSource { + System, + HostProcess, + Embedded, +} + +pub struct TorManager { + source: TorSource, + process: Option, + _tor_thread: Option>>, +} + +impl TorManager { + pub fn noop() -> Self { + TorManager { + source: TorSource::System, + process: None, + _tor_thread: None, + } + } + + pub fn detect_or_start(config_dir: &Path) -> anyhow::Result { + tracing::info!( + "Checking if Tor is already running (SOCKS:{} control:{})", + SOCKS_PORT, + CONTROL_PORT + ); + if port_reachable(SOCKS_PORT) { + if port_reachable(CONTROL_PORT) { + tracing::info!( + "Tor already running on ports {}/{}, using system instance", + SOCKS_PORT, + CONTROL_PORT + ); + return Ok(TorManager { + source: TorSource::System, + process: None, + _tor_thread: None, + }); + } + tracing::warn!( + "SOCKS port {} reachable but control port {} is not; \ + falling through to start a managed instance", + SOCKS_PORT, + CONTROL_PORT + ); + } + + if let Some(binary) = find_binary("tor") { + tracing::info!("Found system tor at {}", binary.display()); + let child = spawn_host_process(&binary, &config_dir.join("tor"))?; + wait_for_port(SOCKS_PORT)?; + return Ok(TorManager { + source: TorSource::HostProcess, + process: Some(child), + _tor_thread: None, + }); + } + + tracing::info!("Starting embedded Tor"); + let data_dir = config_dir.join("tor").join("data"); + std::fs::create_dir_all(&data_dir)?; + + let handle = libtor::Tor::new() + .flag(libtor::TorFlag::DataDirectory( + data_dir.to_string_lossy().into_owned(), + )) + .flag(libtor::TorFlag::SocksPort(SOCKS_PORT)) + .flag(libtor::TorFlag::ControlPort(CONTROL_PORT)) + .flag(libtor::TorFlag::CookieAuthentication( + libtor::TorBool::False, + )) + .start_background(); + + wait_for_port(SOCKS_PORT)?; + tracing::info!("Embedded Tor ready"); + + Ok(TorManager { + source: TorSource::Embedded, + process: None, + _tor_thread: Some(handle), + }) + } + + pub fn source_label(&self) -> &'static str { + match self.source { + TorSource::System => "system", + TorSource::HostProcess => "host", + TorSource::Embedded => "embedded", + } + } +} + +impl Drop for TorManager { + fn drop(&mut self) { + if let TorSource::HostProcess = self.source { + if let Some(ref mut child) = self.process { + let _ = child.kill(); + let _ = child.wait(); + tracing::info!("Tor process stopped"); + } + } + } +} + +fn port_reachable(port: u16) -> bool { + let addr: std::net::SocketAddr = ([127, 0, 0, 1], port).into(); + TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT).is_ok() +} + +fn find_binary(name: &str) -> Option { + if let Ok(output) = std::process::Command::new("which").arg(name).output() { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path_str.is_empty() { + return Some(PathBuf::from(path_str)); + } + } + } + for prefix in ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"] { + let candidate = PathBuf::from(prefix).join(name); + if candidate.exists() { + return Some(candidate); + } + } + None +} + +fn spawn_host_process(binary: &Path, tor_dir: &Path) -> anyhow::Result { + let data_dir = tor_dir.join("data"); + std::fs::create_dir_all(&data_dir)?; + + let torrc_path = tor_dir.join("torrc"); + let torrc_content = format!( + "SocksPort 127.0.0.1:{SOCKS_PORT}\nControlPort 127.0.0.1:{CONTROL_PORT}\nCookieAuthentication 0\nDataDirectory {}\n", + data_dir.display() + ); + std::fs::write(&torrc_path, &torrc_content)?; + + tracing::info!("Spawning tor: {}", binary.display()); + + std::process::Command::new(binary) + .args([ + "-f", + torrc_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("torrc path is not valid UTF-8"))?, + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(Into::into) +} + +fn wait_for_port(port: u16) -> anyhow::Result<()> { + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + if port_reachable(port) { + return Ok(()); + } + std::thread::sleep(POLL_INTERVAL); + } + Err(anyhow::anyhow!( + "Tor did not become reachable on port {} within {:?}", + port, + STARTUP_TIMEOUT + )) +} diff --git a/tests/api/mod.rs b/tests/api/mod.rs index 8f845c0..089e0af 100644 --- a/tests/api/mod.rs +++ b/tests/api/mod.rs @@ -35,7 +35,7 @@ pub fn test_app() -> Router { std::fs::remove_dir_all(&config_dir).unwrap(); } std::fs::create_dir_all(&config_dir).unwrap(); - let manager = MakerManager::new(config_dir).expect("MakerManager::new"); + let manager = MakerManager::new_for_testing(config_dir).expect("MakerManager::new_for_testing"); let state = Arc::new(Mutex::new(manager)); api_router().with_state(state) } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index d2db9c7..8a172ac 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -201,9 +201,10 @@ impl ApiClient { fn new(port: u16, creds: RpcCreds) -> Self { Self { base: format!("http://127.0.0.1:{port}/api"), - agent: ureq::AgentBuilder::new() - .timeout(Duration::from_secs(120)) - .build(), + agent: ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(120))) + .build() + .new_agent(), creds, } } @@ -213,7 +214,8 @@ impl ApiClient { .get(&format!("{}{path}", self.base)) .call() .unwrap_or_else(|e| panic!("GET {path} failed: {e}")) - .into_json() + .into_body() + .read_json() .unwrap_or_else(|e| panic!("JSON decode GET {path}: {e}")) } @@ -222,7 +224,8 @@ impl ApiClient { .post(&format!("{}{path}", self.base)) .send_json(body) .unwrap_or_else(|e| panic!("POST {path} failed: {e}")) - .into_json() + .into_body() + .read_json() .unwrap_or_else(|e| panic!("JSON decode POST {path}: {e}")) } @@ -236,10 +239,8 @@ impl ApiClient { .send_json(body) { Ok(resp) => resp - .into_json() - .unwrap_or_else(|e| panic!("JSON decode POST {path}: {e}")), - Err(ureq::Error::Status(_, resp)) => resp - .into_json() + .into_body() + .read_json::() .unwrap_or(serde_json::json!({"success": false})), Err(e) => panic!("POST {path} transport error: {e}"), } @@ -250,7 +251,8 @@ impl ApiClient { .put(&format!("{}{path}", self.base)) .send_json(body) .unwrap_or_else(|e| panic!("PUT {path} failed: {e}")) - .into_json() + .into_body() + .read_json() .unwrap_or_else(|e| panic!("JSON decode PUT {path}: {e}")) }