From 84c78652115a6b42b365e2298574e86c3b02c935 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 11 Jan 2026 14:30:00 +0100 Subject: [PATCH 01/42] feat: init cargo workspace with rust nix flake --- Cargo.toml | 13 ++ crates/funnel-client/Cargo.toml | 19 +++ crates/funnel-client/src/main.rs | 3 + crates/funnel-core/Cargo.toml | 18 ++ crates/funnel-core/src/error.rs | 19 +++ crates/funnel-core/src/lib.rs | 3 + crates/funnel-core/src/protocol/message.rs | 186 +++++++++++++++++++++ crates/funnel-core/src/protocol/mod.rs | 3 + crates/funnel-core/src/tunnel/id.rs | 179 ++++++++++++++++++++ crates/funnel-core/src/tunnel/mod.rs | 3 + crates/funnel-server/Cargo.toml | 30 ++++ crates/funnel-server/src/main.rs | 3 + crates/funnel-web/Cargo.toml | 10 ++ crates/funnel-web/src/lib.rs | 1 + flake.nix | 133 +++++---------- nix/build-inputs.nix | 25 +++ nix/default.nix | 6 + nix/dev-tools.nix | 18 ++ nix/shells.nix | 56 +++++++ nix/toolchains.nix | 49 ++++++ 20 files changed, 688 insertions(+), 89 deletions(-) create mode 100644 Cargo.toml create mode 100644 crates/funnel-client/Cargo.toml create mode 100644 crates/funnel-client/src/main.rs create mode 100644 crates/funnel-core/Cargo.toml create mode 100644 crates/funnel-core/src/error.rs create mode 100644 crates/funnel-core/src/lib.rs create mode 100644 crates/funnel-core/src/protocol/message.rs create mode 100644 crates/funnel-core/src/protocol/mod.rs create mode 100644 crates/funnel-core/src/tunnel/id.rs create mode 100644 crates/funnel-core/src/tunnel/mod.rs create mode 100644 crates/funnel-server/Cargo.toml create mode 100644 crates/funnel-server/src/main.rs create mode 100644 crates/funnel-web/Cargo.toml create mode 100644 crates/funnel-web/src/lib.rs create mode 100644 nix/build-inputs.nix create mode 100644 nix/default.nix create mode 100644 nix/dev-tools.nix create mode 100644 nix/shells.nix create mode 100644 nix/toolchains.nix diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7a6cf66 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +resolver = "3" +members = [ + "crates/funnel-core", + "crates/funnel-server", + "crates/funnel-client", + "crates/funnel-web", +] + +[workspace.package] +edition = "2024" +license = "MIT" +repository = "https://github.com/karol-broda/funnel" diff --git a/crates/funnel-client/Cargo.toml b/crates/funnel-client/Cargo.toml new file mode 100644 index 0000000..5e9b894 --- /dev/null +++ b/crates/funnel-client/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "funnel-client" +version = "0.1.0" +edition = "2024" + +[dependencies] +funnel-core = { path = "../funnel-core" } + +anyhow = "1" +clap = { version = "4", features = ["derive"] } +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } +toml = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +uuid = { version = "1", features = ["v7"] } diff --git a/crates/funnel-client/src/main.rs b/crates/funnel-client/src/main.rs new file mode 100644 index 0000000..ae062fd --- /dev/null +++ b/crates/funnel-client/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("funnel-client"); +} diff --git a/crates/funnel-core/Cargo.toml b/crates/funnel-core/Cargo.toml new file mode 100644 index 0000000..c1603a6 --- /dev/null +++ b/crates/funnel-core/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "funnel-core" +version = "0.1.0" +edition = "2024" + +[dependencies] +base64 = "0.22" +bytes = "1" +http = "1" +nanoid = "0.4" +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +uuid = { version = "1", features = ["v7", "serde"] } + +[dev-dependencies] +pretty_assertions = "1" diff --git a/crates/funnel-core/src/error.rs b/crates/funnel-core/src/error.rs new file mode 100644 index 0000000..3876b98 --- /dev/null +++ b/crates/funnel-core/src/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ValidationError { + #[error("tunnel ID cannot be empty")] + Empty, + + #[error("tunnel ID must be at least 3 characters, got {0}")] + TooShort(usize), + + #[error("tunnel ID must be at most 63 characters, got {0}")] + TooLong(usize), + + #[error("tunnel ID must be lowercase")] + NotLowercase, + + #[error("tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen")] + InvalidFormat, +} diff --git a/crates/funnel-core/src/lib.rs b/crates/funnel-core/src/lib.rs new file mode 100644 index 0000000..7aed731 --- /dev/null +++ b/crates/funnel-core/src/lib.rs @@ -0,0 +1,3 @@ +pub mod error; +pub mod protocol; +pub mod tunnel; diff --git a/crates/funnel-core/src/protocol/message.rs b/crates/funnel-core/src/protocol/message.rs new file mode 100644 index 0000000..4d22e4e --- /dev/null +++ b/crates/funnel-core/src/protocol/message.rs @@ -0,0 +1,186 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::tunnel::TunnelId; + +/// HTTP request payload forwarded through the tunnel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestPayload { + pub method: String, + pub path: String, + pub headers: HashMap>, + #[serde(with = "base64_bytes")] + pub body: Vec, +} + +/// HTTP response payload returned through the tunnel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponsePayload { + pub status: u16, + pub headers: HashMap>, + #[serde(with = "base64_bytes")] + pub body: Vec, +} + +/// A message sent over the WebSocket tunnel connection. +/// +/// Uses a tagged enum (`"type"` field) for clean pattern matching +/// instead of stringly-typed message kinds. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TunnelMessage { + Request { + tunnel_id: TunnelId, + request_id: Uuid, + payload: RequestPayload, + }, + Response { + request_id: Uuid, + payload: ResponsePayload, + }, + Ping, + Pong, + RequestCancel { + request_id: Uuid, + }, +} + +/// Serde helper for encoding `Vec` as base64 strings in JSON. +mod base64_bytes { + use base64::prelude::*; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(bytes: &Vec, s: S) -> Result { + s.serialize_str(&BASE64_STANDARD.encode(bytes)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let s = String::deserialize(d)?; + BASE64_STANDARD.decode(&s).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize_request() { + let msg = TunnelMessage::Request { + tunnel_id: TunnelId::new("my-tunnel").unwrap(), + request_id: Uuid::nil(), + payload: RequestPayload { + method: "GET".into(), + path: "/hello".into(), + headers: HashMap::new(), + body: vec![], + }, + }; + + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["type"], "request"); + assert_eq!(json["tunnel_id"], "my-tunnel"); + assert_eq!(json["payload"]["method"], "GET"); + } + + #[test] + fn serialize_response() { + let msg = TunnelMessage::Response { + request_id: Uuid::nil(), + payload: ResponsePayload { + status: 200, + headers: HashMap::new(), + body: b"hello world".to_vec(), + }, + }; + + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["type"], "response"); + assert_eq!(json["payload"]["status"], 200); + } + + #[test] + fn serialize_ping_pong() { + let ping_json = serde_json::to_value(&TunnelMessage::Ping).unwrap(); + assert_eq!(ping_json["type"], "ping"); + + let pong_json = serde_json::to_value(&TunnelMessage::Pong).unwrap(); + assert_eq!(pong_json["type"], "pong"); + } + + #[test] + fn serialize_request_cancel() { + let id = Uuid::nil(); + let msg = TunnelMessage::RequestCancel { request_id: id }; + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["type"], "request_cancel"); + assert_eq!(json["request_id"], id.to_string()); + } + + #[test] + fn roundtrip_request() { + let msg = TunnelMessage::Request { + tunnel_id: TunnelId::new("test-abc").unwrap(), + request_id: Uuid::now_v7(), + payload: RequestPayload { + method: "POST".into(), + path: "/api/data".into(), + headers: { + let mut h = HashMap::new(); + h.insert("Content-Type".into(), vec!["application/json".into()]); + h + }, + body: b"{\"key\": \"value\"}".to_vec(), + }, + }; + + let json = serde_json::to_string(&msg).unwrap(); + let parsed: TunnelMessage = serde_json::from_str(&json).unwrap(); + + match parsed { + TunnelMessage::Request { + tunnel_id, + payload, + .. + } => { + assert_eq!(tunnel_id.as_ref(), "test-abc"); + assert_eq!(payload.method, "POST"); + assert_eq!(payload.body, b"{\"key\": \"value\"}"); + } + _ => panic!("expected Request variant"), + } + } + + #[test] + fn roundtrip_response_with_body() { + let body = vec![0u8, 1, 2, 255, 128]; + let msg = TunnelMessage::Response { + request_id: Uuid::now_v7(), + payload: ResponsePayload { + status: 201, + headers: HashMap::new(), + body: body.clone(), + }, + }; + + let json = serde_json::to_string(&msg).unwrap(); + let parsed: TunnelMessage = serde_json::from_str(&json).unwrap(); + + match parsed { + TunnelMessage::Response { payload, .. } => { + assert_eq!(payload.status, 201); + assert_eq!(payload.body, body); + } + _ => panic!("expected Response variant"), + } + } + + #[test] + fn deserialize_unknown_type_fails() { + let json = r#"{"type": "unknown_msg"}"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } +} diff --git a/crates/funnel-core/src/protocol/mod.rs b/crates/funnel-core/src/protocol/mod.rs new file mode 100644 index 0000000..8805705 --- /dev/null +++ b/crates/funnel-core/src/protocol/mod.rs @@ -0,0 +1,3 @@ +mod message; + +pub use message::{RequestPayload, ResponsePayload, TunnelMessage}; diff --git a/crates/funnel-core/src/tunnel/id.rs b/crates/funnel-core/src/tunnel/id.rs new file mode 100644 index 0000000..d21443a --- /dev/null +++ b/crates/funnel-core/src/tunnel/id.rs @@ -0,0 +1,179 @@ +use std::fmt; + +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::error::ValidationError; + +const DOMAIN_SAFE_ALPHABET: &[char] = &[ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', + 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', +]; + +const DEFAULT_ID_LENGTH: usize = 8; + +/// A validated tunnel identifier. +/// +/// Guaranteed to be 3-63 characters, lowercase alphanumeric with hyphens, +/// and not starting or ending with a hyphen. Safe for use as a DNS subdomain label. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct TunnelId(String); + +impl TunnelId { + /// Parse and validate a raw string as a tunnel ID. + pub fn new(raw: impl Into) -> Result { + let s = raw.into(); + Self::validate(&s)?; + Ok(Self(s)) + } + + /// Generate a random domain-safe tunnel ID. + pub fn generate() -> Self { + let id = nanoid::nanoid!(DEFAULT_ID_LENGTH, DOMAIN_SAFE_ALPHABET); + Self(id) + } + + fn validate(s: &str) -> Result<(), ValidationError> { + if s.is_empty() { + return Err(ValidationError::Empty); + } + if s.len() < 3 { + return Err(ValidationError::TooShort(s.len())); + } + if s.len() > 63 { + return Err(ValidationError::TooLong(s.len())); + } + + let lower = s.to_lowercase(); + if s != lower { + return Err(ValidationError::NotLowercase); + } + + // Must match: starts and ends with alnum, middle can have hyphens + let re = Regex::new(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$").expect("valid regex"); + if !re.is_match(s) { + return Err(ValidationError::InvalidFormat); + } + + Ok(()) + } +} + +impl AsRef for TunnelId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for TunnelId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From for String { + fn from(id: TunnelId) -> Self { + id.0 + } +} + +impl TryFrom for TunnelId { + type Error = ValidationError; + + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_ids() { + assert!(TunnelId::new("abc").is_ok()); + assert!(TunnelId::new("my-tunnel").is_ok()); + assert!(TunnelId::new("test-123").is_ok()); + assert!(TunnelId::new("a1b").is_ok()); + assert!(TunnelId::new("a".repeat(63)).is_ok()); + } + + #[test] + fn empty_id() { + assert!(matches!(TunnelId::new(""), Err(ValidationError::Empty))); + } + + #[test] + fn too_short() { + assert!(matches!(TunnelId::new("ab"), Err(ValidationError::TooShort(2)))); + assert!(matches!(TunnelId::new("a"), Err(ValidationError::TooShort(1)))); + } + + #[test] + fn too_long() { + let long = "a".repeat(64); + assert!(matches!(TunnelId::new(long), Err(ValidationError::TooLong(64)))); + } + + #[test] + fn uppercase_rejected() { + assert!(matches!( + TunnelId::new("MyTunnel"), + Err(ValidationError::NotLowercase) + )); + } + + #[test] + fn invalid_format() { + assert!(matches!( + TunnelId::new("-abc"), + Err(ValidationError::InvalidFormat) + )); + assert!(matches!( + TunnelId::new("abc-"), + Err(ValidationError::InvalidFormat) + )); + assert!(matches!( + TunnelId::new("ab_c"), + Err(ValidationError::InvalidFormat) + )); + assert!(matches!( + TunnelId::new("ab c"), + Err(ValidationError::InvalidFormat) + )); + } + + #[test] + fn generate_produces_valid_id() { + for _ in 0..100 { + let id = TunnelId::generate(); + assert_eq!(id.as_ref().len(), DEFAULT_ID_LENGTH); + assert!(TunnelId::new(id.as_ref()).is_ok()); + } + } + + #[test] + fn display_and_into_string() { + let id = TunnelId::new("my-tunnel").unwrap(); + assert_eq!(id.to_string(), "my-tunnel"); + let s: String = id.into(); + assert_eq!(s, "my-tunnel"); + } + + #[test] + fn serde_roundtrip() { + let id = TunnelId::new("test-123").unwrap(); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"test-123\""); + + let parsed: TunnelId = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.as_ref(), "test-123"); + } + + #[test] + fn serde_rejects_invalid() { + let result: Result = serde_json::from_str("\"AB\""); + assert!(result.is_err()); + } +} diff --git a/crates/funnel-core/src/tunnel/mod.rs b/crates/funnel-core/src/tunnel/mod.rs new file mode 100644 index 0000000..0c73029 --- /dev/null +++ b/crates/funnel-core/src/tunnel/mod.rs @@ -0,0 +1,3 @@ +mod id; + +pub use id::TunnelId; diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml new file mode 100644 index 0000000..8b075c8 --- /dev/null +++ b/crates/funnel-server/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "funnel-server" +version = "0.1.0" +edition = "2024" + +[dependencies] +funnel-core = { path = "../funnel-core" } + +anyhow = "1" +argon2 = "0.5" +axum = { version = "0.8", features = ["ws"] } +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive"] } +dashmap = "6" +jsonwebtoken = "9" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono"] } +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +uuid = { version = "1", features = ["v7", "serde"] } + +[dev-dependencies] +axum-test = "16" +pretty_assertions = "1" +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono"] } diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs new file mode 100644 index 0000000..d8cea63 --- /dev/null +++ b/crates/funnel-server/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("funnel-server"); +} diff --git a/crates/funnel-web/Cargo.toml b/crates/funnel-web/Cargo.toml new file mode 100644 index 0000000..0945db1 --- /dev/null +++ b/crates/funnel-web/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "funnel-web" +version = "0.1.0" +edition = "2024" + +[dependencies] +funnel-core = { path = "../funnel-core" } + +leptos = { version = "0.7", features = ["csr"] } +serde = { version = "1", features = ["derive"] } diff --git a/crates/funnel-web/src/lib.rs b/crates/funnel-web/src/lib.rs new file mode 100644 index 0000000..e8c5030 --- /dev/null +++ b/crates/funnel-web/src/lib.rs @@ -0,0 +1 @@ +// Leptos frontend - to be implemented in Phase 3 diff --git a/flake.nix b/flake.nix index 2482201..7d5fec3 100644 --- a/flake.nix +++ b/flake.nix @@ -1,96 +1,51 @@ { - description = "go 1.25.0 dev flake"; + description = "rust development flake"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; - systems.url = "github:nix-systems/default"; + nixpkgs = { + url = "github:NixOS/nixpkgs/nixos-25.05"; + }; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + systems = { + url = "github:nix-systems/default"; + }; }; - outputs = { self, nixpkgs, systems }: - let - supportedSystems = import systems; - forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system); - in - { - overlays.default = final: prev: - let - version = "1.25.0"; - - platformInfo = { - "x86_64-linux" = { suffix = "linux-amd64"; sri = "sha256-KFKvDLIKExObNEiZLmm4aOUO0Pih5ZQO4d6eGaEjthM="; }; - "aarch64-linux" = { suffix = "linux-arm64"; sri = "sha256-Bd511plKJ4NpmBXuVTvVqTJ9i3mZHeNuOLZoYngvVK4="; }; - "i686-linux" = { suffix = "linux-386"; sri = "sha256-jGAt2dmbyUU7OZXSDOS684LMUIVZAKDs5d6ZKd9KmTo="; }; - "armv6l-linux" = { suffix = "linux-armv6l"; sri = "sha256-paj4GY/PAOHkhbjs757gIHeL8ypAik6Iczcb/ORYzQk="; }; - - "x86_64-darwin" = { suffix = "darwin-amd64"; sri = "sha256-W9YOgjA3BiwjB8cegRGAmGURZxTW9rQQWXz1B139gO8="; }; - "aarch64-darwin" = { suffix = "darwin-arm64"; sri = "sha256-VEkyhEFW2Bcveij3fyrJwVojBGaYtiQ/YzsKCwDAdJw="; }; - }; - - hostSystem = prev.stdenv.hostPlatform.system; - - chosen = - if prev.lib.hasAttr hostSystem platformInfo then platformInfo.${hostSystem} - else - throw '' - unsupported system: ${hostSystem} - add a mapping for your platform using the upstream tarball + sri sha256 - ''; - in - { - go_1_25_bin = prev.stdenvNoCC.mkDerivation { - pname = "go"; - version = version; - - src = prev.fetchurl { - url = "https://go.dev/dl/go${version}.${chosen.suffix}.tar.gz"; - hash = chosen.sri; - }; - - dontBuild = true; - - installPhase = '' - runHook preInstall - mkdir -p "$out"/{bin,share} - tar -C "$TMPDIR" -xzf "$src" - cp -a "$TMPDIR/go" "$out/share/go" - ln -s "$out/share/go/bin/go" "$out/bin/go" - ln -s "$out/share/go/bin/gofmt" "$out/bin/gofmt" - runHook postInstall - ''; - - dontPatchELF = true; - dontStrip = true; - - meta = with prev.lib; { - description = "go compiler and tools v${version}"; - homepage = "https://go.dev/dl/"; - license = licenses.bsd3; - platforms = [ hostSystem ]; - }; - }; - }; - - packages = forAllSystems (system: - let pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; }; - in { - default = pkgs.go_1_25_bin; - go_1_25_bin = pkgs.go_1_25_bin; - } - ); - - devShells = forAllSystems (system: - let pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; }; - in { - default = pkgs.mkShell { - packages = [ pkgs.go_1_25_bin pkgs.git ]; - - GOTOOLCHAIN = "local"; - - shellHook = '' - echo "go toolchain: $(go version)" - ''; - }; - } - ); + outputs = { + self, + nixpkgs, + rust-overlay, + systems, + }: let + supportedSystems = import systems; + forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system); + + mkPkgs = system: + import nixpkgs { + inherit system; + overlays = [ + rust-overlay.overlays.default + ]; + }; + + mkModules = system: + import ./nix { + pkgs = mkPkgs system; + }; + in { + devShells = forAllSystems ( + system: let + modules = mkModules system; + in { + inherit (modules.shells) default nightly wasm; + } + ); + + overlays = { + default = rust-overlay.overlays.default; }; + }; } diff --git a/nix/build-inputs.nix b/nix/build-inputs.nix new file mode 100644 index 0000000..705b03d --- /dev/null +++ b/nix/build-inputs.nix @@ -0,0 +1,25 @@ +{pkgs}: { + buildInputs = with pkgs; + [ + openssl + zlib + ] + ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ + darwin.apple_sdk.frameworks.Security + darwin.apple_sdk.frameworks.SystemConfiguration + darwin.apple_sdk.frameworks.CoreFoundation + darwin.apple_sdk.frameworks.CoreServices + libiconv + ]; + + nativeBuildInputs = with pkgs; [ + pkg-config + ]; + + env = { + OPENSSL_NO_VENDOR = "1"; + OPENSSL_DIR = "${pkgs.openssl.dev}"; + OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib"; + OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include"; + }; +} diff --git a/nix/default.nix b/nix/default.nix new file mode 100644 index 0000000..cab37c3 --- /dev/null +++ b/nix/default.nix @@ -0,0 +1,6 @@ +{pkgs}: let + toolchains = import ./toolchains.nix {inherit pkgs;}; + buildInputs = import ./build-inputs.nix {inherit pkgs;}; + devTools = import ./dev-tools.nix {inherit pkgs;}; + shells = import ./shells.nix {inherit pkgs toolchains buildInputs devTools;}; +in {inherit toolchains buildInputs devTools shells;} diff --git a/nix/dev-tools.nix b/nix/dev-tools.nix new file mode 100644 index 0000000..bbbab70 --- /dev/null +++ b/nix/dev-tools.nix @@ -0,0 +1,18 @@ +{pkgs}: +with pkgs; [ + cargo-watch + cargo-edit + cargo-expand + cargo-outdated + cargo-audit + cargo-nextest + cargo-deny + cargo-machete + cargo-flamegraph + cargo-bloat + + bacon + just + hyperfine + tokei +] diff --git a/nix/shells.nix b/nix/shells.nix new file mode 100644 index 0000000..7da23b5 --- /dev/null +++ b/nix/shells.nix @@ -0,0 +1,56 @@ +{ + pkgs, + toolchains, + buildInputs, + devTools, +}: let + mkRustShell = { + toolchain, + includeDevTools ? true, + extraPackages ? [], + extraBuildInputs ? [], + extraEnv ? {}, + shellHookExtra ? "", + }: + pkgs.mkShell ({ + packages = + [toolchain] + ++ buildInputs.nativeBuildInputs + ++ pkgs.lib.optionals includeDevTools devTools + ++ extraPackages; + + buildInputs = buildInputs.buildInputs ++ extraBuildInputs; + + RUST_SRC_PATH = "${toolchain}/lib/rustlib/src/rust/library"; + + shellHook = '' + if [ -n "$PS1" ]; then + echo "rust dev shell" + echo " rustc: $(rustc --version)" + echo " cargo: $(cargo --version)" + fi + ${shellHookExtra} + ''; + } + // buildInputs.env // extraEnv); +in { + default = mkRustShell { + toolchain = toolchains.default; + }; + + nightly = mkRustShell { + toolchain = toolchains.nightly; + }; + + wasm = mkRustShell { + toolchain = toolchains.wasm; + extraPackages = with pkgs; [ + wasm-pack + wasm-bindgen-cli + wasmtime + binaryen + ]; + }; + + inherit mkRustShell; +} diff --git a/nix/toolchains.nix b/nix/toolchains.nix new file mode 100644 index 0000000..4c3cf5b --- /dev/null +++ b/nix/toolchains.nix @@ -0,0 +1,49 @@ +{pkgs}: { + default = pkgs.rust-bin.stable.latest.default.override { + extensions = [ + "rust-src" + "rust-analyzer" + "clippy" + "rustfmt" + ]; + }; + + nightly = pkgs.rust-bin.nightly.latest.default.override { + extensions = [ + "rust-src" + "rust-analyzer" + "clippy" + "rustfmt" + "miri" + "llvm-tools" + ]; + }; + + wasm = pkgs.rust-bin.stable.latest.default.override { + extensions = [ + "rust-src" + "rust-analyzer" + "clippy" + "rustfmt" + ]; + targets = [ + "wasm32-unknown-unknown" + "wasm32-wasip1" + ]; + }; + + custom = { + channel ? "stable", + version ? "latest", + extensions ? [], + targets ? [], + }: let + base = + if version == "latest" + then pkgs.rust-bin.${channel}.latest.default + else pkgs.rust-bin.${channel}.${version}.default; + in + base.override { + inherit extensions targets; + }; +} From 93761719348fbf895a6db8234d4e25e3f81434eb Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Mon, 12 Jan 2026 11:15:00 +0100 Subject: [PATCH 02/42] feat(core): add tunnel id, message protocol, and api key types --- crates/funnel-core/Cargo.toml | 3 + crates/funnel-core/src/auth/mod.rs | 3 + crates/funnel-core/src/auth/token.rs | 115 +++++++++++++++++++++++++++ crates/funnel-core/src/lib.rs | 1 + 4 files changed, 122 insertions(+) create mode 100644 crates/funnel-core/src/auth/mod.rs create mode 100644 crates/funnel-core/src/auth/token.rs diff --git a/crates/funnel-core/Cargo.toml b/crates/funnel-core/Cargo.toml index c1603a6..18a0fcc 100644 --- a/crates/funnel-core/Cargo.toml +++ b/crates/funnel-core/Cargo.toml @@ -6,11 +6,14 @@ edition = "2024" [dependencies] base64 = "0.22" bytes = "1" +getrandom = "0.3" +hex = "0.4" http = "1" nanoid = "0.4" regex = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" thiserror = "2" uuid = { version = "1", features = ["v7", "serde"] } diff --git a/crates/funnel-core/src/auth/mod.rs b/crates/funnel-core/src/auth/mod.rs new file mode 100644 index 0000000..8798cec --- /dev/null +++ b/crates/funnel-core/src/auth/mod.rs @@ -0,0 +1,3 @@ +mod token; + +pub use token::{ApiKeyPrefix, hash_token, generate_api_key}; diff --git a/crates/funnel-core/src/auth/token.rs b/crates/funnel-core/src/auth/token.rs new file mode 100644 index 0000000..2fa59a4 --- /dev/null +++ b/crates/funnel-core/src/auth/token.rs @@ -0,0 +1,115 @@ +use std::fmt; + +use base64::prelude::*; +use serde::{Deserialize, Serialize}; + +const API_KEY_PREFIX: &str = "sk_"; +const API_KEY_RANDOM_BYTES: usize = 32; +const DISPLAY_PREFIX_LEN: usize = 10; + +/// The visible prefix of an API key (e.g. `"sk_abc123..."`), safe for display and logging. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApiKeyPrefix(String); + +impl ApiKeyPrefix { + pub fn from_key(full_key: &str) -> Self { + let end = full_key.len().min(DISPLAY_PREFIX_LEN); + Self(full_key[..end].to_string()) + } +} + +impl AsRef for ApiKeyPrefix { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ApiKeyPrefix { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}...", self.0) + } +} + +/// Generate a cryptographically random API key with the `sk_` prefix. +/// +/// Returns the full plaintext key. This should be shown to the user exactly once +/// and then only stored as a hash. +pub fn generate_api_key() -> String { + let mut bytes = [0u8; API_KEY_RANDOM_BYTES]; + getrandom::fill(&mut bytes).expect("failed to generate random bytes"); + format!("{}{}", API_KEY_PREFIX, BASE64_URL_SAFE_NO_PAD.encode(bytes)) +} + +/// Hash a plaintext API key using SHA-256. +/// +/// This is used for storage and constant-time comparison. We use SHA-256 rather +/// than argon2 for API keys because they are high-entropy random tokens, not +/// user-chosen passwords, so brute-force resistance from a slow hash is unnecessary. +pub fn hash_token(token: &str) -> String { + use sha2::{Digest, Sha256}; + let hash = Sha256::digest(token.as_bytes()); + hex::encode(hash) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_key_has_prefix() { + let key = generate_api_key(); + assert!(key.starts_with("sk_")); + } + + #[test] + fn generate_key_has_sufficient_length() { + let key = generate_api_key(); + // sk_ (3) + base64 of 32 bytes (43) = 46 chars + assert!(key.len() >= 40); + } + + #[test] + fn generated_keys_are_unique() { + let a = generate_api_key(); + let b = generate_api_key(); + assert_ne!(a, b); + } + + #[test] + fn hash_is_deterministic() { + let key = "sk_test_token_123"; + assert_eq!(hash_token(key), hash_token(key)); + } + + #[test] + fn hash_differs_for_different_inputs() { + assert_ne!(hash_token("sk_aaa"), hash_token("sk_bbb")); + } + + #[test] + fn hash_is_hex_encoded_sha256() { + let hash = hash_token("test"); + assert_eq!(hash.len(), 64); // SHA-256 = 32 bytes = 64 hex chars + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn prefix_from_key() { + let key = generate_api_key(); + let prefix = ApiKeyPrefix::from_key(&key); + assert_eq!(prefix.as_ref().len(), DISPLAY_PREFIX_LEN); + assert!(key.starts_with(prefix.as_ref())); + } + + #[test] + fn prefix_display() { + let prefix = ApiKeyPrefix::from_key("sk_abcdefghij_rest"); + assert_eq!(format!("{}", prefix), "sk_abcdefg..."); + } + + #[test] + fn prefix_from_short_key() { + let prefix = ApiKeyPrefix::from_key("sk_ab"); + assert_eq!(prefix.as_ref(), "sk_ab"); + } +} diff --git a/crates/funnel-core/src/lib.rs b/crates/funnel-core/src/lib.rs index 7aed731..3ba0702 100644 --- a/crates/funnel-core/src/lib.rs +++ b/crates/funnel-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod error; pub mod protocol; pub mod tunnel; From f336be07125a04ab46eff9a16069321bc4cd1f17 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Mon, 12 Jan 2026 13:40:00 +0100 Subject: [PATCH 03/42] feat(db): add migrations for users, api keys, and tunnel sessions --- migrations/001_create_users.sql | 15 +++++++++++++++ migrations/002_create_api_keys.sql | 14 ++++++++++++++ migrations/003_create_tunnel_sessions.sql | 16 ++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 migrations/001_create_users.sql create mode 100644 migrations/002_create_api_keys.sql create mode 100644 migrations/003_create_tunnel_sessions.sql diff --git a/migrations/001_create_users.sql b/migrations/001_create_users.sql new file mode 100644 index 0000000..a8810cd --- /dev/null +++ b/migrations/001_create_users.sql @@ -0,0 +1,15 @@ +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + name TEXT, + avatar_url TEXT, + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE(provider, provider_id) +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_provider ON users(provider, provider_id); diff --git a/migrations/002_create_api_keys.sql b/migrations/002_create_api_keys.sql new file mode 100644 index 0000000..7346561 --- /dev/null +++ b/migrations/002_create_api_keys.sql @@ -0,0 +1,14 @@ +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + key_prefix TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + + UNIQUE(user_id, name) +); + +CREATE INDEX idx_api_keys_user ON api_keys(user_id); +CREATE INDEX idx_api_keys_hash ON api_keys(key_hash) WHERE revoked_at IS NULL; diff --git a/migrations/003_create_tunnel_sessions.sql b/migrations/003_create_tunnel_sessions.sql new file mode 100644 index 0000000..683d4f7 --- /dev/null +++ b/migrations/003_create_tunnel_sessions.sql @@ -0,0 +1,16 @@ +CREATE TABLE tunnel_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tunnel_id TEXT NOT NULL, + client_ip INET, + connected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + disconnected_at TIMESTAMPTZ, + bytes_in BIGINT NOT NULL DEFAULT 0, + bytes_out BIGINT NOT NULL DEFAULT 0, + requests BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_tunnel_sessions_user ON tunnel_sessions(user_id); +CREATE INDEX idx_tunnel_sessions_tunnel ON tunnel_sessions(tunnel_id); +CREATE INDEX idx_tunnel_sessions_active ON tunnel_sessions(disconnected_at) + WHERE disconnected_at IS NULL; From 4a43e58be0e49341eae9fc2aa61bcc1314ec0160 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 25 Jan 2026 16:20:00 +0100 Subject: [PATCH 04/42] feat(server): add axum skeleton with db layer, api routes, and clap cli --- crates/funnel-server/Cargo.toml | 2 +- crates/funnel-server/src/api/health.rs | 20 ++ crates/funnel-server/src/api/keys.rs | 60 ++++++ crates/funnel-server/src/api/mod.rs | 3 + crates/funnel-server/src/api/tunnels.rs | 38 ++++ crates/funnel-server/src/app.rs | 44 ++++ crates/funnel-server/src/auth/mod.rs | 1 + crates/funnel-server/src/db/api_keys.rs | 199 ++++++++++++++++++ crates/funnel-server/src/db/mod.rs | 3 + .../funnel-server/src/db/tunnel_sessions.rs | 155 ++++++++++++++ crates/funnel-server/src/db/users.rs | 189 +++++++++++++++++ crates/funnel-server/src/error.rs | 74 +++++++ crates/funnel-server/src/main.rs | 75 ++++++- crates/funnel-server/src/proxy/mod.rs | 1 + crates/funnel-server/src/tls/mod.rs | 1 + crates/funnel-server/src/tunnel/mod.rs | 1 + crates/funnel-server/src/ws/mod.rs | 1 + 17 files changed, 864 insertions(+), 3 deletions(-) create mode 100644 crates/funnel-server/src/api/health.rs create mode 100644 crates/funnel-server/src/api/keys.rs create mode 100644 crates/funnel-server/src/api/mod.rs create mode 100644 crates/funnel-server/src/api/tunnels.rs create mode 100644 crates/funnel-server/src/app.rs create mode 100644 crates/funnel-server/src/auth/mod.rs create mode 100644 crates/funnel-server/src/db/api_keys.rs create mode 100644 crates/funnel-server/src/db/mod.rs create mode 100644 crates/funnel-server/src/db/tunnel_sessions.rs create mode 100644 crates/funnel-server/src/db/users.rs create mode 100644 crates/funnel-server/src/error.rs create mode 100644 crates/funnel-server/src/proxy/mod.rs create mode 100644 crates/funnel-server/src/tls/mod.rs create mode 100644 crates/funnel-server/src/tunnel/mod.rs create mode 100644 crates/funnel-server/src/ws/mod.rs diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index 8b075c8..1f2f334 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -15,7 +15,7 @@ dashmap = "6" jsonwebtoken = "9" serde = { version = "1", features = ["derive"] } serde_json = "1" -sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "migrate"] } thiserror = "2" tokio = { version = "1", features = ["full"] } tower = "0.5" diff --git a/crates/funnel-server/src/api/health.rs b/crates/funnel-server/src/api/health.rs new file mode 100644 index 0000000..9d50e27 --- /dev/null +++ b/crates/funnel-server/src/api/health.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; +use serde::Serialize; + +use crate::app::AppState; + +#[derive(Serialize)] +pub struct HealthResponse { + pub status: &'static str, + pub uptime_secs: u64, +} + +pub async fn handler(State(state): State>) -> Json { + Json(HealthResponse { + status: "healthy", + uptime_secs: state.start_time.elapsed().as_secs(), + }) +} diff --git a/crates/funnel-server/src/api/keys.rs b/crates/funnel-server/src/api/keys.rs new file mode 100644 index 0000000..c612af4 --- /dev/null +++ b/crates/funnel-server/src/api/keys.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, State}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::app::AppState; +use crate::db::api_keys::{self, ApiKeyView}; +use crate::error::AppError; + +#[derive(Deserialize)] +pub struct CreateKeyRequest { + pub name: String, + // TODO: extract user_id from auth middleware instead + pub user_id: Uuid, +} + +#[derive(Serialize)] +pub struct CreateKeyResponse { + pub key: String, + pub info: ApiKeyView, +} + +/// List all active API keys for a user. +pub async fn list(State(state): State>) -> Result>, AppError> { + // TODO: get user_id from auth middleware + // For now, return empty + let _pool = &state.db; + Ok(Json(vec![])) +} + +/// Create a new API key. +pub async fn create( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let (plaintext, key) = api_keys::create(&state.db, req.user_id, &req.name).await?; + + Ok(Json(CreateKeyResponse { + key: plaintext, + info: key.into(), + })) +} + +/// Revoke an API key. +pub async fn revoke( + State(state): State>, + Path(id): Path, +) -> Result, AppError> { + // TODO: get user_id from auth middleware + let user_id = Uuid::nil(); // placeholder + + let revoked = api_keys::revoke(&state.db, id, user_id).await?; + if !revoked { + return Err(AppError::NotFound("api key not found".into())); + } + + Ok(Json(serde_json::json!({ "revoked": true }))) +} diff --git a/crates/funnel-server/src/api/mod.rs b/crates/funnel-server/src/api/mod.rs new file mode 100644 index 0000000..0b28d39 --- /dev/null +++ b/crates/funnel-server/src/api/mod.rs @@ -0,0 +1,3 @@ +pub mod health; +pub mod keys; +pub mod tunnels; diff --git a/crates/funnel-server/src/api/tunnels.rs b/crates/funnel-server/src/api/tunnels.rs new file mode 100644 index 0000000..c3465a8 --- /dev/null +++ b/crates/funnel-server/src/api/tunnels.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, State}; +use serde::Serialize; + +use crate::app::AppState; +use crate::error::AppError; + +#[derive(Serialize)] +pub struct TunnelInfo { + pub id: String, + pub status: &'static str, +} + +/// List all active tunnels. +pub async fn list(State(_state): State>) -> Json> { + // TODO: wire to TunnelManager once tunnel module is implemented + Json(vec![]) +} + +/// Get a specific tunnel by ID. +pub async fn get_tunnel( + State(_state): State>, + Path(id): Path, +) -> Result, AppError> { + // TODO: wire to TunnelManager + Err(AppError::TunnelNotFound(id)) +} + +/// Delete (force-close) a tunnel. +pub async fn delete( + State(_state): State>, + Path(id): Path, +) -> Result, AppError> { + // TODO: wire to TunnelManager + Err(AppError::TunnelNotFound(id)) +} diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs new file mode 100644 index 0000000..9a18b25 --- /dev/null +++ b/crates/funnel-server/src/app.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use axum::Router; +use axum::routing::get; +use sqlx::PgPool; +use tokio::time::Instant; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; + +use crate::api; + +/// Shared application state, available in all handlers via `State>`. +pub struct AppState { + pub db: PgPool, + pub start_time: Instant, +} + +impl AppState { + pub fn new(db: PgPool) -> Self { + Self { + db, + start_time: Instant::now(), + } + } +} + +/// Build the complete Axum router with all routes and middleware. +pub fn build_router(state: Arc) -> Router { + let api_routes = Router::new() + .route("/health", get(api::health::handler)) + .route("/tunnels", get(api::tunnels::list)) + .route( + "/tunnels/{id}", + get(api::tunnels::get_tunnel).delete(api::tunnels::delete), + ) + .route("/keys", get(api::keys::list).post(api::keys::create)) + .route("/keys/{id}", axum::routing::delete(api::keys::revoke)); + + Router::new() + .nest("/api", api_routes) + .with_state(state) + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::permissive()) +} diff --git a/crates/funnel-server/src/auth/mod.rs b/crates/funnel-server/src/auth/mod.rs new file mode 100644 index 0000000..4e0d17b --- /dev/null +++ b/crates/funnel-server/src/auth/mod.rs @@ -0,0 +1 @@ +// Auth middleware — to be implemented in Phase 2 diff --git a/crates/funnel-server/src/db/api_keys.rs b/crates/funnel-server/src/db/api_keys.rs new file mode 100644 index 0000000..c5ee425 --- /dev/null +++ b/crates/funnel-server/src/db/api_keys.rs @@ -0,0 +1,199 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +use funnel_core::auth; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct ApiKey { + pub id: Uuid, + pub user_id: Uuid, + pub name: String, + pub key_hash: String, + pub key_prefix: String, + pub created_at: DateTime, + pub revoked_at: Option>, +} + +/// A view of an API key safe for returning in API responses (no hash). +#[derive(Debug, Clone, Serialize)] +pub struct ApiKeyView { + pub id: Uuid, + pub name: String, + pub key_prefix: String, + pub created_at: DateTime, + pub revoked_at: Option>, +} + +impl From for ApiKeyView { + fn from(key: ApiKey) -> Self { + Self { + id: key.id, + name: key.name, + key_prefix: key.key_prefix, + created_at: key.created_at, + revoked_at: key.revoked_at, + } + } +} + +/// Create a new API key for a user. Returns the full plaintext key (show once) and the DB record. +pub async fn create( + pool: &PgPool, + user_id: Uuid, + name: &str, +) -> Result<(String, ApiKey), sqlx::Error> { + let plaintext = auth::generate_api_key(); + let hash = auth::hash_token(&plaintext); + let prefix = auth::ApiKeyPrefix::from_key(&plaintext); + + let key = sqlx::query_as::<_, ApiKey>( + r#" + INSERT INTO api_keys (user_id, name, key_hash, key_prefix) + VALUES ($1, $2, $3, $4) + RETURNING * + "#, + ) + .bind(user_id) + .bind(name) + .bind(&hash) + .bind(prefix.as_ref()) + .fetch_one(pool) + .await?; + + Ok((plaintext, key)) +} + +/// Find the user ID associated with a plaintext API key, if it's valid and not revoked. +pub async fn validate(pool: &PgPool, plaintext: &str) -> Result, sqlx::Error> { + let hash = auth::hash_token(plaintext); + + sqlx::query_as::<_, ApiKey>( + "SELECT * FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL", + ) + .bind(&hash) + .fetch_optional(pool) + .await +} + +/// List all active (non-revoked) API keys for a user. +pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, ApiKey>( + "SELECT * FROM api_keys WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC", + ) + .bind(user_id) + .fetch_all(pool) + .await +} + +/// Revoke an API key by setting its `revoked_at` timestamp. +pub async fn revoke(pool: &PgPool, key_id: Uuid, user_id: Uuid) -> Result { + let result = sqlx::query( + "UPDATE api_keys SET revoked_at = now() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL", + ) + .bind(key_id) + .bind(user_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::users; + + async fn setup_user(pool: &PgPool) -> users::User { + users::create( + pool, + users::NewUser { + email: format!("apikey-test-{}@example.com", Uuid::now_v7()), + name: Some("API Key Test".into()), + avatar_url: None, + provider: "github".into(), + provider_id: Uuid::now_v7().to_string(), + }, + ) + .await + .unwrap() + } + + #[tokio::test] + #[ignore = "requires database"] + async fn create_and_validate_key() { + let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + + let user = setup_user(&pool).await; + let (plaintext, key) = create(&pool, user.id, "test-key").await.unwrap(); + + assert!(plaintext.starts_with("sk_")); + assert_eq!(key.name, "test-key"); + assert_eq!(key.user_id, user.id); + assert!(key.revoked_at.is_none()); + + let validated = validate(&pool, &plaintext).await.unwrap(); + assert!(validated.is_some()); + assert_eq!(validated.unwrap().id, key.id); + } + + #[tokio::test] + #[ignore = "requires database"] + async fn validate_invalid_key() { + let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + + let result = validate(&pool, "sk_totally_bogus_key").await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + #[ignore = "requires database"] + async fn revoke_key_prevents_validation() { + let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + + let user = setup_user(&pool).await; + let (plaintext, key) = create(&pool, user.id, "revoke-test").await.unwrap(); + + let revoked = revoke(&pool, key.id, user.id).await.unwrap(); + assert!(revoked); + + let validated = validate(&pool, &plaintext).await.unwrap(); + assert!(validated.is_none()); + } + + #[tokio::test] + #[ignore = "requires database"] + async fn list_keys_for_user() { + let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + + let user = setup_user(&pool).await; + create(&pool, user.id, "key-1").await.unwrap(); + create(&pool, user.id, "key-2").await.unwrap(); + + let keys = list_for_user(&pool, user.id).await.unwrap(); + assert_eq!(keys.len(), 2); + } + + #[tokio::test] + #[ignore = "requires database"] + async fn duplicate_key_name_rejected() { + let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + + let user = setup_user(&pool).await; + create(&pool, user.id, "dupe-name").await.unwrap(); + + let result = create(&pool, user.id, "dupe-name").await; + assert!(result.is_err()); + } +} diff --git a/crates/funnel-server/src/db/mod.rs b/crates/funnel-server/src/db/mod.rs new file mode 100644 index 0000000..21ec42e --- /dev/null +++ b/crates/funnel-server/src/db/mod.rs @@ -0,0 +1,3 @@ +pub mod users; +pub mod api_keys; +pub mod tunnel_sessions; diff --git a/crates/funnel-server/src/db/tunnel_sessions.rs b/crates/funnel-server/src/db/tunnel_sessions.rs new file mode 100644 index 0000000..edbcc74 --- /dev/null +++ b/crates/funnel-server/src/db/tunnel_sessions.rs @@ -0,0 +1,155 @@ +use std::net::IpAddr; + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct TunnelSession { + pub id: Uuid, + pub user_id: Uuid, + pub tunnel_id: String, + pub client_ip: Option, + pub connected_at: DateTime, + pub disconnected_at: Option>, + pub bytes_in: i64, + pub bytes_out: i64, + pub requests: i64, +} + +/// Record a new tunnel connection. +pub async fn create( + pool: &PgPool, + user_id: Uuid, + tunnel_id: &str, + client_ip: Option, +) -> Result { + sqlx::query_as::<_, TunnelSession>( + r#" + INSERT INTO tunnel_sessions (user_id, tunnel_id, client_ip) + VALUES ($1, $2, $3) + RETURNING * + "#, + ) + .bind(user_id) + .bind(tunnel_id) + .bind(client_ip) + .fetch_one(pool) + .await +} + +/// Mark a tunnel session as disconnected and record final stats. +pub async fn disconnect( + pool: &PgPool, + session_id: Uuid, + bytes_in: i64, + bytes_out: i64, + requests: i64, +) -> Result { + let result = sqlx::query( + r#" + UPDATE tunnel_sessions + SET disconnected_at = now(), bytes_in = $2, bytes_out = $3, requests = $4 + WHERE id = $1 AND disconnected_at IS NULL + "#, + ) + .bind(session_id) + .bind(bytes_in) + .bind(bytes_out) + .bind(requests) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// List recent tunnel sessions for a user, most recent first. +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + limit: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, TunnelSession>( + r#" + SELECT * FROM tunnel_sessions + WHERE user_id = $1 + ORDER BY connected_at DESC + LIMIT $2 + "#, + ) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await +} + +/// List currently active (not disconnected) sessions. +pub async fn list_active(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, TunnelSession>( + "SELECT * FROM tunnel_sessions WHERE disconnected_at IS NULL ORDER BY connected_at DESC", + ) + .fetch_all(pool) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::users; + + async fn setup_user(pool: &PgPool) -> users::User { + users::create( + pool, + users::NewUser { + email: format!("session-test-{}@example.com", Uuid::now_v7()), + name: Some("Session Test".into()), + avatar_url: None, + provider: "github".into(), + provider_id: Uuid::now_v7().to_string(), + }, + ) + .await + .unwrap() + } + + #[tokio::test] + #[ignore = "requires database"] + async fn create_and_disconnect_session() { + let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + + let user = setup_user(&pool).await; + let session = create(&pool, user.id, "test-tunnel", None).await.unwrap(); + + assert_eq!(session.tunnel_id, "test-tunnel"); + assert!(session.disconnected_at.is_none()); + assert_eq!(session.bytes_in, 0); + + let disconnected = disconnect(&pool, session.id, 1024, 2048, 10) + .await + .unwrap(); + assert!(disconnected); + } + + #[tokio::test] + #[ignore = "requires database"] + async fn list_active_sessions() { + let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + + let user = setup_user(&pool).await; + let s1 = create(&pool, user.id, "active-1", None).await.unwrap(); + let s2 = create(&pool, user.id, "active-2", None).await.unwrap(); + + // Disconnect one + disconnect(&pool, s1.id, 0, 0, 0).await.unwrap(); + + let active = list_active(&pool).await.unwrap(); + let active_ids: Vec = active.iter().map(|s| s.id).collect(); + assert!(active_ids.contains(&s2.id)); + assert!(!active_ids.contains(&s1.id)); + } +} diff --git a/crates/funnel-server/src/db/users.rs b/crates/funnel-server/src/db/users.rs new file mode 100644 index 0000000..bec8d08 --- /dev/null +++ b/crates/funnel-server/src/db/users.rs @@ -0,0 +1,189 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct User { + pub id: Uuid, + pub email: String, + pub name: Option, + pub avatar_url: Option, + pub provider: String, + pub provider_id: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub struct NewUser { + pub email: String, + pub name: Option, + pub avatar_url: Option, + pub provider: String, + pub provider_id: String, +} + +/// Find a user by their unique ID. +pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await +} + +/// Find a user by email address. +pub async fn find_by_email(pool: &PgPool, email: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") + .bind(email) + .fetch_optional(pool) + .await +} + +/// Find a user by their OAuth provider and provider-specific ID. +pub async fn find_by_provider( + pool: &PgPool, + provider: &str, + provider_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>( + "SELECT * FROM users WHERE provider = $1 AND provider_id = $2", + ) + .bind(provider) + .bind(provider_id) + .fetch_optional(pool) + .await +} + +/// Create a new user, returning the created record. +pub async fn create(pool: &PgPool, new_user: NewUser) -> Result { + sqlx::query_as::<_, User>( + r#" + INSERT INTO users (email, name, avatar_url, provider, provider_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + "#, + ) + .bind(&new_user.email) + .bind(&new_user.name) + .bind(&new_user.avatar_url) + .bind(&new_user.provider) + .bind(&new_user.provider_id) + .fetch_one(pool) + .await +} + +/// Upsert a user based on OAuth provider identity. +/// +/// If a user with the same (provider, provider_id) exists, update their profile. +/// Otherwise, create a new user. Returns the user in either case. +pub async fn upsert_from_oauth(pool: &PgPool, new_user: NewUser) -> Result { + sqlx::query_as::<_, User>( + r#" + INSERT INTO users (email, name, avatar_url, provider, provider_id) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (provider, provider_id) + DO UPDATE SET + email = EXCLUDED.email, + name = EXCLUDED.name, + avatar_url = EXCLUDED.avatar_url, + updated_at = now() + RETURNING * + "#, + ) + .bind(&new_user.email) + .bind(&new_user.name) + .bind(&new_user.avatar_url) + .bind(&new_user.provider) + .bind(&new_user.provider_id) + .fetch_one(pool) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_new_user() -> NewUser { + NewUser { + email: format!("test-{}@example.com", Uuid::now_v7()), + name: Some("Test User".into()), + avatar_url: None, + provider: "github".into(), + provider_id: Uuid::now_v7().to_string(), + } + } + + // Integration tests require a running Postgres instance. + // Run with: DATABASE_URL=postgres://... cargo test -- --ignored + #[tokio::test] + #[ignore = "requires database"] + async fn create_and_find_user() { + let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + + let new = test_new_user(); + let email = new.email.clone(); + + let created = create(&pool, new).await.unwrap(); + assert_eq!(created.email, email); + assert!(created.id != Uuid::nil()); + + let found = find_by_id(&pool, created.id).await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().email, email); + } + + #[tokio::test] + #[ignore = "requires database"] + async fn find_nonexistent_user() { + let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + + let found = find_by_id(&pool, Uuid::now_v7()).await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + #[ignore = "requires database"] + async fn upsert_creates_then_updates() { + let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + + let provider_id = Uuid::now_v7().to_string(); + + let first = upsert_from_oauth( + &pool, + NewUser { + email: format!("upsert-{}@example.com", Uuid::now_v7()), + name: Some("First".into()), + avatar_url: None, + provider: "github".into(), + provider_id: provider_id.clone(), + }, + ) + .await + .unwrap(); + + let second = upsert_from_oauth( + &pool, + NewUser { + email: format!("upsert-updated-{}@example.com", Uuid::now_v7()), + name: Some("Updated".into()), + avatar_url: Some("https://example.com/avatar.png".into()), + provider: "github".into(), + provider_id: provider_id.clone(), + }, + ) + .await + .unwrap(); + + // Same user row + assert_eq!(first.id, second.id); + // But fields updated + assert_eq!(second.name.as_deref(), Some("Updated")); + assert!(second.avatar_url.is_some()); + } +} diff --git a/crates/funnel-server/src/error.rs b/crates/funnel-server/src/error.rs new file mode 100644 index 0000000..74c9906 --- /dev/null +++ b/crates/funnel-server/src/error.rs @@ -0,0 +1,74 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use funnel_core::error::ValidationError; +use serde::Serialize; + +#[derive(Debug, Serialize)] +struct ApiErrorBody { + error: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("tunnel not found: {0}")] + TunnelNotFound(String), + + #[error("tunnel ID already in use: {0}")] + TunnelConflict(String), + + #[error("unauthorized: {0}")] + Unauthorized(String), + + #[error("forbidden: {0}")] + Forbidden(String), + + #[error("not found: {0}")] + NotFound(String), + + #[error("conflict: {0}")] + Conflict(String), + + #[error("bad request: {0}")] + BadRequest(String), + + #[error("validation error: {0}")] + Validation(#[from] ValidationError), + + #[error("database error")] + Database(#[from] sqlx::Error), + + #[error("internal error: {0}")] + Internal(#[from] anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + Self::TunnelNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), + Self::TunnelConflict(_) => (StatusCode::CONFLICT, self.to_string()), + Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, self.to_string()), + Self::Forbidden(_) => (StatusCode::FORBIDDEN, self.to_string()), + Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), + Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()), + Self::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), + Self::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()), + Self::Database(e) => { + tracing::error!(error = %e, "database error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + Self::Internal(e) => { + tracing::error!(error = %e, "internal error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + }; + + (status, Json(ApiErrorBody { error: message })).into_response() + } +} diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index d8cea63..993d7fa 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -1,3 +1,74 @@ -fn main() { - println!("funnel-server"); +mod api; +mod app; +mod auth; +mod db; +mod error; +mod proxy; +mod tls; +mod tunnel; +mod ws; + +use std::sync::Arc; + +use clap::Parser; +use sqlx::postgres::PgPoolOptions; +use tracing_subscriber::EnvFilter; + +#[derive(Parser)] +#[command(name = "funnel-server", about = "Funnel tunnel server")] +struct Cli { + /// Port to listen on + #[arg(short, long, default_value_t = 8080)] + port: u16, + + /// Host to bind to + #[arg(long, default_value = "0.0.0.0")] + host: String, + + /// PostgreSQL connection URL + #[arg(long, env = "DATABASE_URL")] + database_url: String, + + /// Maximum database connections + #[arg(long, default_value_t = 10)] + db_max_connections: u32, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .json() + .init(); + + let cli = Cli::parse(); + + tracing::info!(host = %cli.host, port = cli.port, "starting funnel server"); + + let pool = PgPoolOptions::new() + .max_connections(cli.db_max_connections) + .connect(&cli.database_url) + .await?; + + tracing::info!("connected to database"); + + sqlx::migrate!("../../migrations") + .run(&pool) + .await?; + + tracing::info!("database migrations applied"); + + let state = Arc::new(app::AppState::new(pool)); + let router = app::build_router(state); + + let addr = format!("{}:{}", cli.host, cli.port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + tracing::info!(addr = %addr, "server listening"); + + axum::serve(listener, router).await?; + + Ok(()) } diff --git a/crates/funnel-server/src/proxy/mod.rs b/crates/funnel-server/src/proxy/mod.rs new file mode 100644 index 0000000..b48c7fd --- /dev/null +++ b/crates/funnel-server/src/proxy/mod.rs @@ -0,0 +1 @@ +// Proxy/routing — to be implemented in Phase 1, increment 2 diff --git a/crates/funnel-server/src/tls/mod.rs b/crates/funnel-server/src/tls/mod.rs new file mode 100644 index 0000000..9fb262b --- /dev/null +++ b/crates/funnel-server/src/tls/mod.rs @@ -0,0 +1 @@ +// TLS/ACME certificate management — to be implemented in Phase 4 diff --git a/crates/funnel-server/src/tunnel/mod.rs b/crates/funnel-server/src/tunnel/mod.rs new file mode 100644 index 0000000..bb00f67 --- /dev/null +++ b/crates/funnel-server/src/tunnel/mod.rs @@ -0,0 +1 @@ +// Tunnel management — to be implemented in Phase 1, increment 2 diff --git a/crates/funnel-server/src/ws/mod.rs b/crates/funnel-server/src/ws/mod.rs new file mode 100644 index 0000000..ef3549a --- /dev/null +++ b/crates/funnel-server/src/ws/mod.rs @@ -0,0 +1 @@ +// WebSocket handler — to be implemented in Phase 1, increment 2 From 77d842ca105b1fa7a7c7e80d907607d4a14a3516 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 25 Jan 2026 16:45:00 +0100 Subject: [PATCH 05/42] chore: update gitignore for rust --- .gitignore | 60 ++++++++++++------------------------------------------ 1 file changed, 13 insertions(+), 47 deletions(-) diff --git a/.gitignore b/.gitignore index c3c9c1a..f4b6ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,62 +1,28 @@ -# binaries -bin/ -dist/ -*.exe -*.exe~ -*.dll -*.so -*.dylib +# Rust +/target +**/*.rs.bk +Cargo.lock -# test binary, built with `go test -c` -*.test +# Environment +.env +.direnv +result -# output of the go coverage tool -*.out - -# dependency directories -vendor/ - -# IDE files +# IDE .vscode/ .idea/ *.swp *.swo *~ -# OS files +# OS .DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db Thumbs.db -# logs +# Logs *.log -# temporary files +# Temporary *.tmp *.temp - -# Built binaries -client_main -server_main -/client_main -/server_main -tunneling-client -tunneling-server - -# Coverage reports -coverage.out -coverage.html - -# Profiling data -*.prof -*.pprof - -# Debug files -debug -*.debug - -/tmp \ No newline at end of file +/tmp From 4fc45b2b695bb086a42c063fd0c743c6796b6d9b Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Mon, 26 Jan 2026 10:00:00 +0100 Subject: [PATCH 06/42] style: clean up comments and use named constants --- crates/funnel-core/src/auth/token.rs | 34 +++++------- crates/funnel-core/src/protocol/message.rs | 7 --- crates/funnel-core/src/tunnel/id.rs | 53 +++++++------------ crates/funnel-server/src/app.rs | 2 - crates/funnel-server/src/auth/mod.rs | 1 - crates/funnel-server/src/db/api_keys.rs | 5 -- .../funnel-server/src/db/tunnel_sessions.rs | 5 -- crates/funnel-server/src/db/users.rs | 15 ++---- crates/funnel-server/src/proxy/mod.rs | 1 - crates/funnel-server/src/tls/mod.rs | 1 - crates/funnel-server/src/tunnel/mod.rs | 1 - crates/funnel-server/src/ws/mod.rs | 1 - crates/funnel-web/src/lib.rs | 2 +- 13 files changed, 37 insertions(+), 91 deletions(-) diff --git a/crates/funnel-core/src/auth/token.rs b/crates/funnel-core/src/auth/token.rs index 2fa59a4..04e5e7f 100644 --- a/crates/funnel-core/src/auth/token.rs +++ b/crates/funnel-core/src/auth/token.rs @@ -3,17 +3,17 @@ use std::fmt; use base64::prelude::*; use serde::{Deserialize, Serialize}; -const API_KEY_PREFIX: &str = "sk_"; -const API_KEY_RANDOM_BYTES: usize = 32; -const DISPLAY_PREFIX_LEN: usize = 10; +const PREFIX: &str = "sk_"; +const RANDOM_BYTES: usize = 32; +const VISIBLE_PREFIX_LEN: usize = 10; -/// The visible prefix of an API key (e.g. `"sk_abc123..."`), safe for display and logging. +/// the visible prefix of an api key, safe for display and logging. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ApiKeyPrefix(String); impl ApiKeyPrefix { pub fn from_key(full_key: &str) -> Self { - let end = full_key.len().min(DISPLAY_PREFIX_LEN); + let end = full_key.len().min(VISIBLE_PREFIX_LEN); Self(full_key[..end].to_string()) } } @@ -30,21 +30,14 @@ impl fmt::Display for ApiKeyPrefix { } } -/// Generate a cryptographically random API key with the `sk_` prefix. -/// -/// Returns the full plaintext key. This should be shown to the user exactly once -/// and then only stored as a hash. pub fn generate_api_key() -> String { - let mut bytes = [0u8; API_KEY_RANDOM_BYTES]; + let mut bytes = [0u8; RANDOM_BYTES]; getrandom::fill(&mut bytes).expect("failed to generate random bytes"); - format!("{}{}", API_KEY_PREFIX, BASE64_URL_SAFE_NO_PAD.encode(bytes)) + format!("{}{}", PREFIX, BASE64_URL_SAFE_NO_PAD.encode(bytes)) } -/// Hash a plaintext API key using SHA-256. -/// -/// This is used for storage and constant-time comparison. We use SHA-256 rather -/// than argon2 for API keys because they are high-entropy random tokens, not -/// user-chosen passwords, so brute-force resistance from a slow hash is unnecessary. +/// sha256 is fine here since api keys are high entropy random tokens, +/// not user chosen passwords, so slow hashing is unnecessary. pub fn hash_token(token: &str) -> String { use sha2::{Digest, Sha256}; let hash = Sha256::digest(token.as_bytes()); @@ -58,13 +51,13 @@ mod tests { #[test] fn generate_key_has_prefix() { let key = generate_api_key(); - assert!(key.starts_with("sk_")); + assert!(key.starts_with(PREFIX)); } #[test] fn generate_key_has_sufficient_length() { let key = generate_api_key(); - // sk_ (3) + base64 of 32 bytes (43) = 46 chars + // prefix (3) + base64 of 32 bytes (43) = 46 chars assert!(key.len() >= 40); } @@ -89,7 +82,8 @@ mod tests { #[test] fn hash_is_hex_encoded_sha256() { let hash = hash_token("test"); - assert_eq!(hash.len(), 64); // SHA-256 = 32 bytes = 64 hex chars + // 32 bytes = 64 hex chars + assert_eq!(hash.len(), 64); assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); } @@ -97,7 +91,7 @@ mod tests { fn prefix_from_key() { let key = generate_api_key(); let prefix = ApiKeyPrefix::from_key(&key); - assert_eq!(prefix.as_ref().len(), DISPLAY_PREFIX_LEN); + assert_eq!(prefix.as_ref().len(), VISIBLE_PREFIX_LEN); assert!(key.starts_with(prefix.as_ref())); } diff --git a/crates/funnel-core/src/protocol/message.rs b/crates/funnel-core/src/protocol/message.rs index 4d22e4e..a022cb7 100644 --- a/crates/funnel-core/src/protocol/message.rs +++ b/crates/funnel-core/src/protocol/message.rs @@ -5,7 +5,6 @@ use uuid::Uuid; use crate::tunnel::TunnelId; -/// HTTP request payload forwarded through the tunnel. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RequestPayload { pub method: String, @@ -15,7 +14,6 @@ pub struct RequestPayload { pub body: Vec, } -/// HTTP response payload returned through the tunnel. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResponsePayload { pub status: u16, @@ -24,10 +22,6 @@ pub struct ResponsePayload { pub body: Vec, } -/// A message sent over the WebSocket tunnel connection. -/// -/// Uses a tagged enum (`"type"` field) for clean pattern matching -/// instead of stringly-typed message kinds. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum TunnelMessage { @@ -47,7 +41,6 @@ pub enum TunnelMessage { }, } -/// Serde helper for encoding `Vec` as base64 strings in JSON. mod base64_bytes { use base64::prelude::*; use serde::{Deserialize, Deserializer, Serializer}; diff --git a/crates/funnel-core/src/tunnel/id.rs b/crates/funnel-core/src/tunnel/id.rs index d21443a..821b9be 100644 --- a/crates/funnel-core/src/tunnel/id.rs +++ b/crates/funnel-core/src/tunnel/id.rs @@ -10,27 +10,24 @@ const DOMAIN_SAFE_ALPHABET: &[char] = &[ 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', ]; -const DEFAULT_ID_LENGTH: usize = 8; +const DEFAULT_LENGTH: usize = 8; +const MIN_LENGTH: usize = 3; +const MAX_LENGTH: usize = 63; -/// A validated tunnel identifier. -/// -/// Guaranteed to be 3-63 characters, lowercase alphanumeric with hyphens, -/// and not starting or ending with a hyphen. Safe for use as a DNS subdomain label. +/// a validated tunnel identifier, guaranteed to be a valid dns subdomain label. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] pub struct TunnelId(String); impl TunnelId { - /// Parse and validate a raw string as a tunnel ID. pub fn new(raw: impl Into) -> Result { let s = raw.into(); Self::validate(&s)?; Ok(Self(s)) } - /// Generate a random domain-safe tunnel ID. pub fn generate() -> Self { - let id = nanoid::nanoid!(DEFAULT_ID_LENGTH, DOMAIN_SAFE_ALPHABET); + let id = nanoid::nanoid!(DEFAULT_LENGTH, DOMAIN_SAFE_ALPHABET); Self(id) } @@ -38,19 +35,16 @@ impl TunnelId { if s.is_empty() { return Err(ValidationError::Empty); } - if s.len() < 3 { + if s.len() < MIN_LENGTH { return Err(ValidationError::TooShort(s.len())); } - if s.len() > 63 { + if s.len() > MAX_LENGTH { return Err(ValidationError::TooLong(s.len())); } - - let lower = s.to_lowercase(); - if s != lower { + if s != s.to_lowercase() { return Err(ValidationError::NotLowercase); } - // Must match: starts and ends with alnum, middle can have hyphens let re = Regex::new(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$").expect("valid regex"); if !re.is_match(s) { return Err(ValidationError::InvalidFormat); @@ -96,7 +90,7 @@ mod tests { assert!(TunnelId::new("my-tunnel").is_ok()); assert!(TunnelId::new("test-123").is_ok()); assert!(TunnelId::new("a1b").is_ok()); - assert!(TunnelId::new("a".repeat(63)).is_ok()); + assert!(TunnelId::new("a".repeat(MAX_LENGTH)).is_ok()); } #[test] @@ -112,8 +106,11 @@ mod tests { #[test] fn too_long() { - let long = "a".repeat(64); - assert!(matches!(TunnelId::new(long), Err(ValidationError::TooLong(64)))); + let long = "a".repeat(MAX_LENGTH + 1); + assert!(matches!( + TunnelId::new(long), + Err(ValidationError::TooLong(64)) + )); } #[test] @@ -126,29 +123,17 @@ mod tests { #[test] fn invalid_format() { - assert!(matches!( - TunnelId::new("-abc"), - Err(ValidationError::InvalidFormat) - )); - assert!(matches!( - TunnelId::new("abc-"), - Err(ValidationError::InvalidFormat) - )); - assert!(matches!( - TunnelId::new("ab_c"), - Err(ValidationError::InvalidFormat) - )); - assert!(matches!( - TunnelId::new("ab c"), - Err(ValidationError::InvalidFormat) - )); + assert!(matches!(TunnelId::new("-abc"), Err(ValidationError::InvalidFormat))); + assert!(matches!(TunnelId::new("abc-"), Err(ValidationError::InvalidFormat))); + assert!(matches!(TunnelId::new("ab_c"), Err(ValidationError::InvalidFormat))); + assert!(matches!(TunnelId::new("ab c"), Err(ValidationError::InvalidFormat))); } #[test] fn generate_produces_valid_id() { for _ in 0..100 { let id = TunnelId::generate(); - assert_eq!(id.as_ref().len(), DEFAULT_ID_LENGTH); + assert_eq!(id.as_ref().len(), DEFAULT_LENGTH); assert!(TunnelId::new(id.as_ref()).is_ok()); } } diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs index 9a18b25..29db85f 100644 --- a/crates/funnel-server/src/app.rs +++ b/crates/funnel-server/src/app.rs @@ -9,7 +9,6 @@ use tower_http::trace::TraceLayer; use crate::api; -/// Shared application state, available in all handlers via `State>`. pub struct AppState { pub db: PgPool, pub start_time: Instant, @@ -24,7 +23,6 @@ impl AppState { } } -/// Build the complete Axum router with all routes and middleware. pub fn build_router(state: Arc) -> Router { let api_routes = Router::new() .route("/health", get(api::health::handler)) diff --git a/crates/funnel-server/src/auth/mod.rs b/crates/funnel-server/src/auth/mod.rs index 4e0d17b..e69de29 100644 --- a/crates/funnel-server/src/auth/mod.rs +++ b/crates/funnel-server/src/auth/mod.rs @@ -1 +0,0 @@ -// Auth middleware — to be implemented in Phase 2 diff --git a/crates/funnel-server/src/db/api_keys.rs b/crates/funnel-server/src/db/api_keys.rs index c5ee425..320e0c1 100644 --- a/crates/funnel-server/src/db/api_keys.rs +++ b/crates/funnel-server/src/db/api_keys.rs @@ -16,7 +16,6 @@ pub struct ApiKey { pub revoked_at: Option>, } -/// A view of an API key safe for returning in API responses (no hash). #[derive(Debug, Clone, Serialize)] pub struct ApiKeyView { pub id: Uuid, @@ -38,7 +37,6 @@ impl From for ApiKeyView { } } -/// Create a new API key for a user. Returns the full plaintext key (show once) and the DB record. pub async fn create( pool: &PgPool, user_id: Uuid, @@ -65,7 +63,6 @@ pub async fn create( Ok((plaintext, key)) } -/// Find the user ID associated with a plaintext API key, if it's valid and not revoked. pub async fn validate(pool: &PgPool, plaintext: &str) -> Result, sqlx::Error> { let hash = auth::hash_token(plaintext); @@ -77,7 +74,6 @@ pub async fn validate(pool: &PgPool, plaintext: &str) -> Result, .await } -/// List all active (non-revoked) API keys for a user. pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, ApiKey>( "SELECT * FROM api_keys WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC", @@ -87,7 +83,6 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result, .await } -/// Revoke an API key by setting its `revoked_at` timestamp. pub async fn revoke(pool: &PgPool, key_id: Uuid, user_id: Uuid) -> Result { let result = sqlx::query( "UPDATE api_keys SET revoked_at = now() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL", diff --git a/crates/funnel-server/src/db/tunnel_sessions.rs b/crates/funnel-server/src/db/tunnel_sessions.rs index edbcc74..1be0e17 100644 --- a/crates/funnel-server/src/db/tunnel_sessions.rs +++ b/crates/funnel-server/src/db/tunnel_sessions.rs @@ -18,7 +18,6 @@ pub struct TunnelSession { pub requests: i64, } -/// Record a new tunnel connection. pub async fn create( pool: &PgPool, user_id: Uuid, @@ -39,7 +38,6 @@ pub async fn create( .await } -/// Mark a tunnel session as disconnected and record final stats. pub async fn disconnect( pool: &PgPool, session_id: Uuid, @@ -64,7 +62,6 @@ pub async fn disconnect( Ok(result.rows_affected() > 0) } -/// List recent tunnel sessions for a user, most recent first. pub async fn list_for_user( pool: &PgPool, user_id: Uuid, @@ -84,7 +81,6 @@ pub async fn list_for_user( .await } -/// List currently active (not disconnected) sessions. pub async fn list_active(pool: &PgPool) -> Result, sqlx::Error> { sqlx::query_as::<_, TunnelSession>( "SELECT * FROM tunnel_sessions WHERE disconnected_at IS NULL ORDER BY connected_at DESC", @@ -144,7 +140,6 @@ mod tests { let s1 = create(&pool, user.id, "active-1", None).await.unwrap(); let s2 = create(&pool, user.id, "active-2", None).await.unwrap(); - // Disconnect one disconnect(&pool, s1.id, 0, 0, 0).await.unwrap(); let active = list_active(&pool).await.unwrap(); diff --git a/crates/funnel-server/src/db/users.rs b/crates/funnel-server/src/db/users.rs index bec8d08..9466d4f 100644 --- a/crates/funnel-server/src/db/users.rs +++ b/crates/funnel-server/src/db/users.rs @@ -23,7 +23,6 @@ pub struct NewUser { pub provider_id: String, } -/// Find a user by their unique ID. pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") .bind(id) @@ -31,7 +30,6 @@ pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::E .await } -/// Find a user by email address. pub async fn find_by_email(pool: &PgPool, email: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") .bind(email) @@ -39,7 +37,6 @@ pub async fn find_by_email(pool: &PgPool, email: &str) -> Result, s .await } -/// Find a user by their OAuth provider and provider-specific ID. pub async fn find_by_provider( pool: &PgPool, provider: &str, @@ -54,7 +51,6 @@ pub async fn find_by_provider( .await } -/// Create a new user, returning the created record. pub async fn create(pool: &PgPool, new_user: NewUser) -> Result { sqlx::query_as::<_, User>( r#" @@ -72,10 +68,8 @@ pub async fn create(pool: &PgPool, new_user: NewUser) -> Result Result { sqlx::query_as::<_, User>( r#" @@ -113,8 +107,7 @@ mod tests { } } - // Integration tests require a running Postgres instance. - // Run with: DATABASE_URL=postgres://... cargo test -- --ignored + // run with: DATABASE_URL=postgres://... cargo test -- --ignored #[tokio::test] #[ignore = "requires database"] async fn create_and_find_user() { @@ -180,9 +173,7 @@ mod tests { .await .unwrap(); - // Same user row assert_eq!(first.id, second.id); - // But fields updated assert_eq!(second.name.as_deref(), Some("Updated")); assert!(second.avatar_url.is_some()); } diff --git a/crates/funnel-server/src/proxy/mod.rs b/crates/funnel-server/src/proxy/mod.rs index b48c7fd..e69de29 100644 --- a/crates/funnel-server/src/proxy/mod.rs +++ b/crates/funnel-server/src/proxy/mod.rs @@ -1 +0,0 @@ -// Proxy/routing — to be implemented in Phase 1, increment 2 diff --git a/crates/funnel-server/src/tls/mod.rs b/crates/funnel-server/src/tls/mod.rs index 9fb262b..e69de29 100644 --- a/crates/funnel-server/src/tls/mod.rs +++ b/crates/funnel-server/src/tls/mod.rs @@ -1 +0,0 @@ -// TLS/ACME certificate management — to be implemented in Phase 4 diff --git a/crates/funnel-server/src/tunnel/mod.rs b/crates/funnel-server/src/tunnel/mod.rs index bb00f67..e69de29 100644 --- a/crates/funnel-server/src/tunnel/mod.rs +++ b/crates/funnel-server/src/tunnel/mod.rs @@ -1 +0,0 @@ -// Tunnel management — to be implemented in Phase 1, increment 2 diff --git a/crates/funnel-server/src/ws/mod.rs b/crates/funnel-server/src/ws/mod.rs index ef3549a..e69de29 100644 --- a/crates/funnel-server/src/ws/mod.rs +++ b/crates/funnel-server/src/ws/mod.rs @@ -1 +0,0 @@ -// WebSocket handler — to be implemented in Phase 1, increment 2 diff --git a/crates/funnel-web/src/lib.rs b/crates/funnel-web/src/lib.rs index e8c5030..433d7dd 100644 --- a/crates/funnel-web/src/lib.rs +++ b/crates/funnel-web/src/lib.rs @@ -1 +1 @@ -// Leptos frontend - to be implemented in Phase 3 +// leptos frontend From a87fe75dd4068e009a425122553d74aeeafbbfb3 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 15 Feb 2026 15:30:00 +0100 Subject: [PATCH 07/42] feat(tunnel): add tunnel manager, active tunnel, and stats --- crates/funnel-server/Cargo.toml | 2 + crates/funnel-server/src/tunnel/connection.rs | 236 ++++++++++++++++++ crates/funnel-server/src/tunnel/manager.rs | 102 ++++++++ crates/funnel-server/src/tunnel/mod.rs | 3 + crates/funnel-server/src/tunnel/stats.rs | 75 ++++++ 5 files changed, 418 insertions(+) create mode 100644 crates/funnel-server/src/tunnel/connection.rs create mode 100644 crates/funnel-server/src/tunnel/manager.rs create mode 100644 crates/funnel-server/src/tunnel/stats.rs diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index 1f2f334..4ff9699 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -12,12 +12,14 @@ axum = { version = "0.8", features = ["ws"] } chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } dashmap = "6" +futures = "0.3" jsonwebtoken = "9" serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "migrate"] } thiserror = "2" tokio = { version = "1", features = ["full"] } +tokio-util = "0.7" tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace"] } tracing = "0.1" diff --git a/crates/funnel-server/src/tunnel/connection.rs b/crates/funnel-server/src/tunnel/connection.rs new file mode 100644 index 0000000..0a05a63 --- /dev/null +++ b/crates/funnel-server/src/tunnel/connection.rs @@ -0,0 +1,236 @@ +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::ws::{Message, WebSocket}; +use dashmap::DashMap; +use futures::{SinkExt, StreamExt}; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use funnel_core::protocol::{ResponsePayload, TunnelMessage}; +use funnel_core::tunnel::TunnelId; + +use super::stats::TunnelStats; + +const CHANNEL_BUFFER: usize = 128; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +pub struct ActiveTunnel { + id: TunnelId, + outgoing_tx: mpsc::Sender, + pending_requests: DashMap>, + stats: TunnelStats, + connected_at: tokio::time::Instant, + cancel: CancellationToken, +} + +impl ActiveTunnel { + pub fn id(&self) -> &TunnelId { + &self.id + } + + pub fn stats(&self) -> super::stats::TunnelStatsSnapshot { + self.stats.snapshot() + } + + pub fn connected_at(&self) -> tokio::time::Instant { + self.connected_at + } + + pub fn is_alive(&self) -> bool { + !self.cancel.is_cancelled() + } + + /// wait until the tunnel connection has been closed. + pub async fn cancelled(&self) { + self.cancel.cancelled().await; + } + + /// send a request through the tunnel and wait for the client's response. + pub async fn send_request( + &self, + payload: funnel_core::protocol::RequestPayload, + ) -> Result { + let request_id = Uuid::now_v7(); + let (tx, rx) = oneshot::channel(); + + self.pending_requests.insert(request_id, tx); + self.stats.inc_requests(); + + let msg = TunnelMessage::Request { + tunnel_id: self.id.clone(), + request_id, + payload, + }; + + if self.outgoing_tx.send(msg).await.is_err() { + self.pending_requests.remove(&request_id); + return Err(SendError::TunnelClosed); + } + + let result = tokio::time::timeout(REQUEST_TIMEOUT, rx).await; + + // clean up regardless of outcome + self.pending_requests.remove(&request_id); + + match result { + Ok(Ok(response)) => Ok(response), + Ok(Err(_)) => Err(SendError::TunnelClosed), + Err(_) => { + // send cancel so the client can stop working on this request + let _ = self.outgoing_tx.send(TunnelMessage::RequestCancel { request_id }).await; + Err(SendError::Timeout) + } + } + } + + /// shut down the tunnel connection. + pub fn close(&self) { + self.cancel.cancel(); + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SendError { + #[error("tunnel connection closed")] + TunnelClosed, + #[error("request timed out")] + Timeout, +} + +/// spawn the read/write tasks for a websocket connection and return the active tunnel. +/// the returned tunnel is live immediately; the background tasks run until the +/// connection drops or `close()` is called. +pub fn spawn(id: TunnelId, ws: WebSocket) -> Arc { + let (ws_sink, ws_stream) = ws.split(); + let (outgoing_tx, outgoing_rx) = mpsc::channel(CHANNEL_BUFFER); + let cancel = CancellationToken::new(); + + let tunnel = Arc::new(ActiveTunnel { + id: id.clone(), + outgoing_tx, + pending_requests: DashMap::new(), + stats: TunnelStats::new(), + connected_at: tokio::time::Instant::now(), + cancel: cancel.clone(), + }); + + let read_tunnel = Arc::clone(&tunnel); + let read_cancel = cancel.clone(); + tokio::spawn(async move { + read_loop(ws_stream, &read_tunnel, read_cancel).await; + }); + + let write_cancel = cancel.clone(); + tokio::spawn(async move { + write_loop(ws_sink, outgoing_rx, write_cancel).await; + }); + + tunnel +} + +async fn read_loop( + mut stream: futures::stream::SplitStream, + tunnel: &ActiveTunnel, + cancel: CancellationToken, +) { + loop { + let msg = tokio::select! { + msg = stream.next() => msg, + _ = cancel.cancelled() => break, + }; + + let Some(result) = msg else { break }; + + let frame = match result { + Ok(frame) => frame, + Err(e) => { + tracing::debug!(tunnel_id = %tunnel.id, error = %e, "websocket read error"); + break; + } + }; + + match frame { + Message::Text(text) => { + tunnel.stats.add_bytes_in(text.len() as u64); + handle_incoming_text(tunnel, &text); + } + Message::Close(_) => break, + _ => {} + } + } + + tracing::debug!(tunnel_id = %tunnel.id, "read loop exiting"); + cancel.cancel(); +} + +fn handle_incoming_text(tunnel: &ActiveTunnel, text: &str) { + let msg: TunnelMessage = match serde_json::from_str(text) { + Ok(m) => m, + Err(e) => { + tracing::warn!(tunnel_id = %tunnel.id, error = %e, "malformed message from client"); + return; + } + }; + + match msg { + TunnelMessage::Response { + request_id, + payload, + } => { + if let Some((_, tx)) = tunnel.pending_requests.remove(&request_id) { + let _ = tx.send(payload); + } + } + TunnelMessage::Pong => {} + other => { + tracing::debug!( + tunnel_id = %tunnel.id, + msg_type = ?other, + "unexpected message type from client" + ); + } + } +} + +async fn write_loop( + mut sink: futures::stream::SplitSink, + mut rx: mpsc::Receiver, + cancel: CancellationToken, +) { + loop { + let msg = tokio::select! { + msg = rx.recv() => msg, + _ = cancel.cancelled() => break, + }; + + let Some(msg) = msg else { break }; + + let json = match serde_json::to_string(&msg) { + Ok(j) => j, + Err(e) => { + tracing::error!(error = %e, "failed to serialize tunnel message"); + continue; + } + }; + + if sink.send(Message::text(json)).await.is_err() { + break; + } + } + + tracing::debug!("write loop exiting"); + cancel.cancel(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn send_error_display() { + assert_eq!(SendError::TunnelClosed.to_string(), "tunnel connection closed"); + assert_eq!(SendError::Timeout.to_string(), "request timed out"); + } +} diff --git a/crates/funnel-server/src/tunnel/manager.rs b/crates/funnel-server/src/tunnel/manager.rs new file mode 100644 index 0000000..5de137e --- /dev/null +++ b/crates/funnel-server/src/tunnel/manager.rs @@ -0,0 +1,102 @@ +use std::sync::Arc; + +use dashmap::DashMap; +use dashmap::mapref::entry::Entry; +use serde::Serialize; + +use funnel_core::tunnel::TunnelId; + +use super::connection::ActiveTunnel; +use super::stats::TunnelStatsSnapshot; + +pub struct TunnelManager { + tunnels: DashMap>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TunnelInfo { + pub id: String, + pub uptime_secs: f64, + pub stats: TunnelStatsSnapshot, +} + +impl TunnelManager { + pub fn new() -> Self { + Self { + tunnels: DashMap::new(), + } + } + + pub fn insert(&self, id: TunnelId, tunnel: Arc) -> Result<(), Arc> { + match self.tunnels.entry(id) { + Entry::Occupied(_) => Err(tunnel), + Entry::Vacant(e) => { + e.insert(tunnel); + Ok(()) + } + } + } + + pub fn remove(&self, id: &TunnelId) -> Option> { + self.tunnels.remove(id).map(|(_, t)| t) + } + + pub fn get(&self, id: &TunnelId) -> Option> { + self.tunnels.get(id).map(|r| Arc::clone(r.value())) + } + + pub fn exists(&self, id: &TunnelId) -> bool { + self.tunnels.contains_key(id) + } + + pub fn list(&self) -> Vec { + self.tunnels + .iter() + .map(|entry| { + let tunnel = entry.value(); + TunnelInfo { + id: entry.key().to_string(), + uptime_secs: tunnel.connected_at().elapsed().as_secs_f64(), + stats: tunnel.stats(), + } + }) + .collect() + } + + pub fn count(&self) -> usize { + self.tunnels.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_manager_is_empty() { + let mgr = TunnelManager::new(); + assert_eq!(mgr.count(), 0); + assert!(mgr.list().is_empty()); + } + + #[test] + fn exists_returns_false_for_missing() { + let mgr = TunnelManager::new(); + let id = TunnelId::new("test-abc").unwrap(); + assert!(!mgr.exists(&id)); + } + + #[test] + fn get_returns_none_for_missing() { + let mgr = TunnelManager::new(); + let id = TunnelId::new("test-abc").unwrap(); + assert!(mgr.get(&id).is_none()); + } + + #[test] + fn remove_returns_none_for_missing() { + let mgr = TunnelManager::new(); + let id = TunnelId::new("test-abc").unwrap(); + assert!(mgr.remove(&id).is_none()); + } +} diff --git a/crates/funnel-server/src/tunnel/mod.rs b/crates/funnel-server/src/tunnel/mod.rs index e69de29..ef787ad 100644 --- a/crates/funnel-server/src/tunnel/mod.rs +++ b/crates/funnel-server/src/tunnel/mod.rs @@ -0,0 +1,3 @@ +pub mod connection; +pub mod manager; +pub mod stats; diff --git a/crates/funnel-server/src/tunnel/stats.rs b/crates/funnel-server/src/tunnel/stats.rs new file mode 100644 index 0000000..d90738f --- /dev/null +++ b/crates/funnel-server/src/tunnel/stats.rs @@ -0,0 +1,75 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::Serialize; + +pub struct TunnelStats { + bytes_in: AtomicU64, + bytes_out: AtomicU64, + requests: AtomicU64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TunnelStatsSnapshot { + pub bytes_in: u64, + pub bytes_out: u64, + pub requests: u64, +} + +impl TunnelStats { + pub fn new() -> Self { + Self { + bytes_in: AtomicU64::new(0), + bytes_out: AtomicU64::new(0), + requests: AtomicU64::new(0), + } + } + + pub fn add_bytes_in(&self, n: u64) { + self.bytes_in.fetch_add(n, Ordering::Relaxed); + } + + pub fn add_bytes_out(&self, n: u64) { + self.bytes_out.fetch_add(n, Ordering::Relaxed); + } + + pub fn inc_requests(&self) { + self.requests.fetch_add(1, Ordering::Relaxed); + } + + pub fn snapshot(&self) -> TunnelStatsSnapshot { + TunnelStatsSnapshot { + bytes_in: self.bytes_in.load(Ordering::Relaxed), + bytes_out: self.bytes_out.load(Ordering::Relaxed), + requests: self.requests.load(Ordering::Relaxed), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracks_bytes_and_requests() { + let stats = TunnelStats::new(); + stats.add_bytes_in(100); + stats.add_bytes_in(200); + stats.add_bytes_out(50); + stats.inc_requests(); + stats.inc_requests(); + + let snap = stats.snapshot(); + assert_eq!(snap.bytes_in, 300); + assert_eq!(snap.bytes_out, 50); + assert_eq!(snap.requests, 2); + } + + #[test] + fn snapshot_starts_at_zero() { + let stats = TunnelStats::new(); + let snap = stats.snapshot(); + assert_eq!(snap.bytes_in, 0); + assert_eq!(snap.bytes_out, 0); + assert_eq!(snap.requests, 0); + } +} From 7e946578be1372f2a44a54202388806bec6e8210 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Mon, 16 Feb 2026 11:00:00 +0100 Subject: [PATCH 08/42] feat(ws): add websocket upgrade handler for tunnel clients --- crates/funnel-server/src/ws/handler.rs | 68 ++++++++++++++++++++++++++ crates/funnel-server/src/ws/mod.rs | 1 + 2 files changed, 69 insertions(+) create mode 100644 crates/funnel-server/src/ws/handler.rs diff --git a/crates/funnel-server/src/ws/handler.rs b/crates/funnel-server/src/ws/handler.rs new file mode 100644 index 0000000..13f2f54 --- /dev/null +++ b/crates/funnel-server/src/ws/handler.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use axum::extract::ws::WebSocket; +use axum::extract::{Query, State, WebSocketUpgrade}; +use axum::response::Response; +use serde::Deserialize; + +use funnel_core::tunnel::TunnelId; + +use crate::app::AppState; +use crate::error::AppError; +use crate::tunnel::connection; + +#[derive(Deserialize)] +pub struct ConnectParams { + id: Option, +} + +pub async fn upgrade( + State(state): State>, + Query(params): Query, + ws: WebSocketUpgrade, +) -> Result { + let tunnel_id = match params.id { + Some(raw) => TunnelId::new(raw)?, + None => TunnelId::generate(), + }; + + if state.tunnels.exists(&tunnel_id) { + return Err(AppError::TunnelConflict(tunnel_id.to_string())); + } + + tracing::info!(tunnel_id = %tunnel_id, "websocket upgrade accepted"); + + Ok(ws.on_upgrade(move |socket| handle_socket(state, tunnel_id, socket))) +} + +async fn handle_socket(state: Arc, id: TunnelId, socket: WebSocket) { + let tunnel = connection::spawn(id.clone(), socket); + + if let Err(_returned) = state.tunnels.insert(id.clone(), Arc::clone(&tunnel)) { + tracing::warn!(tunnel_id = %id, "tunnel id became occupied during upgrade"); + tunnel.close(); + return; + } + + tracing::info!( + tunnel_id = %id, + total_tunnels = state.tunnels.count(), + "tunnel connected" + ); + + // wait until the tunnel connection drops + tunnel.cancelled().await; + + let stats = tunnel.stats(); + state.tunnels.remove(&id); + + tracing::info!( + tunnel_id = %id, + bytes_in = stats.bytes_in, + bytes_out = stats.bytes_out, + requests = stats.requests, + uptime_secs = tunnel.connected_at().elapsed().as_secs_f64(), + remaining_tunnels = state.tunnels.count(), + "tunnel disconnected" + ); +} diff --git a/crates/funnel-server/src/ws/mod.rs b/crates/funnel-server/src/ws/mod.rs index e69de29..062ae9d 100644 --- a/crates/funnel-server/src/ws/mod.rs +++ b/crates/funnel-server/src/ws/mod.rs @@ -0,0 +1 @@ +pub mod handler; From ecf579dcca30cfc0dcfd64a7b3f3e092a9aa8f3c Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 8 Mar 2026 14:00:00 +0100 Subject: [PATCH 09/42] feat(proxy): add subdomain router and forwarding headers --- crates/funnel-server/src/proxy/headers.rs | 94 +++++++++++++ crates/funnel-server/src/proxy/mod.rs | 2 + crates/funnel-server/src/proxy/router.rs | 158 ++++++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 crates/funnel-server/src/proxy/headers.rs create mode 100644 crates/funnel-server/src/proxy/router.rs diff --git a/crates/funnel-server/src/proxy/headers.rs b/crates/funnel-server/src/proxy/headers.rs new file mode 100644 index 0000000..b171f59 --- /dev/null +++ b/crates/funnel-server/src/proxy/headers.rs @@ -0,0 +1,94 @@ +use std::collections::HashMap; +use std::net::SocketAddr; + +use axum::http::{HeaderMap, HeaderValue}; + +/// copy request headers and add standard proxy headers (X-Forwarded-For, etc). +pub fn prepare_forwarding_headers( + original: &HeaderMap, + host: &str, + remote_addr: SocketAddr, + is_tls: bool, +) -> HashMap> { + let mut headers: HashMap> = HashMap::new(); + + for (name, value) in original.iter() { + let key = name.as_str().to_string(); + let val = value.to_str().unwrap_or("").to_string(); + headers.entry(key).or_default().push(val); + } + + let client_ip = remote_addr.ip().to_string(); + + // append to existing X-Forwarded-For or create new + match headers.get("x-forwarded-for") { + Some(existing) if !existing.is_empty() => { + let combined = format!("{}, {}", existing[0], client_ip); + headers.insert("x-forwarded-for".to_string(), vec![combined]); + } + _ => { + headers.insert("x-forwarded-for".to_string(), vec![client_ip.clone()]); + } + } + + if !host.is_empty() { + headers.insert("x-forwarded-host".to_string(), vec![host.to_string()]); + } + + let proto = if is_tls { "https" } else { "http" }; + headers.insert("x-forwarded-proto".to_string(), vec![proto.to_string()]); + headers.insert("x-real-ip".to_string(), vec![client_ip]); + + headers +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + fn test_addr() -> SocketAddr { + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), 54321) + } + + #[test] + fn adds_forwarding_headers() { + let headers = HeaderMap::new(); + let result = prepare_forwarding_headers(&headers, "my-tunnel.example.com", test_addr(), false); + + assert_eq!(result["x-forwarded-for"], vec!["192.168.1.100"]); + assert_eq!(result["x-forwarded-host"], vec!["my-tunnel.example.com"]); + assert_eq!(result["x-forwarded-proto"], vec!["http"]); + assert_eq!(result["x-real-ip"], vec!["192.168.1.100"]); + } + + #[test] + fn appends_to_existing_forwarded_for() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.1")); + + let result = prepare_forwarding_headers(&headers, "t.example.com", test_addr(), false); + + assert_eq!(result["x-forwarded-for"], vec!["10.0.0.1, 192.168.1.100"]); + } + + #[test] + fn sets_https_proto_when_tls() { + let headers = HeaderMap::new(); + let result = prepare_forwarding_headers(&headers, "t.example.com", test_addr(), true); + + assert_eq!(result["x-forwarded-proto"], vec!["https"]); + } + + #[test] + fn copies_original_headers() { + let mut headers = HeaderMap::new(); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers.insert("authorization", HeaderValue::from_static("Bearer token123")); + + let result = prepare_forwarding_headers(&headers, "t.example.com", test_addr(), false); + + assert_eq!(result["content-type"], vec!["application/json"]); + assert_eq!(result["authorization"], vec!["Bearer token123"]); + } +} diff --git a/crates/funnel-server/src/proxy/mod.rs b/crates/funnel-server/src/proxy/mod.rs index e69de29..9fd8957 100644 --- a/crates/funnel-server/src/proxy/mod.rs +++ b/crates/funnel-server/src/proxy/mod.rs @@ -0,0 +1,2 @@ +pub mod headers; +pub mod router; diff --git a/crates/funnel-server/src/proxy/router.rs b/crates/funnel-server/src/proxy/router.rs new file mode 100644 index 0000000..3320b3a --- /dev/null +++ b/crates/funnel-server/src/proxy/router.rs @@ -0,0 +1,158 @@ +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{ConnectInfo, Host, State}; +use axum::http::{Request, Response, StatusCode}; + +use funnel_core::protocol::RequestPayload; +use funnel_core::tunnel::TunnelId; + +use crate::app::AppState; +use super::headers::prepare_forwarding_headers; + +const MAX_BODY_SIZE: usize = 10 * 1024 * 1024; // 10 mb + +/// axum fallback handler that routes requests based on subdomain. +/// requests to `{tunnel_id}.{base_domain}` are forwarded through the matching tunnel. +pub async fn handle_tunnel_request( + State(state): State>, + Host(host): Host, + ConnectInfo(remote_addr): ConnectInfo, + request: Request, +) -> Response { + let subdomain = match extract_subdomain(&host) { + Some(s) => s, + None => return not_found("tunnel not found"), + }; + + let tunnel_id = match TunnelId::new(subdomain) { + Ok(id) => id, + Err(_) => return not_found("tunnel not found"), + }; + + let tunnel = match state.tunnels.get(&tunnel_id) { + Some(t) => t, + None => return not_found("tunnel not found"), + }; + + let method = request.method().to_string(); + let path = request.uri().to_string(); + let is_tls = false; // will be determined by tls termination layer later + + let headers = prepare_forwarding_headers(request.headers(), &host, remote_addr, is_tls); + + let body_bytes = match axum::body::to_bytes(request.into_body(), MAX_BODY_SIZE).await { + Ok(b) => b.to_vec(), + Err(_) => return error_response(StatusCode::BAD_REQUEST, "request body too large"), + }; + + let payload = RequestPayload { + method, + path, + headers, + body: body_bytes, + }; + + match tunnel.send_request(payload).await { + Ok(response) => build_response(response), + Err(crate::tunnel::connection::SendError::Timeout) => { + error_response(StatusCode::GATEWAY_TIMEOUT, "request timed out") + } + Err(crate::tunnel::connection::SendError::TunnelClosed) => { + error_response(StatusCode::BAD_GATEWAY, "tunnel connection lost") + } + } +} + +/// extract the first subdomain label from a host string. +/// `"my-tunnel.example.com"` -> `Some("my-tunnel")` +/// `"example.com"` -> `None` (no subdomain) +/// `"localhost:8080"` -> `None` +fn extract_subdomain(host: &str) -> Option<&str> { + // strip port if present + let host = host.split(':').next().unwrap_or(host); + + let dot = host.find('.')?; + if dot == 0 { + return None; + } + + let subdomain = &host[..dot]; + let rest = &host[dot + 1..]; + + // need at least one more dot for it to be a real subdomain + // (subdomain.domain.tld), not just (domain.tld) + if rest.contains('.') { + Some(subdomain) + } else { + None + } +} + +fn build_response(payload: funnel_core::protocol::ResponsePayload) -> Response { + let mut builder = Response::builder().status(payload.status); + + if let Some(headers) = builder.headers_mut() { + for (name, values) in &payload.headers { + for value in values { + if let (Ok(name), Ok(value)) = ( + axum::http::HeaderName::try_from(name.as_str()), + axum::http::HeaderValue::from_str(value), + ) { + headers.append(name, value); + } + } + } + } + + builder + .body(Body::from(payload.body)) + .unwrap_or_else(|_| error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")) +} + +fn not_found(msg: &str) -> Response { + error_response(StatusCode::NOT_FOUND, msg) +} + +fn error_response(status: StatusCode, msg: &str) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain") + .body(Body::from(msg.to_string())) + .unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_subdomain_basic() { + assert_eq!(extract_subdomain("my-tunnel.example.com"), Some("my-tunnel")); + assert_eq!(extract_subdomain("abc.example.com"), Some("abc")); + } + + #[test] + fn extract_subdomain_with_port() { + assert_eq!(extract_subdomain("my-tunnel.example.com:8080"), Some("my-tunnel")); + } + + #[test] + fn extract_subdomain_no_subdomain() { + assert_eq!(extract_subdomain("example.com"), None); + assert_eq!(extract_subdomain("localhost"), None); + assert_eq!(extract_subdomain("localhost:8080"), None); + } + + #[test] + fn extract_subdomain_empty_or_dot_prefix() { + assert_eq!(extract_subdomain(""), None); + assert_eq!(extract_subdomain(".example.com"), None); + } + + #[test] + fn extract_subdomain_deep_nesting() { + // only extracts the first label + assert_eq!(extract_subdomain("a.b.example.com"), Some("a")); + } +} From 7821caa92d2c196f5b63e4708088a899ae60b303 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 8 Mar 2026 15:20:00 +0100 Subject: [PATCH 10/42] feat(server): wire tunnel engine into app state and routes --- crates/funnel-server/src/api/tunnels.rs | 48 +++++++++++++++---------- crates/funnel-server/src/app.rs | 7 ++++ crates/funnel-server/src/main.rs | 6 +++- 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/crates/funnel-server/src/api/tunnels.rs b/crates/funnel-server/src/api/tunnels.rs index c3465a8..0b7dc94 100644 --- a/crates/funnel-server/src/api/tunnels.rs +++ b/crates/funnel-server/src/api/tunnels.rs @@ -2,37 +2,47 @@ use std::sync::Arc; use axum::Json; use axum::extract::{Path, State}; -use serde::Serialize; + +use funnel_core::tunnel::TunnelId; use crate::app::AppState; use crate::error::AppError; +use crate::tunnel::manager::TunnelInfo; -#[derive(Serialize)] -pub struct TunnelInfo { - pub id: String, - pub status: &'static str, -} - -/// List all active tunnels. -pub async fn list(State(_state): State>) -> Json> { - // TODO: wire to TunnelManager once tunnel module is implemented - Json(vec![]) +pub async fn list(State(state): State>) -> Json> { + Json(state.tunnels.list()) } -/// Get a specific tunnel by ID. pub async fn get_tunnel( - State(_state): State>, + State(state): State>, Path(id): Path, ) -> Result, AppError> { - // TODO: wire to TunnelManager - Err(AppError::TunnelNotFound(id)) + let tunnel_id = TunnelId::new(id.clone())?; + + let tunnel = state + .tunnels + .get(&tunnel_id) + .ok_or_else(|| AppError::TunnelNotFound(id))?; + + Ok(Json(TunnelInfo { + id: tunnel.id().to_string(), + uptime_secs: tunnel.connected_at().elapsed().as_secs_f64(), + stats: tunnel.stats(), + })) } -/// Delete (force-close) a tunnel. pub async fn delete( - State(_state): State>, + State(state): State>, Path(id): Path, ) -> Result, AppError> { - // TODO: wire to TunnelManager - Err(AppError::TunnelNotFound(id)) + let tunnel_id = TunnelId::new(id.clone())?; + + let tunnel = state + .tunnels + .remove(&tunnel_id) + .ok_or_else(|| AppError::TunnelNotFound(id))?; + + tunnel.close(); + + Ok(Json(serde_json::json!({ "deleted": true }))) } diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs index 29db85f..95bfcaa 100644 --- a/crates/funnel-server/src/app.rs +++ b/crates/funnel-server/src/app.rs @@ -8,9 +8,13 @@ use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use crate::api; +use crate::tunnel::manager::TunnelManager; +use crate::ws; +use crate::proxy; pub struct AppState { pub db: PgPool, + pub tunnels: TunnelManager, pub start_time: Instant, } @@ -18,6 +22,7 @@ impl AppState { pub fn new(db: PgPool) -> Self { Self { db, + tunnels: TunnelManager::new(), start_time: Instant::now(), } } @@ -36,6 +41,8 @@ pub fn build_router(state: Arc) -> Router { Router::new() .nest("/api", api_routes) + .route("/ws", get(ws::handler::upgrade)) + .fallback(proxy::router::handle_tunnel_request) .with_state(state) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index 993d7fa..c60dd8e 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -68,7 +68,11 @@ async fn main() -> anyhow::Result<()> { tracing::info!(addr = %addr, "server listening"); - axum::serve(listener, router).await?; + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await?; Ok(()) } From 9fd7fdfef3aa466188e7fcc163a99129ffe1b509 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 5 Apr 2026 13:00:00 +0200 Subject: [PATCH 11/42] feat(client): add tunnel client with websocket, forwarding, and cli --- crates/funnel-client/Cargo.toml | 9 +- crates/funnel-client/src/config.rs | 120 ++++++++++ crates/funnel-client/src/forwarder.rs | 123 +++++++++++ crates/funnel-client/src/main.rs | 207 ++++++++++++++++- crates/funnel-client/src/runner.rs | 116 ++++++++++ crates/funnel-client/src/tunnel.rs | 305 ++++++++++++++++++++++++++ 6 files changed, 876 insertions(+), 4 deletions(-) create mode 100644 crates/funnel-client/src/config.rs create mode 100644 crates/funnel-client/src/forwarder.rs create mode 100644 crates/funnel-client/src/runner.rs create mode 100644 crates/funnel-client/src/tunnel.rs diff --git a/crates/funnel-client/Cargo.toml b/crates/funnel-client/Cargo.toml index 5e9b894..a87bb2c 100644 --- a/crates/funnel-client/Cargo.toml +++ b/crates/funnel-client/Cargo.toml @@ -8,12 +8,17 @@ funnel-core = { path = "../funnel-core" } anyhow = "1" clap = { version = "4", features = ["derive"] } -reqwest = { version = "0.12", features = ["json"] } +dirs = "6" +futures = "0.3" +http = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } +tokio-util = "0.7" toml = "0.8" tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" uuid = { version = "1", features = ["v7"] } diff --git a/crates/funnel-client/src/config.rs b/crates/funnel-client/src/config.rs new file mode 100644 index 0000000..f0c9ac5 --- /dev/null +++ b/crates/funnel-client/src/config.rs @@ -0,0 +1,120 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +const CONFIG_DIR: &str = "funnel"; +const CONFIG_FILE: &str = "config.toml"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub inlets: HashMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Inlet { + #[serde(default)] + pub server: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +pub fn config_path() -> PathBuf { + if let Some(dir) = dirs::config_dir() { + return dir.join(CONFIG_DIR).join(CONFIG_FILE); + } + PathBuf::from(CONFIG_FILE) +} + +pub fn load() -> anyhow::Result { + let path = config_path(); + if !path.exists() { + return Ok(Config::default()); + } + let content = std::fs::read_to_string(&path)?; + let config: Config = toml::from_str(&content)?; + Ok(config) +} + +pub fn save(config: &Config) -> anyhow::Result<()> { + let path = config_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let content = toml::to_string_pretty(config)?; + std::fs::write(&path, content)?; + + // restrict permissions on unix (file contains tokens) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(()) +} + +pub fn get_inlet(config: &Config, name: &str) -> Option<&Inlet> { + config.inlets.get(name) +} + +pub fn set_token(inlet_name: &str, token: &str) -> anyhow::Result<()> { + let mut config = load()?; + let inlet = config.inlets.entry(inlet_name.to_string()).or_default(); + inlet.token = Some(token.to_string()); + save(&config) +} + +pub fn set_server(inlet_name: &str, server: &str) -> anyhow::Result<()> { + let mut config = load()?; + let inlet = config.inlets.entry(inlet_name.to_string()).or_default(); + inlet.server = server.to_string(); + save(&config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_has_no_inlets() { + let config = Config::default(); + assert!(config.inlets.is_empty()); + } + + #[test] + fn roundtrip_toml() { + let mut config = Config::default(); + config.inlets.insert( + "default".to_string(), + Inlet { + server: "https://tunnel.example.com".to_string(), + domain: None, + token: Some("sk_test123".to_string()), + }, + ); + + let serialized = toml::to_string_pretty(&config).unwrap(); + let parsed: Config = toml::from_str(&serialized).unwrap(); + + let inlet = parsed.inlets.get("default").unwrap(); + assert_eq!(inlet.server, "https://tunnel.example.com"); + assert_eq!(inlet.token.as_deref(), Some("sk_test123")); + assert!(inlet.domain.is_none()); + } + + #[test] + fn get_inlet_returns_none_for_missing() { + let config = Config::default(); + assert!(get_inlet(&config, "default").is_none()); + } + + #[test] + fn config_path_is_not_empty() { + let path = config_path(); + assert!(!path.as_os_str().is_empty()); + } +} diff --git a/crates/funnel-client/src/forwarder.rs b/crates/funnel-client/src/forwarder.rs new file mode 100644 index 0000000..d9f9f87 --- /dev/null +++ b/crates/funnel-client/src/forwarder.rs @@ -0,0 +1,123 @@ +use std::collections::HashMap; + +use funnel_core::protocol::{RequestPayload, ResponsePayload}; + +const HOP_BY_HOP_HEADERS: &[&str] = &[ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "proxy-connection", +]; + +pub struct Forwarder { + client: reqwest::Client, + local_addr: String, +} + +impl Forwarder { + pub fn new(local_addr: String) -> Self { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(30)) + .pool_max_idle_per_host(10) + .build() + .expect("failed to build http client"); + + Self { client, local_addr } + } + + pub async fn forward(&self, req: RequestPayload) -> ResponsePayload { + match self.try_forward(&req).await { + Ok(resp) => resp, + Err(e) => { + tracing::error!(error = %e, method = %req.method, path = %req.path, "forwarding failed"); + error_response(502, &format!("local service error: {e}")) + } + } + } + + async fn try_forward(&self, req: &RequestPayload) -> anyhow::Result { + let url = format!("http://{}{}", self.local_addr, req.path); + + let method: http::Method = req.method.parse()?; + let mut builder = self.client.request(method, &url); + + for (name, values) in &req.headers { + if is_hop_by_hop(name) || name.eq_ignore_ascii_case("host") { + continue; + } + for value in values { + builder = builder.header(name.as_str(), value.as_str()); + } + } + + builder = builder.header("host", &self.local_addr); + builder = builder.body(req.body.clone()); + + let resp = builder.send().await?; + + let status = resp.status().as_u16(); + let headers = collect_response_headers(resp.headers()); + let body = resp.bytes().await?.to_vec(); + + Ok(ResponsePayload { + status, + headers, + body, + }) + } +} + +fn is_hop_by_hop(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + HOP_BY_HOP_HEADERS.iter().any(|h| *h == lower) +} + +fn collect_response_headers(headers: &reqwest::header::HeaderMap) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for (name, value) in headers.iter() { + if is_hop_by_hop(name.as_str()) { + continue; + } + let val = value.to_str().unwrap_or("").to_string(); + map.entry(name.to_string()).or_default().push(val); + } + map +} + +fn error_response(status: u16, msg: &str) -> ResponsePayload { + let mut headers = HashMap::new(); + headers.insert("content-type".to_string(), vec!["text/plain".to_string()]); + ResponsePayload { + status, + headers, + body: msg.as_bytes().to_vec(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hop_by_hop_detection() { + assert!(is_hop_by_hop("Connection")); + assert!(is_hop_by_hop("transfer-encoding")); + assert!(is_hop_by_hop("Keep-Alive")); + assert!(!is_hop_by_hop("Content-Type")); + assert!(!is_hop_by_hop("Authorization")); + } + + #[test] + fn error_response_has_correct_status() { + let resp = error_response(502, "test error"); + assert_eq!(resp.status, 502); + assert_eq!(resp.body, b"test error"); + assert!(resp.headers.contains_key("content-type")); + } +} diff --git a/crates/funnel-client/src/main.rs b/crates/funnel-client/src/main.rs index ae062fd..4602c08 100644 --- a/crates/funnel-client/src/main.rs +++ b/crates/funnel-client/src/main.rs @@ -1,3 +1,206 @@ -fn main() { - println!("funnel-client"); +mod config; +mod forwarder; +mod runner; +mod tunnel; + +use clap::{Parser, Subcommand}; +use tokio_util::sync::CancellationToken; +use tracing_subscriber::EnvFilter; + +use funnel_core::tunnel::TunnelId; + +#[derive(Parser)] +#[command(name = "funnel", about = "tunnel client for exposing local services")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// create an http tunnel + Http { + /// local address or port to forward to (e.g. "3000" or "localhost:3000") + address: String, + + /// tunnel server url (overrides config) + #[arg(short, long)] + server: Option, + + /// tunnel id (subdomain), generated if omitted + #[arg(short, long)] + id: Option, + + /// inlet configuration to use + #[arg(long, default_value = "default")] + inlet: String, + + /// authentication token (overrides config) + #[arg(short, long)] + token: Option, + }, + /// manage client configuration + Config { + #[command(subcommand)] + action: ConfigAction, + }, +} + +#[derive(Subcommand)] +enum ConfigAction { + /// save authentication token + SetToken { + token: String, + #[arg(long, default_value = "default")] + inlet: String, + }, + /// save server url + SetServer { + url: String, + #[arg(long, default_value = "default")] + inlet: String, + }, + /// show current configuration + Show, + /// show config file path + Path, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + let cli = Cli::parse(); + + match cli.command { + Command::Http { + address, + server, + id, + inlet, + token, + } => run_http(address, server, id, inlet, token).await?, + Command::Config { action } => run_config(action)?, + } + + Ok(()) +} + +async fn run_http( + address: String, + server_flag: Option, + id_flag: Option, + inlet_name: String, + token_flag: Option, +) -> anyhow::Result<()> { + let local_addr = normalize_address(&address); + + let cfg = config::load().unwrap_or_default(); + let inlet = config::get_inlet(&cfg, &inlet_name); + + let server_url = server_flag + .or_else(|| inlet.map(|i| i.server.clone())) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + anyhow::anyhow!( + "no server configured. use --server or run: funnel config set-server " + ) + })?; + + let token = token_flag.or_else(|| inlet.and_then(|i| i.token.clone())); + + let tunnel_id = match id_flag { + Some(raw) => TunnelId::new(raw)?, + None => TunnelId::generate(), + }; + + if let Some(public_url) = runner::build_public_url(&server_url, &tunnel_id) { + tracing::info!( + tunnel_id = %tunnel_id, + local = %local_addr, + public_url = %public_url, + "starting tunnel" + ); + } + + let shutdown = CancellationToken::new(); + let shutdown_signal = shutdown.clone(); + + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + tracing::info!("received shutdown signal"); + shutdown_signal.cancel(); + }); + + runner::run(tunnel_id, server_url, local_addr, token, shutdown).await; + + Ok(()) +} + +fn normalize_address(addr: &str) -> String { + if addr.contains(':') { + addr.to_string() + } else { + format!("localhost:{addr}") + } +} + +fn run_config(action: ConfigAction) -> anyhow::Result<()> { + match action { + ConfigAction::SetToken { token, inlet } => { + config::set_token(&inlet, &token)?; + println!("token saved to inlet \"{inlet}\""); + println!(" config: {}", config::config_path().display()); + } + ConfigAction::SetServer { url, inlet } => { + config::set_server(&inlet, &url)?; + println!("server saved to inlet \"{inlet}\""); + println!(" config: {}", config::config_path().display()); + } + ConfigAction::Show => { + let cfg = config::load()?; + println!("config: {}\n", config::config_path().display()); + if cfg.inlets.is_empty() { + println!("no inlets configured."); + } else { + for (name, inlet) in &cfg.inlets { + println!("[{name}]"); + if !inlet.server.is_empty() { + println!(" server: {}", inlet.server); + } + if let Some(domain) = &inlet.domain { + println!(" domain: {domain}"); + } + if let Some(token) = &inlet.token { + let visible = &token[..token.len().min(10)]; + println!(" token: {visible}..."); + } + println!(); + } + } + } + ConfigAction::Path => { + println!("{}", config::config_path().display()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_port_only() { + assert_eq!(normalize_address("3000"), "localhost:3000"); + } + + #[test] + fn normalize_full_address() { + assert_eq!(normalize_address("127.0.0.1:8080"), "127.0.0.1:8080"); + } } diff --git a/crates/funnel-client/src/runner.rs b/crates/funnel-client/src/runner.rs new file mode 100644 index 0000000..a6ab15a --- /dev/null +++ b/crates/funnel-client/src/runner.rs @@ -0,0 +1,116 @@ +use std::time::Duration; + +use tokio_util::sync::CancellationToken; +use url::Url; + +use funnel_core::tunnel::TunnelId; + +use crate::tunnel::TunnelClient; + +const INITIAL_BACKOFF: Duration = Duration::from_secs(1); +const MAX_BACKOFF: Duration = Duration::from_secs(30); + +pub async fn run( + tunnel_id: TunnelId, + server_url: String, + local_addr: String, + token: Option, + shutdown: CancellationToken, +) { + let mut attempt: u32 = 0; + + loop { + if shutdown.is_cancelled() { + break; + } + + let client = TunnelClient::new( + tunnel_id.clone(), + server_url.clone(), + local_addr.clone(), + token.clone(), + ); + + tracing::info!(attempt = attempt + 1, "connecting to server"); + + let run_cancel = shutdown.child_token(); + match client.run(run_cancel).await { + Ok(()) => { + attempt = 0; + tracing::warn!("connection lost, reconnecting"); + } + Err(e) => { + tracing::error!(error = %e, "connection failed"); + } + } + + let delay = backoff_delay(attempt); + attempt = attempt.saturating_add(1); + + tracing::info!(delay_secs = delay.as_secs_f64(), "reconnecting"); + + tokio::select! { + _ = tokio::time::sleep(delay) => {}, + _ = shutdown.cancelled() => break, + } + } + + tracing::info!("client shut down"); +} + +fn backoff_delay(attempt: u32) -> Duration { + let secs = 2u64.saturating_pow(attempt); + let delay = Duration::from_secs(secs).min(MAX_BACKOFF); + delay.max(INITIAL_BACKOFF) +} + +pub fn build_public_url(server_url: &str, tunnel_id: &TunnelId) -> Option { + let url = Url::parse(server_url).ok()?; + let host = url.host_str()?; + let scheme = if url.scheme() == "https" { "https" } else { "http" }; + + match url.port() { + Some(port) => Some(format!("{scheme}://{tunnel_id}.{host}:{port}")), + None => Some(format!("{scheme}://{tunnel_id}.{host}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_increases_exponentially() { + assert_eq!(backoff_delay(0), Duration::from_secs(1)); + assert_eq!(backoff_delay(1), Duration::from_secs(2)); + assert_eq!(backoff_delay(2), Duration::from_secs(4)); + assert_eq!(backoff_delay(3), Duration::from_secs(8)); + } + + #[test] + fn backoff_caps_at_max() { + assert_eq!(backoff_delay(10), MAX_BACKOFF); + assert_eq!(backoff_delay(20), MAX_BACKOFF); + } + + #[test] + fn public_url_http() { + let id = TunnelId::new("my-tunnel").unwrap(); + let url = build_public_url("http://tunnel.example.com", &id); + assert_eq!(url.as_deref(), Some("http://my-tunnel.tunnel.example.com")); + } + + #[test] + fn public_url_https() { + let id = TunnelId::new("my-tunnel").unwrap(); + let url = build_public_url("https://tunnel.example.com", &id); + assert_eq!(url.as_deref(), Some("https://my-tunnel.tunnel.example.com")); + } + + #[test] + fn public_url_with_port() { + let id = TunnelId::new("abc").unwrap(); + let url = build_public_url("http://localhost:8080", &id); + assert_eq!(url.as_deref(), Some("http://abc.localhost:8080")); + } +} diff --git a/crates/funnel-client/src/tunnel.rs b/crates/funnel-client/src/tunnel.rs new file mode 100644 index 0000000..d805151 --- /dev/null +++ b/crates/funnel-client/src/tunnel.rs @@ -0,0 +1,305 @@ +use std::sync::Arc; +use std::time::Duration; + +use futures::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; +use tokio_util::sync::CancellationToken; +use url::Url; + +use funnel_core::protocol::TunnelMessage; +use funnel_core::tunnel::TunnelId; + +use crate::forwarder::Forwarder; + +const CHANNEL_BUFFER: usize = 128; +const PING_INTERVAL: Duration = Duration::from_secs(30); +const PONG_TIMEOUT: Duration = Duration::from_secs(90); +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_CONCURRENT_REQUESTS: usize = 128; + +pub struct TunnelClient { + pub tunnel_id: TunnelId, + server_url: String, + local_addr: String, + token: Option, +} + +impl TunnelClient { + pub fn new( + tunnel_id: TunnelId, + server_url: String, + local_addr: String, + token: Option, + ) -> Self { + Self { + tunnel_id, + server_url, + local_addr, + token, + } + } + + /// connect to the server and run the tunnel until it disconnects. + pub async fn run(&self, cancel: CancellationToken) -> anyhow::Result<()> { + let ws = self.connect().await?; + let (ws_sink, ws_stream) = ws.split(); + + let (outgoing_tx, outgoing_rx) = mpsc::channel::(CHANNEL_BUFFER); + let forwarder = Arc::new(Forwarder::new(self.local_addr.clone())); + let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_REQUESTS)); + + let read_cancel = cancel.clone(); + let read_tx = outgoing_tx.clone(); + let read_forwarder = Arc::clone(&forwarder); + let read_semaphore = Arc::clone(&semaphore); + let read_tunnel_id = self.tunnel_id.clone(); + let read_handle = tokio::spawn(async move { + read_loop( + ws_stream, + read_tx, + read_forwarder, + read_semaphore, + read_tunnel_id, + read_cancel, + ) + .await; + }); + + let write_cancel = cancel.clone(); + let write_handle = tokio::spawn(async move { + write_loop(ws_sink, outgoing_rx, write_cancel).await; + }); + + let ping_cancel = cancel.clone(); + let ping_tx = outgoing_tx.clone(); + let ping_handle = tokio::spawn(async move { + heartbeat_loop(ping_tx, ping_cancel).await; + }); + + tokio::select! { + _ = read_handle => {}, + _ = write_handle => {}, + _ = ping_handle => {}, + _ = cancel.cancelled() => {}, + } + + Ok(()) + } + + async fn connect( + &self, + ) -> anyhow::Result< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + > { + let mut url = Url::parse(&self.server_url)?; + + match url.scheme() { + "https" => url.set_scheme("wss").unwrap(), + _ => url.set_scheme("ws").unwrap(), + }; + + url.set_path("/ws"); + url.query_pairs_mut() + .append_pair("id", self.tunnel_id.as_ref()); + + let mut request = tokio_tungstenite::tungstenite::http::Request::builder() + .uri(url.as_str()) + .body(())?; + + if let Some(token) = &self.token { + request.headers_mut().insert( + "Authorization", + format!("Bearer {token}").parse()?, + ); + } + + tracing::debug!(url = %url, "connecting to server"); + + let (ws, _response) = + tokio::time::timeout(HANDSHAKE_TIMEOUT, tokio_tungstenite::connect_async(request)) + .await + .map_err(|_| anyhow::anyhow!("connection timed out"))??; + + tracing::info!(tunnel_id = %self.tunnel_id, "websocket connected"); + Ok(ws) + } +} + +async fn read_loop( + mut stream: futures::stream::SplitStream< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + >, + outgoing_tx: mpsc::Sender, + forwarder: Arc, + semaphore: Arc, + tunnel_id: TunnelId, + cancel: CancellationToken, +) { + loop { + let msg = tokio::select! { + msg = stream.next() => msg, + _ = cancel.cancelled() => break, + }; + + let Some(result) = msg else { break }; + + let frame = match result { + Ok(f) => f, + Err(e) => { + tracing::debug!(error = %e, "websocket read error"); + break; + } + }; + + match frame { + Message::Text(text) => { + handle_text_message( + &text, + &outgoing_tx, + &forwarder, + &semaphore, + &tunnel_id, + &cancel, + ); + } + Message::Close(_) => break, + Message::Ping(_) => { + // tungstenite handles pong automatically + } + _ => {} + } + } + + tracing::debug!("read loop exiting"); + cancel.cancel(); +} + +fn handle_text_message( + text: &str, + outgoing_tx: &mpsc::Sender, + forwarder: &Arc, + semaphore: &Arc, + tunnel_id: &TunnelId, + cancel: &CancellationToken, +) { + let msg: TunnelMessage = match serde_json::from_str(text) { + Ok(m) => m, + Err(e) => { + tracing::warn!(error = %e, "malformed message from server"); + return; + } + }; + + match msg { + TunnelMessage::Request { + request_id, + payload, + .. + } => { + tracing::debug!( + request_id = %request_id, + method = %payload.method, + path = %payload.path, + "received request" + ); + + let tx = outgoing_tx.clone(); + let fwd = Arc::clone(forwarder); + let sem = Arc::clone(semaphore); + let cancel = cancel.clone(); + let tid = tunnel_id.clone(); + + tokio::spawn(async move { + let _permit = tokio::select! { + permit = sem.acquire() => match permit { + Ok(p) => p, + Err(_) => return, + }, + _ = cancel.cancelled() => return, + }; + + let response = fwd.forward(payload).await; + + let msg = TunnelMessage::Response { + request_id, + payload: response, + }; + + if tx.send(msg).await.is_err() { + tracing::debug!(tunnel_id = %tid, "outgoing channel closed"); + } + }); + } + TunnelMessage::RequestCancel { request_id } => { + tracing::debug!(request_id = %request_id, "request cancelled by server"); + // cancellation of individual in flight requests would require + // tracking cancel tokens per request. for now we just log it; + // the forwarded request will complete and its response will be + // silently dropped by the server since the pending entry is gone. + } + TunnelMessage::Pong => { + tracing::debug!("pong received"); + } + other => { + tracing::debug!(msg_type = ?other, "unhandled message type"); + } + } +} + +async fn write_loop( + mut sink: futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + Message, + >, + mut rx: mpsc::Receiver, + cancel: CancellationToken, +) { + loop { + let msg = tokio::select! { + msg = rx.recv() => msg, + _ = cancel.cancelled() => break, + }; + + let Some(msg) = msg else { break }; + + let json = match serde_json::to_string(&msg) { + Ok(j) => j, + Err(e) => { + tracing::error!(error = %e, "failed to serialize message"); + continue; + } + }; + + if sink.send(Message::text(json)).await.is_err() { + break; + } + } + + tracing::debug!("write loop exiting"); + cancel.cancel(); +} + +async fn heartbeat_loop(tx: mpsc::Sender, cancel: CancellationToken) { + let mut interval = tokio::time::interval(PING_INTERVAL); + interval.tick().await; // skip first immediate tick + + loop { + tokio::select! { + _ = interval.tick() => {}, + _ = cancel.cancelled() => break, + } + + if tx.send(TunnelMessage::Ping).await.is_err() { + break; + } + } + + tracing::debug!("heartbeat loop exiting"); +} From 11c65442de80563d15ea7c9c4deee7816385d76d Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 5 Apr 2026 14:30:00 +0200 Subject: [PATCH 12/42] fix: resolve build errors in server and client --- crates/funnel-client/src/config.rs | 2 +- crates/funnel-client/src/tunnel.rs | 1 - crates/funnel-server/Cargo.toml | 2 +- .../funnel-server/src/db/tunnel_sessions.rs | 18 +++++---- crates/funnel-server/src/proxy/headers.rs | 3 +- crates/funnel-server/src/proxy/router.rs | 40 +++++++++++-------- 6 files changed, 38 insertions(+), 28 deletions(-) diff --git a/crates/funnel-client/src/config.rs b/crates/funnel-client/src/config.rs index f0c9ac5..c79cd74 100644 --- a/crates/funnel-client/src/config.rs +++ b/crates/funnel-client/src/config.rs @@ -57,7 +57,7 @@ pub fn save(config: &Config) -> anyhow::Result<()> { Ok(()) } -pub fn get_inlet(config: &Config, name: &str) -> Option<&Inlet> { +pub fn get_inlet<'a>(config: &'a Config, name: &str) -> Option<&'a Inlet> { config.inlets.get(name) } diff --git a/crates/funnel-client/src/tunnel.rs b/crates/funnel-client/src/tunnel.rs index d805151..2e24ceb 100644 --- a/crates/funnel-client/src/tunnel.rs +++ b/crates/funnel-client/src/tunnel.rs @@ -14,7 +14,6 @@ use crate::forwarder::Forwarder; const CHANNEL_BUFFER: usize = 128; const PING_INTERVAL: Duration = Duration::from_secs(30); -const PONG_TIMEOUT: Duration = Duration::from_secs(90); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); const MAX_CONCURRENT_REQUESTS: usize = 128; diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index 4ff9699..1a0c71f 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -10,7 +10,7 @@ anyhow = "1" argon2 = "0.5" axum = { version = "0.8", features = ["ws"] } chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } dashmap = "6" futures = "0.3" jsonwebtoken = "9" diff --git a/crates/funnel-server/src/db/tunnel_sessions.rs b/crates/funnel-server/src/db/tunnel_sessions.rs index 1be0e17..443575e 100644 --- a/crates/funnel-server/src/db/tunnel_sessions.rs +++ b/crates/funnel-server/src/db/tunnel_sessions.rs @@ -1,5 +1,3 @@ -use std::net::IpAddr; - use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::PgPool; @@ -10,7 +8,7 @@ pub struct TunnelSession { pub id: Uuid, pub user_id: Uuid, pub tunnel_id: String, - pub client_ip: Option, + pub client_ip: Option, pub connected_at: DateTime, pub disconnected_at: Option>, pub bytes_in: i64, @@ -22,13 +20,13 @@ pub async fn create( pool: &PgPool, user_id: Uuid, tunnel_id: &str, - client_ip: Option, + client_ip: Option<&str>, ) -> Result { sqlx::query_as::<_, TunnelSession>( r#" INSERT INTO tunnel_sessions (user_id, tunnel_id, client_ip) - VALUES ($1, $2, $3) - RETURNING * + VALUES ($1, $2, $3::inet) + RETURNING id, user_id, tunnel_id, client_ip::text, connected_at, disconnected_at, bytes_in, bytes_out, requests "#, ) .bind(user_id) @@ -69,7 +67,8 @@ pub async fn list_for_user( ) -> Result, sqlx::Error> { sqlx::query_as::<_, TunnelSession>( r#" - SELECT * FROM tunnel_sessions + SELECT id, user_id, tunnel_id, client_ip::text, connected_at, disconnected_at, bytes_in, bytes_out, requests + FROM tunnel_sessions WHERE user_id = $1 ORDER BY connected_at DESC LIMIT $2 @@ -83,7 +82,10 @@ pub async fn list_for_user( pub async fn list_active(pool: &PgPool) -> Result, sqlx::Error> { sqlx::query_as::<_, TunnelSession>( - "SELECT * FROM tunnel_sessions WHERE disconnected_at IS NULL ORDER BY connected_at DESC", + r#" + SELECT id, user_id, tunnel_id, client_ip::text, connected_at, disconnected_at, bytes_in, bytes_out, requests + FROM tunnel_sessions WHERE disconnected_at IS NULL ORDER BY connected_at DESC + "#, ) .fetch_all(pool) .await diff --git a/crates/funnel-server/src/proxy/headers.rs b/crates/funnel-server/src/proxy/headers.rs index b171f59..5c54f9d 100644 --- a/crates/funnel-server/src/proxy/headers.rs +++ b/crates/funnel-server/src/proxy/headers.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::net::SocketAddr; -use axum::http::{HeaderMap, HeaderValue}; +use axum::http::HeaderMap; /// copy request headers and add standard proxy headers (X-Forwarded-For, etc). pub fn prepare_forwarding_headers( @@ -45,6 +45,7 @@ pub fn prepare_forwarding_headers( #[cfg(test)] mod tests { use super::*; + use axum::http::HeaderValue; use std::net::{IpAddr, Ipv4Addr}; fn test_addr() -> SocketAddr { diff --git a/crates/funnel-server/src/proxy/router.rs b/crates/funnel-server/src/proxy/router.rs index 3320b3a..e970358 100644 --- a/crates/funnel-server/src/proxy/router.rs +++ b/crates/funnel-server/src/proxy/router.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use axum::body::Body; -use axum::extract::{ConnectInfo, Host, State}; +use axum::extract::State; use axum::http::{Request, Response, StatusCode}; use funnel_core::protocol::RequestPayload; @@ -16,16 +16,20 @@ const MAX_BODY_SIZE: usize = 10 * 1024 * 1024; // 10 mb /// requests to `{tunnel_id}.{base_domain}` are forwarded through the matching tunnel. pub async fn handle_tunnel_request( State(state): State>, - Host(host): Host, - ConnectInfo(remote_addr): ConnectInfo, request: Request, ) -> Response { - let subdomain = match extract_subdomain(&host) { - Some(s) => s, + let host = request + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let subdomain = match extract_subdomain(host) { + Some(s) => s.to_string(), None => return not_found("tunnel not found"), }; - let tunnel_id = match TunnelId::new(subdomain) { + let tunnel_id = match TunnelId::new(&subdomain) { Ok(id) => id, Err(_) => return not_found("tunnel not found"), }; @@ -37,9 +41,21 @@ pub async fn handle_tunnel_request( let method = request.method().to_string(); let path = request.uri().to_string(); - let is_tls = false; // will be determined by tls termination layer later - let headers = prepare_forwarding_headers(request.headers(), &host, remote_addr, is_tls); + let remote_addr = request + .extensions() + .get::>() + .map(|ci| ci.0); + + let headers = match remote_addr { + Some(addr) => prepare_forwarding_headers(request.headers(), host, addr, false), + None => prepare_forwarding_headers( + request.headers(), + host, + std::net::SocketAddr::from(([0, 0, 0, 0], 0)), + false, + ), + }; let body_bytes = match axum::body::to_bytes(request.into_body(), MAX_BODY_SIZE).await { Ok(b) => b.to_vec(), @@ -64,12 +80,7 @@ pub async fn handle_tunnel_request( } } -/// extract the first subdomain label from a host string. -/// `"my-tunnel.example.com"` -> `Some("my-tunnel")` -/// `"example.com"` -> `None` (no subdomain) -/// `"localhost:8080"` -> `None` fn extract_subdomain(host: &str) -> Option<&str> { - // strip port if present let host = host.split(':').next().unwrap_or(host); let dot = host.find('.')?; @@ -80,8 +91,6 @@ fn extract_subdomain(host: &str) -> Option<&str> { let subdomain = &host[..dot]; let rest = &host[dot + 1..]; - // need at least one more dot for it to be a real subdomain - // (subdomain.domain.tld), not just (domain.tld) if rest.contains('.') { Some(subdomain) } else { @@ -152,7 +161,6 @@ mod tests { #[test] fn extract_subdomain_deep_nesting() { - // only extracts the first label assert_eq!(extract_subdomain("a.b.example.com"), Some("a")); } } From d57361e88a594ffe41323fa03669d847eeb8f01e Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Mon, 6 Apr 2026 10:15:00 +0200 Subject: [PATCH 13/42] fix: use ipnetwork for inet columns and suppress dead code warnings --- crates/funnel-server/Cargo.toml | 3 ++- crates/funnel-server/src/db/mod.rs | 5 +++- .../funnel-server/src/db/tunnel_sessions.rs | 23 ++++++++++--------- crates/funnel-server/src/error.rs | 1 + crates/funnel-server/src/tunnel/connection.rs | 1 + crates/funnel-server/src/tunnel/stats.rs | 1 + 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index 1a0c71f..066fdb3 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -16,7 +16,8 @@ futures = "0.3" jsonwebtoken = "9" serde = { version = "1", features = ["derive"] } serde_json = "1" -sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "migrate"] } +ipnetwork = "0.20" +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "migrate", "ipnetwork"] } thiserror = "2" tokio = { version = "1", features = ["full"] } tokio-util = "0.7" diff --git a/crates/funnel-server/src/db/mod.rs b/crates/funnel-server/src/db/mod.rs index 21ec42e..6847d25 100644 --- a/crates/funnel-server/src/db/mod.rs +++ b/crates/funnel-server/src/db/mod.rs @@ -1,3 +1,6 @@ -pub mod users; +#[allow(dead_code)] pub mod api_keys; +#[allow(dead_code)] pub mod tunnel_sessions; +#[allow(dead_code)] +pub mod users; diff --git a/crates/funnel-server/src/db/tunnel_sessions.rs b/crates/funnel-server/src/db/tunnel_sessions.rs index 443575e..a710b03 100644 --- a/crates/funnel-server/src/db/tunnel_sessions.rs +++ b/crates/funnel-server/src/db/tunnel_sessions.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use ipnetwork::IpNetwork; use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; @@ -8,7 +9,7 @@ pub struct TunnelSession { pub id: Uuid, pub user_id: Uuid, pub tunnel_id: String, - pub client_ip: Option, + pub client_ip: Option, pub connected_at: DateTime, pub disconnected_at: Option>, pub bytes_in: i64, @@ -20,13 +21,13 @@ pub async fn create( pool: &PgPool, user_id: Uuid, tunnel_id: &str, - client_ip: Option<&str>, + client_ip: Option, ) -> Result { sqlx::query_as::<_, TunnelSession>( r#" INSERT INTO tunnel_sessions (user_id, tunnel_id, client_ip) - VALUES ($1, $2, $3::inet) - RETURNING id, user_id, tunnel_id, client_ip::text, connected_at, disconnected_at, bytes_in, bytes_out, requests + VALUES ($1, $2, $3) + RETURNING * "#, ) .bind(user_id) @@ -67,8 +68,7 @@ pub async fn list_for_user( ) -> Result, sqlx::Error> { sqlx::query_as::<_, TunnelSession>( r#" - SELECT id, user_id, tunnel_id, client_ip::text, connected_at, disconnected_at, bytes_in, bytes_out, requests - FROM tunnel_sessions + SELECT * FROM tunnel_sessions WHERE user_id = $1 ORDER BY connected_at DESC LIMIT $2 @@ -82,10 +82,7 @@ pub async fn list_for_user( pub async fn list_active(pool: &PgPool) -> Result, sqlx::Error> { sqlx::query_as::<_, TunnelSession>( - r#" - SELECT id, user_id, tunnel_id, client_ip::text, connected_at, disconnected_at, bytes_in, bytes_out, requests - FROM tunnel_sessions WHERE disconnected_at IS NULL ORDER BY connected_at DESC - "#, + "SELECT * FROM tunnel_sessions WHERE disconnected_at IS NULL ORDER BY connected_at DESC", ) .fetch_all(pool) .await @@ -119,11 +116,15 @@ mod tests { .unwrap(); let user = setup_user(&pool).await; - let session = create(&pool, user.id, "test-tunnel", None).await.unwrap(); + let ip: IpNetwork = "192.168.1.1".parse::().unwrap().into(); + let session = create(&pool, user.id, "test-tunnel", Some(ip)) + .await + .unwrap(); assert_eq!(session.tunnel_id, "test-tunnel"); assert!(session.disconnected_at.is_none()); assert_eq!(session.bytes_in, 0); + assert!(session.client_ip.is_some()); let disconnected = disconnect(&pool, session.id, 1024, 2048, 10) .await diff --git a/crates/funnel-server/src/error.rs b/crates/funnel-server/src/error.rs index 74c9906..88a056f 100644 --- a/crates/funnel-server/src/error.rs +++ b/crates/funnel-server/src/error.rs @@ -10,6 +10,7 @@ struct ApiErrorBody { } #[derive(Debug, thiserror::Error)] +#[allow(dead_code)] pub enum AppError { #[error("tunnel not found: {0}")] TunnelNotFound(String), diff --git a/crates/funnel-server/src/tunnel/connection.rs b/crates/funnel-server/src/tunnel/connection.rs index 0a05a63..c9f4f15 100644 --- a/crates/funnel-server/src/tunnel/connection.rs +++ b/crates/funnel-server/src/tunnel/connection.rs @@ -38,6 +38,7 @@ impl ActiveTunnel { self.connected_at } + #[allow(dead_code)] pub fn is_alive(&self) -> bool { !self.cancel.is_cancelled() } diff --git a/crates/funnel-server/src/tunnel/stats.rs b/crates/funnel-server/src/tunnel/stats.rs index d90738f..b9a26a7 100644 --- a/crates/funnel-server/src/tunnel/stats.rs +++ b/crates/funnel-server/src/tunnel/stats.rs @@ -28,6 +28,7 @@ impl TunnelStats { self.bytes_in.fetch_add(n, Ordering::Relaxed); } + #[allow(dead_code)] pub fn add_bytes_out(&self, n: u64) { self.bytes_out.fetch_add(n, Ordering::Relaxed); } From fc8e6992d4669a05ae880bdeb5849a0c076d5cd0 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sat, 2 May 2026 12:00:00 +0200 Subject: [PATCH 14/42] chore: remove go source code in favor of rust rewrite --- client/client.go | 74 -- client/client_test.go | 377 ------- client/config.go | 214 ---- client/connection.go | 87 -- client/go.mod | 18 - client/go.sum | 20 - client/request_handler.go | 364 ------- client/request_handler_header_test.go | 294 ----- client/runner.go | 121 --- cmd/funnel/go.mod | 28 - cmd/funnel/go.sum | 30 - cmd/funnel/main.go | 253 ----- cmd/server/go.mod | 225 ---- cmd/server/go.sum | 1430 ------------------------- cmd/server/main.go | 310 ------ server/api.go | 700 ------------ server/auth.go | 240 ----- server/auth_test.go | 288 ----- server/certs.go | 569 ---------- server/certs_test.go | 176 --- server/docs/docs.go | 972 ----------------- server/docs/swagger.json | 952 ---------------- server/docs/swagger.yaml | 658 ------------ server/go.mod | 214 ---- server/go.sum | 1424 ------------------------ server/router.go | 550 ---------- server/router_bench_test.go | 96 -- server/router_header_test.go | 266 ----- server/server.go | 76 -- server/statistics.go | 242 ----- server/swagger.go | 30 - server/tunnel.go | 411 ------- server/tunnel_test.go | 239 ----- server/websocket.go | 137 --- server/websocket_test.go | 214 ---- shared/go.mod | 11 - shared/go.sum | 15 - shared/logging.go | 81 -- shared/message.go | 12 - shared/message_test.go | 383 ------- shared/utils.go | 188 ---- shared/utils_test.go | 540 ---------- version/go.mod | 3 - version/version.go | 57 - 44 files changed, 13589 deletions(-) delete mode 100644 client/client.go delete mode 100644 client/client_test.go delete mode 100644 client/config.go delete mode 100644 client/connection.go delete mode 100644 client/go.mod delete mode 100644 client/go.sum delete mode 100644 client/request_handler.go delete mode 100644 client/request_handler_header_test.go delete mode 100644 client/runner.go delete mode 100644 cmd/funnel/go.mod delete mode 100644 cmd/funnel/go.sum delete mode 100644 cmd/funnel/main.go delete mode 100644 cmd/server/go.mod delete mode 100644 cmd/server/go.sum delete mode 100644 cmd/server/main.go delete mode 100644 server/api.go delete mode 100644 server/auth.go delete mode 100644 server/auth_test.go delete mode 100644 server/certs.go delete mode 100644 server/certs_test.go delete mode 100644 server/docs/docs.go delete mode 100644 server/docs/swagger.json delete mode 100644 server/docs/swagger.yaml delete mode 100644 server/go.mod delete mode 100644 server/go.sum delete mode 100644 server/router.go delete mode 100644 server/router_bench_test.go delete mode 100644 server/router_header_test.go delete mode 100644 server/server.go delete mode 100644 server/statistics.go delete mode 100644 server/swagger.go delete mode 100644 server/tunnel.go delete mode 100644 server/tunnel_test.go delete mode 100644 server/websocket.go delete mode 100644 server/websocket_test.go delete mode 100644 shared/go.mod delete mode 100644 shared/go.sum delete mode 100644 shared/logging.go delete mode 100644 shared/message.go delete mode 100644 shared/message_test.go delete mode 100644 shared/utils.go delete mode 100644 shared/utils_test.go delete mode 100644 version/go.mod delete mode 100644 version/version.go diff --git a/client/client.go b/client/client.go deleted file mode 100644 index 54b464c..0000000 --- a/client/client.go +++ /dev/null @@ -1,74 +0,0 @@ -package client - -import ( - "context" - "sync" - "time" - - "github.com/gorilla/websocket" - "github.com/karol-broda/funnel/shared" -) - -type Client struct { - TunnelID string - ServerURL string - LocalAddr string - Token string - Conn *websocket.Conn - connMu sync.Mutex - lastPong time.Time - lastPongMu sync.Mutex - ongoingRequests map[string]context.CancelFunc - ongoingRequestsMu sync.Mutex - requestSemaphore chan struct{} - outgoingMessages chan *shared.Message - closeOnce sync.Once - requestWg sync.WaitGroup - ctx context.Context - cancel context.CancelFunc -} - -func New(tunnelID, serverURL, localAddr, token string) *Client { - ctx, cancel := context.WithCancel(context.Background()) - return &Client{ - TunnelID: tunnelID, - ServerURL: serverURL, - LocalAddr: localAddr, - Token: token, - ongoingRequests: make(map[string]context.CancelFunc), - requestSemaphore: make(chan struct{}, 128), - outgoingMessages: make(chan *shared.Message, 100), - lastPong: time.Now(), - ctx: ctx, - cancel: cancel, - } -} - -func (c *Client) Close() { - c.closeOnce.Do(func() { - logger := shared.GetTunnelLogger("client", c.TunnelID) - logger.Debug().Msg("closing client connection") - - c.cancel() - - if c.Conn != nil { - c.Conn.Close() - } - - if c.outgoingMessages != nil { - close(c.outgoingMessages) - } - }) -} - -func (c *Client) updateLastPong() { - c.lastPongMu.Lock() - defer c.lastPongMu.Unlock() - c.lastPong = time.Now() -} - -func (c *Client) getLastPong() time.Time { - c.lastPongMu.Lock() - defer c.lastPongMu.Unlock() - return c.lastPong -} diff --git a/client/client_test.go b/client/client_test.go deleted file mode 100644 index 7fea9fa..0000000 --- a/client/client_test.go +++ /dev/null @@ -1,377 +0,0 @@ -package client - -import ( - "context" - "net/http" - "net/url" - "strings" - "testing" - - "github.com/karol-broda/funnel/shared" -) - -func TestClientConnectionHandling(t *testing.T) { - tests := []struct { - name string - serverURL string - expectError bool - }{ - { - name: "valid http URL", - serverURL: "http://localhost:8080", - expectError: false, - }, - { - name: "valid https URL", - serverURL: "https://example.com", - expectError: false, - }, - { - name: "invalid URL", - serverURL: "invalid-url", - expectError: true, - }, - { - name: "empty URL", - serverURL: "", - expectError: true, - }, - { - name: "URL with port", - serverURL: "http://localhost:9000", - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := &Client{ - ServerURL: tt.serverURL, - LocalAddr: "localhost:3000", - TunnelID: "test-tunnel", - } - - err := client.connect(context.Background()) - - if tt.expectError { - if err == nil { - t.Error("expected error but got none") - } - return - } - - if err != nil { - if _, parseErr := url.Parse(tt.serverURL); parseErr != nil { - t.Errorf("URL parsing failed: %v", parseErr) - } - } - }) - } -} - -func TestClientURLConstruction(t *testing.T) { - tests := []struct { - name string - serverURL string - tunnelID string - expectedScheme string - expectedHost string - expectedQuery string - }{ - { - name: "http to ws", - serverURL: "http://localhost:8080", - tunnelID: "test123", - expectedScheme: "ws", - expectedHost: "localhost:8080", - expectedQuery: "id=test123", - }, - { - name: "https to wss", - serverURL: "https://tunnel.example.com", - tunnelID: "secure-tunnel", - expectedScheme: "wss", - expectedHost: "tunnel.example.com", - expectedQuery: "id=secure-tunnel", - }, - { - name: "http with path", - serverURL: "http://localhost:8080/api", - tunnelID: "path-test", - expectedScheme: "ws", - expectedHost: "localhost:8080", - expectedQuery: "id=path-test", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - u, err := url.Parse(tt.serverURL) - if err != nil { - t.Fatalf("failed to parse server URL: %v", err) - } - - wsScheme := "ws" - if u.Scheme == "https" { - wsScheme = "wss" - } - - if wsScheme != tt.expectedScheme { - t.Errorf("expected scheme %s, got %s", tt.expectedScheme, wsScheme) - } - - if u.Host != tt.expectedHost { - t.Errorf("expected host %s, got %s", tt.expectedHost, u.Host) - } - - expectedURL := wsScheme + "://" + u.Host + "/?id=" + tt.tunnelID - wsURL := expectedURL - - if !strings.Contains(wsURL, tt.expectedQuery) { - t.Errorf("expected query %s in URL %s", tt.expectedQuery, wsURL) - } - }) - } -} - -func TestClientHeaderFiltering(t *testing.T) { - client := &Client{ - TunnelID: "test-headers", - } - - tests := []struct { - name string - inputHeaders map[string][]string - expectedSkipped []string - expectedKept []string - }{ - { - name: "forward x-headers and proxy headers", - inputHeaders: map[string][]string{ - "X-Real-IP": {"192.168.1.1"}, - "X-Forwarded-For": {"10.0.0.1"}, - "Content-Type": {"application/json"}, - "Accept": {"*/*"}, - }, - expectedSkipped: []string{}, - expectedKept: []string{"X-Real-IP", "X-Forwarded-For", "Content-Type", "Accept"}, - }, - { - name: "skip connection-specific headers", - inputHeaders: map[string][]string{ - "Connection": {"keep-alive"}, - "Upgrade": {"websocket"}, - "User-Agent": {"test-client"}, - "Content-Type": {"application/json"}, - }, - expectedSkipped: []string{"Connection", "Upgrade"}, - expectedKept: []string{"User-Agent", "Content-Type"}, - }, - { - name: "keep standard headers and forward proxy headers", - inputHeaders: map[string][]string{ - "Content-Type": {"text/plain"}, - "Content-Length": {"42"}, - "Accept": {"text/html"}, - "User-Agent": {"test-browser"}, - "X-Forwarded-Host": {"example.com"}, - "X-Forwarded-Proto": {"https"}, - }, - expectedSkipped: []string{}, - expectedKept: []string{"Content-Type", "Content-Length", "Accept", "User-Agent", "X-Forwarded-Host", "X-Forwarded-Proto"}, - }, - { - name: "handle host header specially", - inputHeaders: map[string][]string{ - "Host": {"tunnel.example.com"}, - "Content-Type": {"application/json"}, - "Accept": {"*/*"}, - }, - expectedSkipped: []string{}, - expectedKept: []string{"Content-Type", "Accept"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req, err := http.NewRequest("GET", "http://localhost:3000/test", nil) - if err != nil { - t.Fatalf("failed to create request: %v", err) - } - - client.setRequestHeaders(req, tt.inputHeaders) - - for _, header := range tt.expectedSkipped { - if values := req.Header.Values(header); len(values) > 0 { - t.Errorf("header %s should be skipped but found values: %v", header, values) - } - } - - for _, header := range tt.expectedKept { - if values := req.Header.Values(header); len(values) == 0 { - t.Errorf("header %s should be kept but not found", header) - } - } - - if tt.inputHeaders["Host"] != nil { - if req.Host != client.LocalAddr { - t.Errorf("Host should be set to local address %s, but got %s", client.LocalAddr, req.Host) - } - } - }) - } -} - -func TestClientErrorHandling(t *testing.T) { - tests := []struct { - name string - requestID string - expectedStatus int - expectedBody string - }{ - { - name: "internal server error", - requestID: "test-500", - expectedStatus: 500, - expectedBody: "Internal Server Error", - }, - { - name: "bad gateway", - requestID: "test-502", - expectedStatus: 502, - expectedBody: "Bad Gateway", - }, - { - name: "service unavailable", - requestID: "test-503", - expectedStatus: 503, - expectedBody: "Service Unavailable", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - response := shared.Message{ - Type: "response", - RequestID: tt.requestID, - Status: tt.expectedStatus, - Headers: map[string][]string{ - "Content-Type": {"text/plain"}, - }, - Body: []byte(http.StatusText(tt.expectedStatus)), - } - - if response.Type != "response" { - t.Errorf("expected response type, got %s", response.Type) - } - - if response.RequestID != tt.requestID { - t.Errorf("expected request ID %s, got %s", tt.requestID, response.RequestID) - } - - if response.Status != tt.expectedStatus { - t.Errorf("expected status %d, got %d", tt.expectedStatus, response.Status) - } - - if string(response.Body) != tt.expectedBody { - t.Errorf("expected body %s, got %s", tt.expectedBody, string(response.Body)) - } - }) - } -} - -func TestClientConfiguration(t *testing.T) { - tests := []struct { - name string - serverURL string - localAddr string - tunnelID string - valid bool - }{ - { - name: "valid configuration", - serverURL: "http://localhost:8080", - localAddr: "localhost:3000", - tunnelID: "test-tunnel", - valid: true, - }, - { - name: "empty server URL", - serverURL: "", - localAddr: "localhost:3000", - tunnelID: "test-tunnel", - valid: false, - }, - { - name: "empty local address", - serverURL: "http://localhost:8080", - localAddr: "", - tunnelID: "test-tunnel", - valid: false, - }, - { - name: "empty tunnel ID", - serverURL: "http://localhost:8080", - localAddr: "localhost:3000", - tunnelID: "", - valid: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := &Client{ - ServerURL: tt.serverURL, - LocalAddr: tt.localAddr, - TunnelID: tt.tunnelID, - } - - // validate configuration - valid := client.ServerURL != "" && client.LocalAddr != "" && client.TunnelID != "" - - if valid != tt.valid { - t.Errorf("expected valid=%v, got valid=%v", tt.valid, valid) - } - }) - } -} - -func BenchmarkClientHeaderFiltering(b *testing.B) { - client := &Client{TunnelID: "bench-test"} - - headers := map[string][]string{ - "X-Real-IP": {"192.168.1.1"}, - "X-Forwarded-For": {"10.0.0.1"}, - "X-Forwarded-Proto": {"https"}, - "Host": {"example.com"}, - "Connection": {"keep-alive"}, - "Content-Type": {"application/json"}, - "Accept": {"application/json"}, - "User-Agent": {"test-client/1.0"}, - "Authorization": {"Bearer token123"}, - "Content-Length": {"42"}, - } - - b.ReportAllocs() - for i := 0; i < b.N; i++ { - req, _ := http.NewRequest("GET", "http://localhost:3000/test", nil) - client.setRequestHeaders(req, headers) - } -} - -func BenchmarkClientURLConstruction(b *testing.B) { - client := &Client{ - ServerURL: "http://localhost:8080", - TunnelID: "benchmark-tunnel", - } - - b.ReportAllocs() - for i := 0; i < b.N; i++ { - u, _ := url.Parse(client.ServerURL) - wsScheme := "ws" - if u.Scheme == "https" { - wsScheme = "wss" - } - _ = wsScheme + "://" + u.Host + "/?id=" + client.TunnelID - } -} diff --git a/client/config.go b/client/config.go deleted file mode 100644 index 1ea25a9..0000000 --- a/client/config.go +++ /dev/null @@ -1,214 +0,0 @@ -package client - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/BurntSushi/toml" -) - -type Config struct { - Inlets map[string]Inlet `toml:"inlets"` -} - -// Inlet represents a tunnel server configuration -type Inlet struct { - Server string `toml:"server"` - Domain string `toml:"domain,omitempty"` - Token string `toml:"token,omitempty"` -} - -// ConfigManager handles configuration loading and management -type ConfigManager struct { - configPath string - config *Config -} - -// NewConfigManager creates a new configuration manager -func NewConfigManager() *ConfigManager { - return &ConfigManager{} -} - -// getConfigPath determines the configuration file path -func (cm *ConfigManager) getConfigPath() string { - if cm.configPath != "" { - return cm.configPath - } - - // try XDG config directory first - if configDir := os.Getenv("XDG_CONFIG_HOME"); configDir != "" { - cm.configPath = filepath.Join(configDir, "funnel", "config.toml") - return cm.configPath - } - - // fallback to ~/.config/funnel/config.toml - if homeDir, err := os.UserHomeDir(); err == nil { - configDir := filepath.Join(homeDir, ".config", "funnel") - cm.configPath = filepath.Join(configDir, "config.toml") - return cm.configPath - } - - // if all else fails, use current directory - cm.configPath = "funnel.toml" - return cm.configPath -} - -// LoadConfig loads the configuration from file -func (cm *ConfigManager) LoadConfig() (*Config, error) { - configPath := cm.getConfigPath() - - // check if config file exists - if _, err := os.Stat(configPath); os.IsNotExist(err) { - return nil, fmt.Errorf("config file not found at %s", configPath) - } - - // read and parse existing config - data, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err) - } - - config := &Config{ - Inlets: make(map[string]Inlet), - } - - if err := toml.Unmarshal(data, config); err != nil { - return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err) - } - - cm.config = config - return config, nil -} - -// SaveConfig saves the configuration to file -func (cm *ConfigManager) SaveConfig(config *Config) error { - configPath := cm.getConfigPath() - - // ensure the directory exists - configDir := filepath.Dir(configPath) - if err := os.MkdirAll(configDir, 0755); err != nil { - return fmt.Errorf("failed to create config directory %s: %w", configDir, err) - } - - // marshal config to TOML - data, err := toml.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal config to TOML: %w", err) - } - - // write to file with restricted permissions (contains token) - if err := os.WriteFile(configPath, data, 0600); err != nil { - return fmt.Errorf("failed to write config file %s: %w", configPath, err) - } - - cm.config = config - return nil -} - -// GetInlet retrieves a specific inlet configuration by name -func (cm *ConfigManager) GetInlet(name string) (*Inlet, error) { - if cm.config == nil { - if _, err := cm.LoadConfig(); err != nil { - return nil, err - } - } - - inlet, exists := cm.config.Inlets[name] - if !exists { - return nil, fmt.Errorf("inlet '%s' not found in configuration", name) - } - - if inlet.Server == "" { - return nil, fmt.Errorf("inlet '%s' has no server configured", name) - } - - return &inlet, nil -} - -// GetDefaultInlet retrieves the default inlet configuration -func (cm *ConfigManager) GetDefaultInlet() (*Inlet, error) { - return cm.GetInlet("default") -} - -// ListInlets returns all available inlet names -func (cm *ConfigManager) ListInlets() ([]string, error) { - if cm.config == nil { - if _, err := cm.LoadConfig(); err != nil { - return nil, err - } - } - - inlets := make([]string, 0, len(cm.config.Inlets)) - for name := range cm.config.Inlets { - inlets = append(inlets, name) - } - - return inlets, nil -} - -// ValidateConfig validates the loaded configuration -func (cm *ConfigManager) ValidateConfig() error { - if cm.config == nil { - return fmt.Errorf("no configuration loaded") - } - - if len(cm.config.Inlets) == 0 { - return fmt.Errorf("no inlets configured") - } - - for name, inlet := range cm.config.Inlets { - if inlet.Server == "" { - return fmt.Errorf("inlet '%s' has no server configured", name) - } - } - - return nil -} - -// SetToken sets the token for a specific inlet, creating the inlet if it doesn't exist -func (cm *ConfigManager) SetToken(inletName, token string) error { - if cm.config == nil { - cm.config = &Config{Inlets: make(map[string]Inlet)} - cm.LoadConfig() - if cm.config == nil { - cm.config = &Config{Inlets: make(map[string]Inlet)} - } - } - - inlet := cm.config.Inlets[inletName] - inlet.Token = token - cm.config.Inlets[inletName] = inlet - - return cm.SaveConfig(cm.config) -} - -// SetServer sets the server URL for a specific inlet, creating the inlet if it doesn't exist -func (cm *ConfigManager) SetServer(inletName, server string) error { - if cm.config == nil { - cm.config = &Config{Inlets: make(map[string]Inlet)} - cm.LoadConfig() - if cm.config == nil { - cm.config = &Config{Inlets: make(map[string]Inlet)} - } - } - - inlet := cm.config.Inlets[inletName] - inlet.Server = server - cm.config.Inlets[inletName] = inlet - - return cm.SaveConfig(cm.config) -} - -// GetConfigPath returns the path to the config file -func (cm *ConfigManager) GetConfigPath() string { - return cm.getConfigPath() -} - -// GetConfig returns the loaded config, loading it if necessary -func (cm *ConfigManager) GetConfig() *Config { - if cm.config == nil { - cm.LoadConfig() - } - return cm.config -} diff --git a/client/connection.go b/client/connection.go deleted file mode 100644 index a2ddb56..0000000 --- a/client/connection.go +++ /dev/null @@ -1,87 +0,0 @@ -package client - -import ( - "context" - "net/http" - "net/url" - "time" - - "github.com/karol-broda/funnel/shared" - - "github.com/gorilla/websocket" -) - -func (c *Client) connect(ctx context.Context) error { - logger := shared.GetTunnelLogger("client.connection", c.TunnelID) - logger.Debug().Msg("starting connection process") - - connectionStart := time.Now() - - u, err := url.Parse(c.ServerURL) - if err != nil { - logger.Error().Err(err).Msg("failed to parse server url") - return err - } - - wsScheme := "ws" - if u.Scheme == "https" { - wsScheme = "wss" - } - - u.Scheme = wsScheme - u.RawQuery = "id=" + c.TunnelID - wsURL := u.String() - - logger.Debug().Str("websocket_url", wsURL).Msg("constructed websocket URL") - - dialer := *websocket.DefaultDialer - dialer.HandshakeTimeout = 15 * time.Second - dialer.EnableCompression = true - dialer.ReadBufferSize = 65536 - dialer.WriteBufferSize = 65536 - - logger.Debug(). - Dur("handshake_timeout", dialer.HandshakeTimeout). - Int("read_buffer_size", dialer.ReadBufferSize). - Int("write_buffer_size", dialer.WriteBufferSize). - Bool("compression_enabled", dialer.EnableCompression). - Msg("websocket dialer configured") - - var headers http.Header - if c.Token != "" { - headers = http.Header{} - headers.Set("Authorization", "Bearer "+c.Token) - logger.Debug().Msg("authentication token configured") - } - - dialStart := time.Now() - conn, resp, err := dialer.DialContext(ctx, wsURL, headers) - dialDuration := time.Since(dialStart) - - if err != nil { - logger.Error().Err(err). - Str("websocket_url", wsURL). - Dur("dial_duration", dialDuration). - Msg("websocket connection failed") - if resp != nil { - logger.Error().Int("http_status", resp.StatusCode).Msg("websocket upgrade response status") - } - return err - } - - totalConnectTime := time.Since(connectionStart) - logger.Info(). - Dur("dial_time", dialDuration). - Dur("total_connect_time", totalConnectTime). - Msg("websocket connection established") - - logger.Debug(). - Int("http_status", resp.StatusCode). - Msg("websocket upgrade successful") - - c.Conn = conn - c.connMu.Lock() - c.lastPong = time.Now() - c.connMu.Unlock() - return nil -} diff --git a/client/go.mod b/client/go.mod deleted file mode 100644 index 698249f..0000000 --- a/client/go.mod +++ /dev/null @@ -1,18 +0,0 @@ -module github.com/karol-broda/funnel/client - -go 1.24.4 - -require ( - github.com/BurntSushi/toml v1.5.0 - github.com/gorilla/websocket v1.5.3 - github.com/karol-broda/funnel/shared v0.0.0-00010101000000-000000000000 -) - -require ( - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/rs/zerolog v1.34.0 // indirect - golang.org/x/sys v0.32.0 // indirect -) - -replace github.com/karol-broda/funnel/shared => ../shared diff --git a/client/go.sum b/client/go.sum deleted file mode 100644 index 772dea6..0000000 --- a/client/go.sum +++ /dev/null @@ -1,20 +0,0 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/client/request_handler.go b/client/request_handler.go deleted file mode 100644 index c1f1739..0000000 --- a/client/request_handler.go +++ /dev/null @@ -1,364 +0,0 @@ -package client - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "strings" - "time" - - "github.com/gorilla/websocket" - "github.com/karol-broda/funnel/shared" -) - -func (c *Client) readPump() { - defer func() { - c.requestWg.Wait() - c.Close() - }() - logger := shared.GetTunnelLogger("client.handler", c.TunnelID) - - logger.Info().Msg("starting request handler loop") - - transport := &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 5, - IdleConnTimeout: 30 * time.Second, - MaxConnsPerHost: 10, - } - - httpClient := &http.Client{ - Timeout: 30 * time.Second, - Transport: transport, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - } - - logger.Debug(). - Dur("http_timeout", httpClient.Timeout). - Int("max_conns_per_host", 10). - Int("max_idle_conns", 10). - Msg("http client configured") - - c.setupHeartbeat() - - requestCount := 0 - for { - select { - case <-c.ctx.Done(): - logger.Info().Msg("read pump shutting down due to context cancellation") - return - default: - var msg shared.Message - readStart := time.Now() - err := c.Conn.ReadJSON(&msg) - readDuration := time.Since(readStart) - - if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { - logger.Error().Err(err).Dur("read_duration", readDuration).Msg("unexpected websocket read error") - } else { - logger.Info().Dur("read_duration", readDuration).Msg("websocket connection closed gracefully") - } - return - } - - logger.Debug().Dur("message_read_time", readDuration).Str("message_type", msg.Type).Msg("received message from server") - - switch msg.Type { - case "request": - requestCount++ - logger.Info(). - Int("request_count", requestCount). - Str("request_id", msg.RequestID). - Str("method", msg.Method). - Str("path", msg.Path). - Int("body_size", len(msg.Body)). - Msg("received request from server") - - c.requestWg.Add(1) - go func(m shared.Message) { - defer c.requestWg.Done() - c.processRequest(httpClient, m) - }(msg) - case "request_cancel": - logger.Info().Str("request_id", msg.RequestID).Msg("received request cancellation") - c.ongoingRequestsMu.Lock() - if cancel, ok := c.ongoingRequests[msg.RequestID]; ok { - cancel() - delete(c.ongoingRequests, msg.RequestID) - } - c.ongoingRequestsMu.Unlock() - default: - logger.Debug().Str("message_type", msg.Type).Msg("ignoring unhandled message type") - } - } - } -} - -func (c *Client) setupHeartbeat() { - logger := shared.GetTunnelLogger("client.handler", c.TunnelID) - - c.Conn.SetPongHandler(func(string) error { - c.updateLastPong() - logger.Debug().Msg("pong received from server") - return nil - }) - - go func() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-c.ctx.Done(): - logger.Debug().Msg("context cancelled, stopping ping routine") - return - case <-ticker.C: - if c.Conn == nil { - logger.Debug().Msg("connection is nil, stopping ping routine") - return - } - - lastPong := c.getLastPong() - if time.Since(lastPong) > 90*time.Second { - logger.Warn(). - Dur("time_since_last_pong", time.Since(lastPong)). - Msg("no pong received recently, connection may be stale") - c.Close() - return - } - - select { - case c.outgoingMessages <- &shared.Message{Type: "ping"}: - logger.Debug().Msg("ping message queued") - case <-c.ctx.Done(): - logger.Debug().Msg("context cancelled while queuing ping, stopping ping routine") - return - default: - logger.Warn().Msg("outgoing message channel full, cannot queue ping") - } - } - } - }() - - logger.Debug().Msg("heartbeat mechanism setup completed") -} - -func (c *Client) writePump() { - defer func() { - c.Close() - }() - logger := shared.GetTunnelLogger("client.handler", c.TunnelID) - logger.Info().Msg("starting writer loop") - - for msg := range c.outgoingMessages { - if msg.Type == "ping" { - if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { - logger.Error().Err(err).Msg("failed to send ping") - return - } - logger.Debug().Msg("ping sent to server") - continue - } - - c.connMu.Lock() - err := c.Conn.WriteJSON(msg) - c.connMu.Unlock() - - if err != nil { - logger.Error().Err(err).Msg("failed to write json message") - return - } - - if msg.RequestID != "" { - logger.Debug(). - Str("request_id", msg.RequestID). - Str("type", msg.Type). - Msg("message sent to server") - } - } - logger.Info().Msg("writer loop finished") -} - -func (c *Client) processRequest(httpClient *http.Client, msg shared.Message) { - logger := shared.GetRequestLogger("client.handler", c.TunnelID, msg.RequestID) - - select { - case c.requestSemaphore <- struct{}{}: - defer func() { <-c.requestSemaphore }() - case <-c.ctx.Done(): - logger.Warn().Msg("shutting down, not processing new request") - return - } - - reqCtx, cancel := context.WithTimeout(c.ctx, 30*time.Second) - defer cancel() - - c.ongoingRequestsMu.Lock() - c.ongoingRequests[msg.RequestID] = cancel - c.ongoingRequestsMu.Unlock() - - defer func() { - c.ongoingRequestsMu.Lock() - delete(c.ongoingRequests, msg.RequestID) - c.ongoingRequestsMu.Unlock() - }() - - processStart := time.Now() - logger.Debug(). - Str("method", msg.Method). - Str("path", msg.Path). - Int("header_count", len(msg.Headers)). - Int("body_size", len(msg.Body)). - Int("concurrent_requests", len(c.requestSemaphore)). - Msg("processing request") - - req, err := http.NewRequestWithContext(reqCtx, msg.Method, "http://"+c.LocalAddr+msg.Path, bytes.NewReader(msg.Body)) - if err != nil { - logger.Error().Err(err).Msg("failed to create request") - if reqCtx.Err() == nil { - c.sendError(msg.RequestID, http.StatusInternalServerError, "failed to create request") - } - return - } - - c.setRequestHeaders(req, msg.Headers) - - logger.Debug(). - Int("concurrent_requests", len(c.requestSemaphore)). - Int("header_count", len(req.Header)). - Str("local_url", req.URL.String()). - Msg("forwarding request to local service") - - upstreamResponseTimeStart := time.Now() - resp, err := httpClient.Do(req) - - if err != nil { - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - logger.Warn().Err(err).Msg("request to local service was canceled or timed out") - return - } - logger.Error().Err(err).Msg("failed to make request to local service") - c.sendError(msg.RequestID, http.StatusBadGateway, "local service connection failed") - return - } - defer resp.Body.Close() - - logger.Debug(). - Int("status_code", resp.StatusCode). - Dur("upstream_response_time", time.Since(upstreamResponseTimeStart)). - Int64("content_length", resp.ContentLength). - Msg("received response from local service") - - bodyReadStart := time.Now() - body, err := io.ReadAll(resp.Body) - bodyReadDuration := time.Since(bodyReadStart) - - if err != nil { - logger.Error().Err(err). - Dur("body_read_duration", bodyReadDuration). - Msg("failed to read response body") - c.sendError(msg.RequestID, http.StatusBadGateway, "failed to read response body") - return - } - - totalProcessTime := time.Since(processStart) - logger.Info(). - Int("status_code", resp.StatusCode). - Int("response_size", len(body)). - Dur("total_process_time", totalProcessTime). - Dur("upstream_time", time.Since(upstreamResponseTimeStart)). - Dur("body_read_time", bodyReadDuration). - Msg("request processed successfully") - - select { - case <-c.ctx.Done(): - logger.Info().Str("request_id", msg.RequestID).Msg("shutting down, not sending response") - return - default: - c.sendResponse(msg.RequestID, resp.StatusCode, resp.Header, body) - } -} - -func (c *Client) setRequestHeaders(req *http.Request, headers map[string][]string) { - req.Header = make(http.Header) - - for k, values := range headers { - if c.shouldSkipHeader(k) { - continue - } - if strings.ToLower(k) == "host" { - continue - } - for _, value := range values { - req.Header.Add(k, value) - } - } - req.Host = c.LocalAddr -} - -func (c *Client) shouldSkipHeader(headerName string) bool { - lower := strings.ToLower(headerName) - return lower == "connection" || - lower == "keep-alive" || - lower == "proxy-authenticate" || - lower == "proxy-authorization" || - lower == "te" || - lower == "trailer" || - lower == "transfer-encoding" || - lower == "upgrade" || - lower == "proxy-connection" -} - -func (c *Client) sendResponse(requestID string, status int, headers http.Header, body []byte) { - logger := shared.GetRequestLogger("client.handler", c.TunnelID, requestID) - - respHeaders := make(map[string][]string) - for k, v := range headers { - if !c.shouldSkipHeader(k) { - respHeaders[k] = v - } - } - - respMsg := &shared.Message{ - Type: "response", - RequestID: requestID, - Status: status, - Headers: respHeaders, - Body: body, - } - - select { - case c.outgoingMessages <- respMsg: - sendDuration := time.Since(time.Now()) - logger.Debug(). - Int("status_code", status). - Int("header_count", len(respHeaders)). - Int("response_size", len(body)). - Dur("send_duration", sendDuration). - Msg("response queued successfully") - default: - logger.Warn().Msg("outgoing message channel full, dropping response") - } -} - -func (c *Client) sendError(requestID string, statusCode int, error string) { - logger := shared.GetRequestLogger("client.handler", c.TunnelID, requestID) - logger.Error().Int("status_code", statusCode).Str("error", error).Msg("sending error response") - errMessage := &shared.Message{ - Type: "response", - RequestID: requestID, - Status: statusCode, - Headers: http.Header{"Content-Type": []string{"text/plain"}}, - Body: []byte(error), - } - select { - case c.outgoingMessages <- errMessage: - default: - logger.Warn().Int("status", statusCode).Msg("outgoing message channel full, dropping error response") - } -} diff --git a/client/request_handler_header_test.go b/client/request_handler_header_test.go deleted file mode 100644 index b58876d..0000000 --- a/client/request_handler_header_test.go +++ /dev/null @@ -1,294 +0,0 @@ -package client - -import ( - "net/http/httptest" - "testing" -) - -func TestClient_shouldSkipHeader(t *testing.T) { - client := &Client{ - TunnelID: "test-tunnel", - LocalAddr: "localhost:3000", - } - - tests := []struct { - name string - headerName string - shouldSkip bool - }{ - { - name: "skip Connection header", - headerName: "Connection", - shouldSkip: true, - }, - { - name: "skip connection header lowercase", - headerName: "connection", - shouldSkip: true, - }, - { - name: "skip Upgrade header", - headerName: "Upgrade", - shouldSkip: true, - }, - { - name: "skip upgrade header lowercase", - headerName: "upgrade", - shouldSkip: true, - }, - { - name: "skip Proxy-Connection header", - headerName: "Proxy-Connection", - shouldSkip: true, - }, - { - name: "skip Proxy-Authenticate header", - headerName: "Proxy-Authenticate", - shouldSkip: true, - }, - { - name: "skip Proxy-Authorization header", - headerName: "Proxy-Authorization", - shouldSkip: true, - }, - { - name: "skip TE header", - headerName: "TE", - shouldSkip: true, - }, - { - name: "skip Trailer header", - headerName: "Trailer", - shouldSkip: true, - }, - { - name: "skip Transfer-Encoding header", - headerName: "Transfer-Encoding", - shouldSkip: true, - }, - { - name: "forward User-Agent header", - headerName: "User-Agent", - shouldSkip: false, - }, - { - name: "forward X-Forwarded-For header", - headerName: "X-Forwarded-For", - shouldSkip: false, - }, - { - name: "forward X-Forwarded-Host header", - headerName: "X-Forwarded-Host", - shouldSkip: false, - }, - { - name: "forward X-Forwarded-Proto header", - headerName: "X-Forwarded-Proto", - shouldSkip: false, - }, - { - name: "forward X-Real-IP header", - headerName: "X-Real-IP", - shouldSkip: false, - }, - { - name: "forward Content-Type header", - headerName: "Content-Type", - shouldSkip: false, - }, - { - name: "forward Accept header", - headerName: "Accept", - shouldSkip: false, - }, - { - name: "forward Authorization header", - headerName: "Authorization", - shouldSkip: false, - }, - { - name: "forward custom header", - headerName: "X-Custom-Header", - shouldSkip: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := client.shouldSkipHeader(tt.headerName) - if result != tt.shouldSkip { - t.Errorf("shouldSkipHeader(%s) = %v, want %v", tt.headerName, result, tt.shouldSkip) - } - }) - } -} - -func TestClient_setRequestHeaders(t *testing.T) { - tests := []struct { - name string - inputHeaders map[string][]string - localAddr string - expectedSkipped []string - expectedForwarded []string - expectedHost string - }{ - { - name: "forward proxy headers", - inputHeaders: map[string][]string{ - "X-Forwarded-For": {"192.168.1.100"}, - "X-Forwarded-Host": {"api.example.com"}, - "X-Forwarded-Proto": {"https"}, - "X-Real-IP": {"203.0.113.45"}, - "User-Agent": {"TestClient/1.0"}, - "Accept": {"application/json"}, - }, - localAddr: "localhost:8080", - expectedSkipped: []string{}, - expectedForwarded: []string{"X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Real-IP", "User-Agent", "Accept"}, - expectedHost: "localhost:8080", - }, - { - name: "skip connection headers", - inputHeaders: map[string][]string{ - "Connection": {"keep-alive"}, - "Upgrade": {"websocket"}, - "Transfer-Encoding": {"chunked"}, - "TE": {"trailers"}, - "User-Agent": {"TestClient/1.0"}, - "Content-Type": {"application/json"}, - }, - localAddr: "localhost:3000", - expectedSkipped: []string{"Connection", "Upgrade", "Transfer-Encoding", "TE"}, - expectedForwarded: []string{"User-Agent", "Content-Type"}, - expectedHost: "localhost:3000", - }, - { - name: "handle Host header specially", - inputHeaders: map[string][]string{ - "Host": {"tunnel.example.com"}, - "User-Agent": {"TestClient/1.0"}, - "Content-Type": {"application/json"}, - }, - localAddr: "localhost:9000", - expectedSkipped: []string{}, - expectedForwarded: []string{"User-Agent", "Content-Type"}, - expectedHost: "localhost:9000", - }, - { - name: "mixed headers with proxy and connection headers", - inputHeaders: map[string][]string{ - "X-Forwarded-For": {"192.168.1.100, 10.0.0.1"}, - "X-Forwarded-Host": {"api.example.com"}, - "Connection": {"close"}, - "Proxy-Connection": {"keep-alive"}, - "User-Agent": {"TestClient/1.0"}, - "Authorization": {"Bearer token123"}, - "Content-Type": {"application/json"}, - "X-Custom-Header": {"custom-value"}, - "Transfer-Encoding": {"gzip"}, - }, - localAddr: "localhost:5000", - expectedSkipped: []string{"Connection", "Proxy-Connection", "Transfer-Encoding"}, - expectedForwarded: []string{"X-Forwarded-For", "X-Forwarded-Host", "User-Agent", "Authorization", "Content-Type", "X-Custom-Header"}, - expectedHost: "localhost:5000", - }, - { - name: "no Host header provided", - inputHeaders: map[string][]string{ - "User-Agent": {"TestClient/1.0"}, - "Content-Type": {"application/json"}, - }, - localAddr: "localhost:4000", - expectedSkipped: []string{}, - expectedForwarded: []string{"User-Agent", "Content-Type"}, - expectedHost: "localhost:4000", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := &Client{ - TunnelID: "test-headers", - LocalAddr: tt.localAddr, - } - - req := httptest.NewRequest("GET", "http://"+tt.localAddr+"/test", nil) - - client.setRequestHeaders(req, tt.inputHeaders) - - for _, skippedHeader := range tt.expectedSkipped { - if values := req.Header.Values(skippedHeader); len(values) > 0 { - t.Errorf("Header %s should be skipped but found values: %v", skippedHeader, values) - } - } - - for _, forwardedHeader := range tt.expectedForwarded { - if values := req.Header.Values(forwardedHeader); len(values) == 0 { - t.Errorf("Header %s should be forwarded but not found", forwardedHeader) - } - } - - if req.Host != tt.expectedHost { - t.Errorf("Host header = %s, want %s", req.Host, tt.expectedHost) - } - - if tt.inputHeaders["X-Forwarded-For"] != nil { - if forwardedFor := req.Header.Get("X-Forwarded-For"); forwardedFor != tt.inputHeaders["X-Forwarded-For"][0] { - t.Errorf("X-Forwarded-For = %s, want %s", forwardedFor, tt.inputHeaders["X-Forwarded-For"][0]) - } - } - }) - } -} - -func TestClient_setRequestHeaders_HeaderValues(t *testing.T) { - client := &Client{ - TunnelID: "test-values", - LocalAddr: "localhost:3000", - } - - tests := []struct { - name string - inputHeaders map[string][]string - headerToTest string - expectedValue string - }{ - { - name: "single value header", - inputHeaders: map[string][]string{ - "Authorization": {"Bearer token123"}, - }, - headerToTest: "Authorization", - expectedValue: "Bearer token123", - }, - { - name: "multiple value header - first value", - inputHeaders: map[string][]string{ - "Accept": {"application/json", "text/html"}, - }, - headerToTest: "Accept", - expectedValue: "application/json", - }, - { - name: "forwarded header preservation", - inputHeaders: map[string][]string{ - "X-Forwarded-Proto": {"https"}, - }, - headerToTest: "X-Forwarded-Proto", - expectedValue: "https", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "http://localhost:3000/test", nil) - - client.setRequestHeaders(req, tt.inputHeaders) - - actualValue := req.Header.Get(tt.headerToTest) - if actualValue != tt.expectedValue { - t.Errorf("Header %s = %s, want %s", tt.headerToTest, actualValue, tt.expectedValue) - } - }) - } -} diff --git a/client/runner.go b/client/runner.go deleted file mode 100644 index a531af7..0000000 --- a/client/runner.go +++ /dev/null @@ -1,121 +0,0 @@ -package client - -import ( - "context" - "fmt" - "math" - "net/url" - "strings" - "sync" - "time" - - "github.com/karol-broda/funnel/shared" -) - -const maxReconnectDelay = 30 * time.Second - -func Run(tunnelID, serverURL, localAddr, token string, shutdown <-chan struct{}) { - logger := shared.GetTunnelLogger("client.runner", tunnelID) - logger.Info().Str("local_addr", localAddr).Str("server_url", serverURL).Bool("has_token", token != "").Msg("starting tunnel client with reconnection logic") - - reconnectAttempts := 0 - - for { - select { - case <-shutdown: - logger.Info().Msg("shutdown signal received, stopping client runner.") - return - default: - // continue - } - - c := New(tunnelID, serverURL, localAddr, token) - - logger.Info().Int("attempt", reconnectAttempts+1).Msg("attempting to connect to server") - err := c.connect(c.ctx) - - if err != nil { - errorCategory := categorizeConnectionError(err) - logger.Error().Err(err).Str("error_category", errorCategory).Msg("connection failed") - - select { - case <-shutdown: - continue - case <-time.After(getReconnectDelay(reconnectAttempts)): - reconnectAttempts++ - continue - } - } - - logger.Info().Msg("connected successfully") - reconnectAttempts = 0 - - if u, err := url.Parse(c.ServerURL); err != nil { - logger.Error().Err(err).Msg("failed to parse server url for public url display") - } else { - publicURL := fmt.Sprintf("http://%s.%s", c.TunnelID, u.Host) - logger.Info().Str("public_url", publicURL).Msg("tunnel is available") - } - - var wg sync.WaitGroup - wg.Add(2) - - runCtx, cancelRun := context.WithCancel(context.Background()) - - go func() { - defer wg.Done() - c.readPump() - cancelRun() - }() - - go func() { - defer wg.Done() - c.writePump() - cancelRun() - }() - - select { - case <-shutdown: - logger.Info().Msg("shutdown during active connection, closing.") - c.Close() - case <-runCtx.Done(): - logger.Warn().Msg("connection lost, will attempt to reconnect.") - } - - wg.Wait() - c.Close() - } -} - -func getReconnectDelay(attempts int) time.Duration { - delay := time.Duration(math.Pow(2, float64(attempts))) * time.Second - if delay > maxReconnectDelay { - return maxReconnectDelay - } - return delay -} - -func categorizeConnectionError(err error) string { - if err == nil { - return "unknown" - } - - errStr := strings.ToLower(err.Error()) - - switch { - case strings.Contains(errStr, "timeout"): - return "timeout" - case strings.Contains(errStr, "refused"): - return "connection_refused" - case strings.Contains(errStr, "no such host"): - return "dns_resolution" - case strings.Contains(errStr, "network"): - return "network_error" - case strings.Contains(errStr, "tls"): - return "tls_error" - case strings.Contains(errStr, "websocket"): - return "websocket_error" - default: - return "other" - } -} diff --git a/cmd/funnel/go.mod b/cmd/funnel/go.mod deleted file mode 100644 index fb2c473..0000000 --- a/cmd/funnel/go.mod +++ /dev/null @@ -1,28 +0,0 @@ -module github.com/karol-broda/funnel/cmd/funnel - -go 1.24.4 - -require ( - github.com/karol-broda/funnel/client v0.0.0-00010101000000-000000000000 - github.com/karol-broda/funnel/shared v0.0.0-00010101000000-000000000000 - github.com/karol-broda/funnel/version v0.0.0-00010101000000-000000000000 -) - -require github.com/BurntSushi/toml v1.5.0 // indirect - -require ( - github.com/gorilla/websocket v1.5.3 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/rs/zerolog v1.34.0 - github.com/spf13/cobra v1.9.1 - github.com/spf13/pflag v1.0.6 // indirect - golang.org/x/sys v0.32.0 // indirect -) - -replace github.com/karol-broda/funnel/client => ../../client - -replace github.com/karol-broda/funnel/shared => ../../shared - -replace github.com/karol-broda/funnel/version => ../../version diff --git a/cmd/funnel/go.sum b/cmd/funnel/go.sum deleted file mode 100644 index 1d397bb..0000000 --- a/cmd/funnel/go.sum +++ /dev/null @@ -1,30 +0,0 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/funnel/main.go b/cmd/funnel/main.go deleted file mode 100644 index 4feb095..0000000 --- a/cmd/funnel/main.go +++ /dev/null @@ -1,253 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/signal" - "path/filepath" - "strings" - "syscall" - - "github.com/karol-broda/funnel/client" - "github.com/karol-broda/funnel/shared" - "github.com/karol-broda/funnel/version" - "github.com/rs/zerolog" - "github.com/spf13/cobra" -) - -var ( - server string - local string - id string - inlet string - token string -) - -var rootCmd = &cobra.Command{ - Use: filepath.Base(os.Args[0]), - Short: "a tunnel client for creating secure tunnels", - Long: `a tunnel client that creates secure tunnels to expose local services through a remote server`, -} - -var httpCmd = &cobra.Command{ - Use: "http [address:port | port]", - Short: "create an HTTP tunnel", - Long: `create an HTTP tunnel to expose a local HTTP service through a remote server`, - Args: cobra.ExactArgs(1), - Run: runHTTPClient, -} - -var versionCmd = &cobra.Command{ - Use: "version", - Short: "print version information", - Run: func(cmd *cobra.Command, args []string) { - version.PrintVersionInfo("funnel-client") - }, -} - -var configCmd = &cobra.Command{ - Use: "config", - Short: "manage client configuration", -} - -var configSetTokenCmd = &cobra.Command{ - Use: "set-token ", - Short: "save authentication token to config file", - Args: cobra.ExactArgs(1), - Run: runConfigSetToken, -} - -var configSetServerCmd = &cobra.Command{ - Use: "set-server ", - Short: "save server URL to config file", - Args: cobra.ExactArgs(1), - Run: runConfigSetServer, -} - -var configShowCmd = &cobra.Command{ - Use: "show", - Short: "show current configuration", - Run: runConfigShow, -} - -var configPathCmd = &cobra.Command{ - Use: "path", - Short: "show config file path", - Run: runConfigPath, -} - -var configInlet string - -func init() { - httpCmd.Flags().StringVarP(&server, "server", "s", "", "tunnel server url (overrides config)") - httpCmd.Flags().StringVarP(&id, "id", "i", "", "tunnel id (subdomain)") - httpCmd.Flags().StringVarP(&inlet, "inlet", "", "default", "inlet configuration to use") - httpCmd.Flags().StringVarP(&token, "token", "t", "", "authentication token (overrides config)") - - configCmd.PersistentFlags().StringVar(&configInlet, "inlet", "default", "inlet to configure") - configCmd.AddCommand(configSetTokenCmd, configSetServerCmd, configShowCmd, configPathCmd) - - rootCmd.AddCommand(httpCmd) - rootCmd.AddCommand(versionCmd) - rootCmd.AddCommand(configCmd) -} - -func runHTTPClient(cmd *cobra.Command, args []string) { - shared.InitializeLogging(shared.DefaultLogConfig()) - - logger := shared.GetLogger("client") - - logger.Info(). - Str("version", version.GetVersion()). - Str("inlet", inlet). - Msg("tunnel client starting up") - - localArg := args[0] - if localArg == "" { - logger.Fatal().Msg("local address or port cannot be empty") - } - - if strings.Contains(localArg, ":") { - local = localArg - logger.Info().Str("local_address", local).Msg("using provided address:port") - } else { - local = "localhost:" + localArg - logger.Info().Str("port", localArg).Str("constructed_local", local).Msg("constructed local address from port") - } - - // resolve server URL and token from config or command line flags - finalServer, finalToken, err := resolveServerAndToken(logger) - if err != nil { - logger.Fatal().Err(err).Msg("failed to resolve server configuration") - } - - logger.Info().Str("server", finalServer).Bool("has_token", finalToken != "").Msg("using server configuration") - - if id == "" { - generatedID, err := shared.GenerateDomainSafeID() - if err != nil { - logger.Fatal().Err(err).Msg("failed to generate tunnel ID") - } - id = generatedID - logger.Info().Str("generated_id", id).Msg("generated tunnel ID") - } else { - logger.Info().Str("provided_id", id).Msg("using provided tunnel ID") - } - - logger.Info().Msg("client configuration validated, starting tunnel") - - shutdownChan := make(chan struct{}) - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-sigChan - logger.Info().Msg("received shutdown signal, initiating shutdown") - close(shutdownChan) - }() - - client.Run(id, finalServer, local, finalToken, shutdownChan) - - logger.Info().Msg("client has shut down") -} - -func resolveServerAndToken(logger zerolog.Logger) (string, string, error) { - var finalServer string - var finalToken string - - // try to load configuration - configManager := client.NewConfigManager() - inletConfig, err := configManager.GetInlet(inlet) - - // resolve server: command line flag takes precedence over config - if server != "" { - finalServer = server - logger.Info().Str("server", server).Msg("using server from command line flag") - } else if err == nil && inletConfig != nil { - finalServer = inletConfig.Server - logger.Info(). - Str("inlet", inlet). - Str("server", inletConfig.Server). - Str("domain", inletConfig.Domain). - Msg("using server from configuration") - } else { - logger.Error().Err(err).Str("inlet", inlet).Msg("failed to load inlet configuration") - return "", "", fmt.Errorf("no configuration found. please specify --server flag or create a config file at ~/.config/funnel/config.toml") - } - - // resolve token: command line flag takes precedence over config - if token != "" { - finalToken = token - logger.Info().Msg("using token from command line flag") - } else if err == nil && inletConfig != nil && inletConfig.Token != "" { - finalToken = inletConfig.Token - logger.Info().Str("inlet", inlet).Msg("using token from configuration") - } - - return finalServer, finalToken, nil -} - -func runConfigSetToken(cmd *cobra.Command, args []string) { - tokenValue := args[0] - - configManager := client.NewConfigManager() - if err := configManager.SetToken(configInlet, tokenValue); err != nil { - fmt.Fprintf(os.Stderr, "error: failed to save token: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Token saved to inlet %q\n", configInlet) - fmt.Printf(" config file: %s\n", configManager.GetConfigPath()) - fmt.Printf("\n Warning: token is stored in plain text in the config file.\n") - fmt.Printf(" Ensure the file has appropriate permissions (chmod 600).\n") -} - -func runConfigSetServer(cmd *cobra.Command, args []string) { - serverValue := args[0] - - configManager := client.NewConfigManager() - if err := configManager.SetServer(configInlet, serverValue); err != nil { - fmt.Fprintf(os.Stderr, "error: failed to save server: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Server saved to inlet %q\n", configInlet) - fmt.Printf(" config file: %s\n", configManager.GetConfigPath()) -} - -func runConfigShow(cmd *cobra.Command, args []string) { - configManager := client.NewConfigManager() - config := configManager.GetConfig() - - fmt.Printf("Config file: %s\n\n", configManager.GetConfigPath()) - - if config == nil || len(config.Inlets) == 0 { - fmt.Println("No inlets configured.") - return - } - - for name, inlet := range config.Inlets { - fmt.Printf("[%s]\n", name) - if inlet.Server != "" { - fmt.Printf(" server: %s\n", inlet.Server) - } - if inlet.Domain != "" { - fmt.Printf(" domain: %s\n", inlet.Domain) - } - if inlet.Token != "" { - fmt.Printf(" token: %s...\n", inlet.Token[:min(10, len(inlet.Token))]) - } - fmt.Println() - } -} - -func runConfigPath(cmd *cobra.Command, args []string) { - configManager := client.NewConfigManager() - fmt.Println(configManager.GetConfigPath()) -} - -func main() { - if err := rootCmd.Execute(); err != nil { - os.Exit(1) - } -} diff --git a/cmd/server/go.mod b/cmd/server/go.mod deleted file mode 100644 index dd7531f..0000000 --- a/cmd/server/go.mod +++ /dev/null @@ -1,225 +0,0 @@ -module github.com/karol-broda/funnel/cmd/server - -go 1.24.4 - -require ( - github.com/karol-broda/funnel/server v0.0.0-00010101000000-000000000000 - github.com/karol-broda/funnel/shared v0.0.0-00010101000000-000000000000 - github.com/karol-broda/funnel/version v0.0.0-20250808200607-dc36be944b32 -) - -require ( - cloud.google.com/go/auth v0.15.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect - cloud.google.com/go/compute/metadata v0.6.0 // indirect - github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect - github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect - github.com/Azure/go-autorest v14.2.0+incompatible // indirect - github.com/Azure/go-autorest/autorest v0.11.30 // indirect - github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect - github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect - github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect - github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect - github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect - github.com/Azure/go-autorest/logger v0.2.1 // indirect - github.com/Azure/go-autorest/tracing v0.6.0 // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 // indirect - github.com/KyleBanks/depth v1.2.1 // indirect - github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect - github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect - github.com/aliyun/alibaba-cloud-sdk-go v1.63.100 // indirect - github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect - github.com/aws/aws-sdk-go-v2/config v1.29.9 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.62 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.1 // indirect - github.com/aws/aws-sdk-go-v2/service/route53 v1.50.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 // indirect - github.com/aws/smithy-go v1.22.2 // indirect - github.com/baidubce/bce-sdk-go v0.9.223 // indirect - github.com/benbjohnson/clock v1.3.0 // indirect - github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/civo/civogo v0.3.11 // indirect - github.com/cloudflare/cloudflare-go v0.115.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dimchansky/utfbom v1.1.1 // indirect - github.com/dnsimple/dnsimple-go v1.7.0 // indirect - github.com/exoscale/egoscale/v3 v3.1.13 // indirect - github.com/fatih/structs v1.1.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.2 // indirect - github.com/ghodss/yaml v1.0.0 // indirect - github.com/go-acme/lego/v4 v4.23.1 // indirect - github.com/go-errors/errors v1.0.1 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/jsonreference v0.20.0 // indirect - github.com/go-openapi/spec v0.20.6 // indirect - github.com/go-openapi/swag v0.19.15 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.16.0 // indirect - github.com/go-resty/resty/v2 v2.16.5 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/goccy/go-json v0.10.5 // indirect - github.com/gofrs/flock v0.12.1 // indirect - github.com/golang-jwt/jwt/v4 v4.5.1 // indirect - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect - github.com/google/go-querystring v1.1.0 // indirect - github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.14.1 // indirect - github.com/gophercloud/gophercloud v1.14.1 // indirect - github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect - github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.141 // indirect - github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect - github.com/infobloxopen/infoblox-go-client/v2 v2.9.0 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect - github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect - github.com/kylelemons/godebug v1.1.0 // indirect - github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect - github.com/labbsr0x/goh v1.0.1 // indirect - github.com/leodido/go-urn v1.2.4 // indirect - github.com/linode/linodego v1.48.1 // indirect - github.com/liquidweb/liquidweb-cli v0.6.9 // indirect - github.com/liquidweb/liquidweb-go v1.6.4 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mailru/easyjson v0.7.6 // indirect - github.com/miekg/dns v1.1.64 // indirect - github.com/mimuret/golang-iij-dpf v0.9.1 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect - github.com/nrdcg/auroradns v1.1.0 // indirect - github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 // indirect - github.com/nrdcg/desec v0.10.0 // indirect - github.com/nrdcg/dnspod-go v0.4.0 // indirect - github.com/nrdcg/freemyip v0.3.0 // indirect - github.com/nrdcg/goacmedns v0.2.0 // indirect - github.com/nrdcg/goinwx v0.10.0 // indirect - github.com/nrdcg/mailinabox v0.2.0 // indirect - github.com/nrdcg/namesilo v0.2.1 // indirect - github.com/nrdcg/nodion v0.1.0 // indirect - github.com/nrdcg/porkbun v0.4.0 // indirect - github.com/nzdjb/go-metaname v1.0.0 // indirect - github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect - github.com/oracle/oci-go-sdk/v65 v65.87.0 // indirect - github.com/ovh/go-ovh v1.7.0 // indirect - github.com/patrickmn/go-cache v2.1.0+incompatible // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect - github.com/peterhellberg/link v1.2.0 // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/pquerna/otp v1.4.0 // indirect - github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect - github.com/sacloud/api-client-go v0.2.10 // indirect - github.com/sacloud/go-http v0.1.8 // indirect - github.com/sacloud/iaas-api-go v1.14.0 // indirect - github.com/sacloud/packages-go v0.0.10 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/scaleway/scaleway-sdk-go v1.0.0-beta.32 // indirect - github.com/selectel/domains-go v1.1.0 // indirect - github.com/selectel/go-selvpcclient/v3 v3.2.1 // indirect - github.com/shopspring/decimal v1.3.1 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect - github.com/softlayer/softlayer-go v1.1.7 // indirect - github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect - github.com/sony/gobreaker v0.5.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/viper v1.18.2 // indirect - github.com/stretchr/testify v1.10.0 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - github.com/swaggo/files/v2 v2.0.0 // indirect - github.com/swaggo/http-swagger/v2 v2.0.2 // indirect - github.com/swaggo/swag v1.16.6 // indirect - github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128 // indirect - github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128 // indirect - github.com/tjfoc/gmsm v1.4.1 // indirect - github.com/transip/gotransip/v6 v6.26.0 // indirect - github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec // indirect - github.com/vinyldns/go-vinyldns v0.9.16 // indirect - github.com/volcengine/volc-sdk-golang v1.0.199 // indirect - github.com/vultr/govultr/v3 v3.17.0 // indirect - github.com/yandex-cloud/go-genproto v0.0.0-20250319153614-fb9d3e5eb01a // indirect - github.com/yandex-cloud/go-sdk v0.0.0-20250320143332-9cbcfc5de4ae // indirect - go.mongodb.org/mongo-driver v1.13.1 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect - go.opentelemetry.io/otel v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - go.uber.org/ratelimit v0.3.0 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/oauth2 v0.28.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/text v0.24.0 // indirect - golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.32.0 // indirect - google.golang.org/api v0.227.0 // indirect - google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect - google.golang.org/grpc v1.71.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/ns1/ns1-go.v2 v2.13.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) - -require ( - github.com/gorilla/websocket v1.5.3 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/rs/zerolog v1.34.0 // indirect - github.com/spf13/cobra v1.9.1 - github.com/spf13/pflag v1.0.6 // indirect - golang.org/x/sys v0.32.0 // indirect -) - -replace github.com/karol-broda/funnel/server => ../../server - -replace github.com/karol-broda/funnel/shared => ../../shared - -replace github.com/karol-broda/funnel/version => ../../version diff --git a/cmd/server/go.sum b/cmd/server/go.sum deleted file mode 100644 index c6eee0e..0000000 --- a/cmd/server/go.sum +++ /dev/null @@ -1,1430 +0,0 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= -cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= -cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= -cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 h1:Dy3M9aegiI7d7PF1LUdjbVigJReo+QOceYsMyFh9qoE= -github.com/AdamSLevy/jsonrpc2/v14 v14.1.0/go.mod h1:ZakZtbCXxCz82NJvq7MoREtiQesnDfrtF6RFUGzQfLo= -github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= -github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 h1:zLzoX5+W2l95UJoVwiyNS4dX8vHyQ6x2xRLoBBL9wMk= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= -github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= -github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= -github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= -github.com/Azure/go-autorest/autorest v0.11.30 h1:iaZ1RGz/ALZtN5eq4Nr1SOFSlf2E4pDI3Tcsl+dZPVE= -github.com/Azure/go-autorest/autorest v0.11.30/go.mod h1:t1kpPIOpIVX7annvothKvb0stsrXa37i7b+xpmBW8Fs= -github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= -github.com/Azure/go-autorest/autorest/adal v0.9.22 h1:/GblQdIudfEM3AWWZ0mrYJQSd7JS4S/Mbzh6F0ov0Xc= -github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= -github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= -github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= -github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 h1:w77/uPk80ZET2F+AfQExZyEWtn+0Rk/uw17m9fv5Ajc= -github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= -github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= -github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= -github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= -github.com/Azure/go-autorest/autorest/mocks v0.4.2 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= -github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= -github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc= -github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M= -github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= -github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= -github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= -github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= -github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= -github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/HdrHistogram/hdrhistogram-go v1.1.0/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= -github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= -github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= -github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= -github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 h1:xPMsUicZ3iosVPSIP7bW5EcGUzjiiMl1OYTe14y/R24= -github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks= -github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= -github.com/Shopify/sarama v1.30.1/go.mod h1:hGgx05L/DiW8XYBXeJdKIN6V2QUy2H6JqME5VT1NLRw= -github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= -github.com/Shopify/toxiproxy/v2 v2.1.6-0.20210914104332-15ea381dcdae/go.mod h1:/cvHQkZ1fst0EmZnA5dFtiQdWCNCFYzb+uE2vqVgvx0= -github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= -github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= -github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= -github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 h1:F1j7z+/DKEsYqZNoxC6wvfmaiDneLsQOFQmuq9NADSY= -github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2/go.mod h1:QlXr/TrICfQ/ANa76sLeQyhAJyNR9sEcfNuZBkY9jgY= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/aliyun/alibaba-cloud-sdk-go v1.63.100 h1:yUkCbrSM1cWtgBfRVKMQtdt22KhDvKY7g4V+92eG9wA= -github.com/aliyun/alibaba-cloud-sdk-go v1.63.100/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= -github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= -github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/config v1.29.9 h1:Kg+fAYNaJeGXp1vmjtidss8O2uXIsXwaRqsQJKXVr+0= -github.com/aws/aws-sdk-go-v2/config v1.29.9/go.mod h1:oU3jj2O53kgOU4TXq/yipt6ryiooYjlkqqVaZk7gY/U= -github.com/aws/aws-sdk-go-v2/credentials v1.17.62 h1:fvtQY3zFzYJ9CfixuAQ96IxDrBajbBWGqjNTCa79ocU= -github.com/aws/aws-sdk-go-v2/credentials v1.17.62/go.mod h1:ElETBxIQqcxej++Cs8GyPBbgMys5DgQPTwo7cUPDKt8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.1 h1:0j58UseBtLuBcP6nY2z4SM1qZEvLF0ylyH6+ggnphLg= -github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.1/go.mod h1:Qy22QnQSdHbZwMZrarsWZBIuK51isPlkD+Z4sztxX0o= -github.com/aws/aws-sdk-go-v2/service/route53 v1.50.0 h1:/nkJHXtJXJeelXHqG0898+fWKgvfaXBhGzbCsSmn9j8= -github.com/aws/aws-sdk-go-v2/service/route53 v1.50.0/go.mod h1:kGYOjvTa0Vw0qxrqrOLut1vMnui6qLxqv/SX3vYeM8Y= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 h1:8JdC7Gr9NROg1Rusk25IcZeTO59zLxsKgE0gkh5O6h0= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.1/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1 h1:KwuLovgQPcdjNMfFt9OhUd9a2OwcOKhxfvF4glTzLuA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= -github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= -github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= -github.com/baidubce/bce-sdk-go v0.9.223 h1:vvDeIemf7ePPP59nLHCntQ/vS++ok2HKbRPgmz1VZKU= -github.com/baidubce/bce-sdk-go v0.9.223/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg= -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= -github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= -github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/c-bata/go-prompt v0.2.5/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw= -github.com/casbin/casbin/v2 v2.37.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg= -github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= -github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= -github.com/civo/civogo v0.3.11 h1:mON/fyrV946Sbk6paRtOSGsN+asCgCmHCgArf5xmGxM= -github.com/civo/civogo v0.3.11/go.mod h1:7+GeeFwc4AYTULaEshpT2vIcl3Qq8HPoxA17viX3l6g= -github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= -github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= -github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= -github.com/dnsimple/dnsimple-go v1.7.0 h1:JKu9xJtZ3SqOC+BuYgAWeab7+EEx0sz422vu8j611ZY= -github.com/dnsimple/dnsimple-go v1.7.0/go.mod h1:EKpuihlWizqYafSnQHGCd/gyvy3HkEQJ7ODB4KdV8T8= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= -github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= -github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/exoscale/egoscale/v3 v3.1.13 h1:CAGC7QRjp2AiGj01agsSD0VKCp4OZmW5f51vV2IguNQ= -github.com/exoscale/egoscale/v3 v3.1.13/go.mod h1:t9+MpSEam94na48O/xgvvPFpQPRiwZ3kBN4/UuQtKco= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= -github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= -github.com/franela/goblin v0.0.0-20210519012713-85d372ac71e2/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo= -github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= -github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-acme/lego/v4 v4.23.1 h1:lZ5fGtGESA2L9FB8dNTvrQUq3/X4QOb8ExkKyY7LSV4= -github.com/go-acme/lego/v4 v4.23.1/go.mod h1:7UMVR7oQbIYw6V7mTgGwi4Er7B6Ww0c+c8feiBM0EgI= -github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s= -github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= -github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= -github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= -github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= -github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= -github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= -github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= -github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA= -github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= -github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= -github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= -github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= -github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= -github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw= -github.com/gophercloud/gophercloud v1.14.1/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= -github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 h1:sH7xkTfYzxIEgzq1tDHIMKRh1vThOEOGNsettdEeLbE= -github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56/go.mod h1:VSalo4adEk+3sNkmVJLnhHoOyOYYS8sTWLG4mv5BKto= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= -github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= -github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= -github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= -github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= -github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.141 h1:8i57QAi5u+iPAYze92bkIvZoHiS0J45ndul5glr/NE8= -github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.141/go.mod h1:Y/+YLCFCJtS29i2MbYPTUlNNfwXvkzEsZKR0imY/2aY= -github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df h1:MZf03xP9WdakyXhOWuAD5uPK3wHh96wCsqe3hCMKh8E= -github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhKWFeDesPjMj+wCHReeknARU3wqlyN4= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/infobloxopen/infoblox-go-client/v2 v2.9.0 h1:wS8kTlQVeVbrepeY83s9X+XdSa6Qah5KO+tdW+zRQXU= -github.com/infobloxopen/infoblox-go-client/v2 v2.9.0/go.mod h1:NeNJpz09efw/edzqkVivGv1bWqBXTomqYBRFbP+XBqg= -github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= -github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= -github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= -github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= -github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= -github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= -github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= -github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= -github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= -github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= -github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= -github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= -github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= -github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= -github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= -github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/labbsr0x/bindman-dns-webhook v1.0.2 h1:I7ITbmQPAVwrDdhd6dHKi+MYJTJqPCK0jE6YNBAevnk= -github.com/labbsr0x/bindman-dns-webhook v1.0.2/go.mod h1:p6b+VCXIR8NYKpDr8/dg1HKfQoRHCdcsROXKvmoehKA= -github.com/labbsr0x/goh v1.0.1 h1:97aBJkDjpyBZGPbQuOK5/gHcSFbcr5aRsq3RSRJFpPk= -github.com/labbsr0x/goh v1.0.1/go.mod h1:8K2UhVoaWXcCU7Lxoa2omWnC8gyW8px7/lmO61c027w= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= -github.com/linode/linodego v1.48.1 h1:Ojw1S+K5jJr1dggO8/H6r4FINxXnJbOU5GkbpaTfmhU= -github.com/linode/linodego v1.48.1/go.mod h1:fc3t60If8X+yZTFAebhCnNDFrhwQhq9HDU92WnBousQ= -github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs= -github.com/liquidweb/liquidweb-cli v0.6.9 h1:acbIvdRauiwbxIsOCEMXGwF75aSJDbDiyAWPjVnwoYM= -github.com/liquidweb/liquidweb-cli v0.6.9/go.mod h1:cE1uvQ+x24NGUL75D0QagOFCG8Wdvmwu8aL9TLmA/eQ= -github.com/liquidweb/liquidweb-go v1.6.4 h1:6S0m3hHSpiLqGD7AFSb7lH/W/qr1wx+tKil9fgIbjMc= -github.com/liquidweb/liquidweb-go v1.6.4/go.mod h1:B934JPIIcdA+uTq2Nz5PgOtG6CuCaEvQKe/Ge/5GgZ4= -github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= -github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= -github.com/miekg/dns v1.1.47/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= -github.com/miekg/dns v1.1.64 h1:wuZgD9wwCE6XMT05UU/mlSko71eRSXEAm2EbjQXLKnQ= -github.com/miekg/dns v1.1.64/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= -github.com/mimuret/golang-iij-dpf v0.9.1 h1:Gj6EhHJkOhr+q2RnvRPJsPMcjuVnWPSccEHyoEehU34= -github.com/mimuret/golang-iij-dpf v0.9.1/go.mod h1:sl9KyOkESib9+KRD3HaGpgi1xk7eoN2+d96LCLsME2M= -github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= -github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= -github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 h1:o6uBwrhM5C8Ll3MAAxrQxRHEu7FkapwTuI2WmL1rw4g= -github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04/go.mod h1:5sN+Lt1CaY4wsPvgQH/jsuJi4XO2ssZbdsIizr4CVC8= -github.com/nats-io/jwt v1.2.2/go.mod h1:/xX356yQA6LuXI9xWW7mZNpxgF2mBmGecH+Fj34sP5Q= -github.com/nats-io/jwt/v2 v2.0.3/go.mod h1:VRP+deawSXyhNjXmxPCHskrR6Mq50BqpEI5SEcNiGlY= -github.com/nats-io/nats-server/v2 v2.5.0/go.mod h1:Kj86UtrXAL6LwYRA6H4RqzkHhK0Vcv2ZnKD5WbQ1t3g= -github.com/nats-io/nats.go v1.12.1/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w= -github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= -github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= -github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nrdcg/auroradns v1.1.0 h1:KekGh8kmf2MNwqZVVYo/fw/ZONt8QMEmbMFOeljteWo= -github.com/nrdcg/auroradns v1.1.0/go.mod h1:O7tViUZbAcnykVnrGkXzIJTHoQCHcgalgAe6X1mzHfk= -github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 h1:ouZ2JWDl8IW5k1qugYbmpbmW8hn85Ig6buSMBRlz3KI= -github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3/go.mod h1:ZwadWt7mVhMHMbAQ1w8IhDqtWO3eWqWq72W7trnaiE8= -github.com/nrdcg/desec v0.10.0 h1:qrEDiqnsvNU9QE7lXIXi/tIHAfyaFXKxF2/8/52O8uM= -github.com/nrdcg/desec v0.10.0/go.mod h1:5+4vyhMRTs49V9CNoODF/HwT8Mwxv9DJ6j+7NekUnBs= -github.com/nrdcg/dnspod-go v0.4.0 h1:c/jn1mLZNKF3/osJ6mz3QPxTudvPArXTjpkmYj0uK6U= -github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= -github.com/nrdcg/freemyip v0.3.0 h1:0D2rXgvLwe2RRaVIjyUcQ4S26+cIS2iFwnhzDsEuuwc= -github.com/nrdcg/freemyip v0.3.0/go.mod h1:c1PscDvA0ukBF0dwelU/IwOakNKnVxetpAQ863RMJoM= -github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0= -github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg= -github.com/nrdcg/goinwx v0.10.0 h1:6W630bjDxQD6OuXKqrFRYVpTt0G/9GXXm3CeOrN0zJM= -github.com/nrdcg/goinwx v0.10.0/go.mod h1:mnMSTi7CXBu2io4DzdOBoGFA1XclD0sEPWJaDhNgkA4= -github.com/nrdcg/mailinabox v0.2.0 h1:IKq8mfKiVwNW2hQii/ng1dJ4yYMMv3HAP3fMFIq2CFk= -github.com/nrdcg/mailinabox v0.2.0/go.mod h1:0yxqeYOiGyxAu7Sb94eMxHPIOsPYXAjTeA9ZhePhGnc= -github.com/nrdcg/namesilo v0.2.1 h1:kLjCjsufdW/IlC+iSfAqj0iQGgKjlbUUeDJio5Y6eMg= -github.com/nrdcg/namesilo v0.2.1/go.mod h1:lwMvfQTyYq+BbjJd30ylEG4GPSS6PII0Tia4rRpRiyw= -github.com/nrdcg/nodion v0.1.0 h1:zLKaqTn2X0aDuBHHfyA1zFgeZfiCpmu/O9DM73okavw= -github.com/nrdcg/nodion v0.1.0/go.mod h1:inbuh3neCtIWlMPZHtEpe43TmRXxHV6+hk97iCZicms= -github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw= -github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/nzdjb/go-metaname v1.0.0 h1:sNASlZC1RM3nSudtBTE1a3ZVTDyTpjqI5WXRPrdZ9Hg= -github.com/nzdjb/go-metaname v1.0.0/go.mod h1:0GR0LshZax1Lz4VrOrfNSE4dGvTp7HGjiemdczXT2H4= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.20.1 h1:YlVIbqct+ZmnEph770q9Q7NVAz4wwIiVNahee6JyUzo= -github.com/onsi/ginkgo/v2 v2.20.1/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= -github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= -github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= -github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= -github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= -github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE= -github.com/oracle/oci-go-sdk/v65 v65.87.0 h1:CeVuK8t0dYODGT3P9IDhz4vyXF8poYE1ijoiO5vrKl0= -github.com/oracle/oci-go-sdk/v65 v65.87.0/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0= -github.com/ovh/go-ovh v1.7.0 h1:V14nF7FwDjQrZt9g7jzcvAAQ3HN6DNShRFRMC3jLoPw= -github.com/ovh/go-ovh v1.7.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= -github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/performancecopilot/speed/v4 v4.0.0/go.mod h1:qxrSyuDGrTOWfV+uKRFhfxw6h/4HXRGUiZiufxo49BM= -github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= -github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= -github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= -github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= -github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= -github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= -github.com/regfish/regfish-dnsapi-go v0.1.1 h1:TJFtbePHkd47q5GZwYl1h3DIYXmoxdLjW/SBsPtB5IE= -github.com/regfish/regfish-dnsapi-go v0.1.1/go.mod h1:ubIgXSfqarSnl3XHSn8hIFwFF3h0yrq0ZiWD93Y2VjY= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sacloud/api-client-go v0.2.10 h1:+rv3jDohD+pkdYwOTBiB+jZsM0xK3AxadXRzhp3q66c= -github.com/sacloud/api-client-go v0.2.10/go.mod h1:Jj3CTy2+O4bcMedVDXlbHuqqche85HEPuVXoQFhLaRc= -github.com/sacloud/go-http v0.1.8 h1:ynreWA/vnM8G2ksbMlmefBHsXURKPz49qlPRqQ9IQdw= -github.com/sacloud/go-http v0.1.8/go.mod h1:7TL7TN1fnPKHsMifIqURDkGujnKViCgEz5Ei/LQdFK8= -github.com/sacloud/iaas-api-go v1.14.0 h1:xjkFWqdo4ilTrKPNNYBNWR/CZ/kVRsJrdAHAad6J/AQ= -github.com/sacloud/iaas-api-go v1.14.0/go.mod h1:C8os2Mnj0TOmMdSllwhaDWKMVG2ysFnpe69kyA4M3V0= -github.com/sacloud/packages-go v0.0.10 h1:UiQGjy8LretewkRhsuna1TBM9Vz/l9FoYpQx+D+AOck= -github.com/sacloud/packages-go v0.0.10/go.mod h1:f8QITBh9z4IZc4yE9j21Q8b0sXEMwRlRmhhjWeDVTYs= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.32 h1:4+LP7qmsLSGbmc66m1s5dKRMBwztRppfxFKlYqYte/c= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.32/go.mod h1:kzh+BSAvpoyHHdHBCDhmSWtBc1NbLMZ2lWHqnBoxFks= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/selectel/domains-go v1.1.0 h1:futG50J43ALLKQAnZk9H9yOtLGnSUh7c5hSvuC5gSHo= -github.com/selectel/domains-go v1.1.0/go.mod h1:SugRKfq4sTpnOHquslCpzda72wV8u0cMBHx0C0l+bzA= -github.com/selectel/go-selvpcclient/v3 v3.2.1 h1:ny6WIAMiHzKxOgOEnwcWE79wIQij1AHHylzPA41MXCw= -github.com/selectel/go-selvpcclient/v3 v3.2.1/go.mod h1:3EfSf8aEWyhspOGbvZ6mvnFg7JN5uckxNyBFPGWsXNQ= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= -github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= -github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 h1:hp2CYQUINdZMHdvTdXtPOY2ainKl4IoMcpAXEf2xj3Q= -github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/smartystreets/gunit v1.0.4 h1:tpTjnuH7MLlqhoD21vRoMZbMIi5GmBsAJDFyF67GhZA= -github.com/smartystreets/gunit v1.0.4/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ= -github.com/softlayer/softlayer-go v1.1.7 h1:SgTL+pQZt1h+5QkAhVmHORM/7N9c1X0sljJhuOIHxWE= -github.com/softlayer/softlayer-go v1.1.7/go.mod h1:WeJrBLoTJcaT8nO1azeyHyNpo/fDLtbpbvh+pzts+Qw= -github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e h1:3OgWYFw7jxCZPcvAg+4R8A50GZ+CCkARF10lxu2qDsQ= -github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e/go.mod h1:fKZCUVdirrxrBpwd9wb+lSoVixvpwAu8eHzbQB2tums= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= -github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= -github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/afero v1.4.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= -github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= -github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= -github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= -github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ= -github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= -github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128 h1:NGnqDc8FQL0YdiCHgTO4Wkso6ToD8rE3JW9VOzoPBNA= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128 h1:mrJ5Fbkd7sZIJ5F6oRfh5zebPQaudPH9Y0+GUmFytYU= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128/go.mod h1:zbsYIBT+VTX4z4ocjTAdLBIWyNYj3z0BRqd0iPdnjsk= -github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= -github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/transip/gotransip/v6 v6.26.0 h1:Aejfvh8rSp8Mj2GX/RpdBjMCv+Iy/DmgfNgczPDP550= -github.com/transip/gotransip/v6 v6.26.0/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s= -github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= -github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= -github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= -github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= -github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= -github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec h1:2s/ghQ8wKE+UzD/hf3P4Gd1j0JI9ncbxv+nsypPoUYI= -github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec/go.mod h1:BZr7Qs3ku1ckpqed8tCRSqTlp8NAeZfAVpfx4OzXMss= -github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= -github.com/vinyldns/go-vinyldns v0.9.16 h1:GZJStDkcCk1F1AcRc64LuuMh+ENL8pHA0CVd4ulRMcQ= -github.com/vinyldns/go-vinyldns v0.9.16/go.mod h1:5qIJOdmzAnatKjurI+Tl4uTus7GJKJxb+zitufjHs3Q= -github.com/volcengine/volc-sdk-golang v1.0.199 h1:zv9QOqTl/IsLwtfC37GlJtcz6vMAHi+pjq8ILWjLYUc= -github.com/volcengine/volc-sdk-golang v1.0.199/go.mod h1:stZX+EPgv1vF4nZwOlEe8iGcriUPRBKX8zA19gXycOQ= -github.com/vultr/govultr/v3 v3.17.0 h1:His5Jh5N8KKqaJxfy3uG6jQbLXy0TmQhNxOiRvkKk00= -github.com/vultr/govultr/v3 v3.17.0/go.mod h1:q34Wd76upKmf+vxFMgaNMH3A8BbsPBmSYZUGC8oZa5w= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= -github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= -github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/yandex-cloud/go-genproto v0.0.0-20250319153614-fb9d3e5eb01a h1:YO8gGyAV4N5SR3NzloZ1128IahSpXWr78oU7aEe7f04= -github.com/yandex-cloud/go-genproto v0.0.0-20250319153614-fb9d3e5eb01a/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= -github.com/yandex-cloud/go-sdk v0.0.0-20250320143332-9cbcfc5de4ae h1:x+uGuST05LVlgCxF5TsP8kQCCTW7uIeAQJ1dKtSmWqE= -github.com/yandex-cloud/go-sdk v0.0.0-20250320143332-9cbcfc5de4ae/go.mod h1:V71iJlJnS/NtNNdg/B7SwccBS19aXxwY3fv/wut9D74= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0= -go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= -go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -go.uber.org/ratelimit v0.3.0 h1:IdZd9wqvFXnvLvSEBo0KPcGfkoBGNkpTHlrE3Rcjkjw= -go.uber.org/ratelimit v0.3.0/go.mod h1:So5LG7CV1zWpY1sHe+DXTJqQvOx+FFPFaAs2SnoyBaI= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210920023735-84f357641f63/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU= -golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= -golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= -golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201110211018-35f3e6cf4a65/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= -gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= -gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc= -google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 h1:Q3nlH8iSQSRUwOskjbcSMcF2jiYMNiQYZ0c2KEJLKKU= -google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38/go.mod h1:xBI+tzfqGGN2JBeSebfKXFSdBpWVQ7sLW40PTupVRm4= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= -gopkg.in/h2non/gock.v1 v1.0.15 h1:SzLqcIlb/fDfg7UvukMpNcWsu7sI5tWwL+KCATZqks0= -gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= -gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/ns1/ns1-go.v2 v2.13.0 h1:I5NNqI9Bi1SGK92TVkOvLTwux5LNrix/99H2datVh48= -gopkg.in/ns1/ns1-go.v2 v2.13.0/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/cmd/server/main.go b/cmd/server/main.go deleted file mode 100644 index 4e0ccab..0000000 --- a/cmd/server/main.go +++ /dev/null @@ -1,310 +0,0 @@ -// Package main provides the main entry point for the funnel server. -// -// @title Funnel Server API -// @version 1.0 -// @description REST API for tunnel management and monitoring in the funnel tunneling solution -// @description -// @description This API provides endpoints for: -// @description - Health checking -// @description - Server statistics and monitoring -// @description - Tunnel management (list, view, delete) -// @description - Individual tunnel statistics -// -// @contact.name Funnel Support -// @contact.url https://github.com/karol-broda/funnel -// -// @license.name MIT -// @license.url https://github.com/karol-broda/funnel/blob/master/LICENSE.md -// -// @host localhost:8080 -// @BasePath /api -// -// @schemes http https -// -// @tag.name Server -// @tag.description Server health and statistics endpoints -// -// @tag.name Tunnels -// @tag.description Tunnel management and monitoring endpoints -package main - -import ( - "crypto/tls" - "fmt" - "net/http" - "os" - "runtime" - "time" - - "github.com/karol-broda/funnel/server" - "github.com/karol-broda/funnel/shared" - "github.com/karol-broda/funnel/version" - "github.com/spf13/cobra" -) - -var ( - port int - tlsPort int - host string - enableTls bool - certDir string - letsEncryptEmail string - dnsProvidersConfig string - tokenStorePath string - requireAuth bool -) - -func getDefaultCertDir() string { - if runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" { - return "/var/lib/funnel/certs" - } - return "./certs" -} - -func getDefaultTokenStorePath() string { - if runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" { - return "/var/lib/funnel/tokens.json" - } - return "./tokens.json" -} - -func main() { - rootCmd := &cobra.Command{ - Use: "server", - Short: "funnel server", - Run: runServer, - } - versionCmd := &cobra.Command{ - Use: "version", - Short: "print version information", - Run: func(cmd *cobra.Command, args []string) { - version.PrintVersionInfo("tunnel-server") - }, - } - - tokenCmd := &cobra.Command{ - Use: "token", - Short: "manage authentication tokens", - } - - tokenCreateCmd := &cobra.Command{ - Use: "create", - Short: "create a new authentication token", - Run: runTokenCreate, - } - tokenCreateCmd.Flags().StringVar(&tokenName, "name", "", "name for the token (required)") - tokenCreateCmd.MarkFlagRequired("name") - - tokenListCmd := &cobra.Command{ - Use: "list", - Short: "list all active tokens", - Run: runTokenList, - } - - tokenRevokeCmd := &cobra.Command{ - Use: "revoke", - Short: "revoke a token", - Run: runTokenRevoke, - } - tokenRevokeCmd.Flags().StringVar(&tokenName, "name", "", "name of the token to revoke (required)") - tokenRevokeCmd.MarkFlagRequired("name") - - tokenCmd.AddCommand(tokenCreateCmd, tokenListCmd, tokenRevokeCmd) - tokenCmd.PersistentFlags().StringVar(&tokenStorePath, "token-store", getDefaultTokenStorePath(), "path to token store file") - - rootCmd.PersistentFlags().IntVarP(&port, "port", "p", 8080, "port to listen on for http") - rootCmd.PersistentFlags().IntVar(&tlsPort, "tls-port", 8443, "port to listen on for https") - rootCmd.PersistentFlags().StringVar(&host, "host", "0.0.0.0", "host to listen on") - rootCmd.PersistentFlags().BoolVar(&enableTls, "enable-tls", false, "enable tls with let's encrypt") - rootCmd.PersistentFlags().StringVar(&certDir, "cert-dir", getDefaultCertDir(), "directory to store tls certificates") - rootCmd.PersistentFlags().StringVar(&letsEncryptEmail, "letsencrypt-email", "", "email address for let's encrypt") - rootCmd.PersistentFlags().StringVar(&dnsProvidersConfig, "dns-providers-config", "", "path to dns providers config file") - rootCmd.PersistentFlags().StringVar(&tokenStorePath, "token-store", getDefaultTokenStorePath(), "path to token store file") - rootCmd.PersistentFlags().BoolVar(&requireAuth, "require-auth", false, "require authentication for tunnel connections") - rootCmd.AddCommand(versionCmd, tokenCmd) - - if err := rootCmd.Execute(); err != nil { - fmt.Println(err) - os.Exit(1) - } -} - -var tokenName string - -func runTokenCreate(cmd *cobra.Command, args []string) { - shared.InitializeLogging(shared.DefaultLogConfig()) - - storePath := cmd.Flag("token-store").Value.String() - tokenStore, err := server.NewTokenStore(storePath) - if err != nil { - fmt.Fprintf(os.Stderr, "error: failed to load token store: %v\n", err) - os.Exit(1) - } - - plainToken, err := tokenStore.Create(tokenName) - if err != nil { - fmt.Fprintf(os.Stderr, "error: failed to create token: %v\n", err) - os.Exit(1) - } - - fmt.Printf("\nToken created for %q\n\n", tokenName) - fmt.Printf(" %s\n\n", plainToken) - fmt.Printf(" Save this token now - it will NOT be shown again.\n") - fmt.Printf(" Token is persisted to disk and survives server restarts.\n\n") -} - -func runTokenList(cmd *cobra.Command, args []string) { - shared.InitializeLogging(shared.DefaultLogConfig()) - - storePath := cmd.Flag("token-store").Value.String() - tokenStore, err := server.NewTokenStore(storePath) - if err != nil { - fmt.Fprintf(os.Stderr, "error: failed to load token store: %v\n", err) - os.Exit(1) - } - - tokens := tokenStore.List() - if len(tokens) == 0 { - fmt.Println("No active tokens found.") - return - } - - fmt.Printf("\n%-20s %-12s %s\n", "NAME", "PREFIX", "CREATED") - fmt.Printf("%-20s %-12s %s\n", "----", "------", "-------") - for _, t := range tokens { - age := formatAge(t.CreatedAt) - fmt.Printf("%-20s %-12s %s\n", t.Name, t.Prefix+"...", age) - } - fmt.Println() -} - -func runTokenRevoke(cmd *cobra.Command, args []string) { - shared.InitializeLogging(shared.DefaultLogConfig()) - - storePath := cmd.Flag("token-store").Value.String() - tokenStore, err := server.NewTokenStore(storePath) - if err != nil { - fmt.Fprintf(os.Stderr, "error: failed to load token store: %v\n", err) - os.Exit(1) - } - - if err := tokenStore.Revoke(tokenName); err != nil { - fmt.Fprintf(os.Stderr, "error: failed to revoke token: %v\n", err) - os.Exit(1) - } - - fmt.Printf("Token %q revoked.\n", tokenName) -} - -func formatAge(t time.Time) string { - d := time.Since(t) - switch { - case d < time.Minute: - return "just now" - case d < time.Hour: - return fmt.Sprintf("%d minutes ago", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%d hours ago", int(d.Hours())) - case d < 7*24*time.Hour: - return fmt.Sprintf("%d days ago", int(d.Hours()/24)) - default: - return t.Format("2006-01-02") - } -} - -func runServer(cmd *cobra.Command, args []string) { - logger := shared.GetLogger("server.main") - - s := server.NewServer() - tunnelRouter := server.NewTunnelRouter(s) - s.SetRouter(tunnelRouter) - - // Initialize API handler - apiHandler := server.NewAPIHandler(s, tunnelRouter) - tunnelRouter.SetAPIHandler(apiHandler) - - // Initialize token store if authentication is enabled - if requireAuth { - tokenStore, err := server.NewTokenStore(tokenStorePath) - if err != nil { - logger.Fatal().Err(err).Str("path", tokenStorePath).Msg("failed to initialize token store") - } - s.SetTokenStore(tokenStore) - - if tokenStore.Count() == 0 { - logger.Warn().Msg("authentication enabled but no tokens exist - run 'server token create --name ' to create one") - } - } else { - logger.Warn().Msg("authentication disabled - anyone can create tunnels. use --require-auth to enable") - } - - if enableTls { - if letsEncryptEmail == "" || dnsProvidersConfig == "" { - logger.Fatal().Msg("--letsencrypt-email and --dns-providers-config must be set when --enable-tls is true") - } - - logger.Info(). - Str("email", letsEncryptEmail). - Str("cert_dir", certDir). - Str("dns_config", dnsProvidersConfig). - Msg("tls is enabled, initializing certificate manager") - - certManager, err := server.NewCertificateManager(letsEncryptEmail, certDir, dnsProvidersConfig) - if err != nil { - logger.Fatal().Err(err).Msg("failed to initialize certificate manager") - } - - if err := certManager.PreloadCertificates(); err != nil { - logger.Fatal().Err(err).Msg("failed to preload certificates") - } - - tlsConfig := &tls.Config{ - GetCertificate: certManager.GetCertificate, - MinVersion: tls.VersionTLS12, - } - - httpsAddr := fmt.Sprintf("%s:%d", host, tlsPort) - httpsServer := &http.Server{ - Addr: httpsAddr, - Handler: tunnelRouter, - TLSConfig: tlsConfig, - } - - logger.Info().Str("address", httpsAddr).Msg("starting https server") - go func() { - if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { - logger.Fatal().Err(err).Msg("https server failed") - } - }() - - httpAddr := fmt.Sprintf("%s:%d", host, port) - httpServer := &http.Server{ - Addr: httpAddr, - Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "https://"+r.Host+r.URL.String(), http.StatusMovedPermanently) - }), - } - logger.Info().Str("address", httpAddr).Msg("starting http to https redirect server") - go func() { - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Fatal().Err(err).Msg("http redirect server failed") - } - }() - - } else { - httpAddr := fmt.Sprintf("%s:%d", host, port) - httpServer := &http.Server{ - Addr: httpAddr, - Handler: tunnelRouter, - } - logger.Info().Str("address", httpAddr).Msg("starting http server") - go func() { - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Fatal().Err(err).Msg("http server failed") - } - }() - } - - // wait indefinitely - select {} -} diff --git a/server/api.go b/server/api.go deleted file mode 100644 index b6eea63..0000000 --- a/server/api.go +++ /dev/null @@ -1,700 +0,0 @@ -package server - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - "time" - - "github.com/karol-broda/funnel/server/docs" - "github.com/karol-broda/funnel/shared" - "github.com/swaggo/swag" -) - -// APIResponse represents a standard API response structure -type APIResponse struct { - Success bool `json:"success" example:"true"` // Indicates if the request was successful - Data interface{} `json:"data,omitempty"` // Response data (present on success) - Error string `json:"error,omitempty" example:"error message"` // Error message (present on failure) -} - -// TunnelInfo represents tunnel information for API responses -type TunnelInfo struct { - ID string `json:"id" example:"my-tunnel"` // Unique tunnel identifier - CreatedAt time.Time `json:"created_at" example:"2025-08-08T18:30:00Z"` // Tunnel creation timestamp - MessagesReceived int64 `json:"messages_received" example:"45"` // Number of messages received from client - MessagesSent int64 `json:"messages_sent" example:"45"` // Number of messages sent to client - BytesReceived int64 `json:"bytes_received" example:"12543"` // Total bytes received from client - BytesSent int64 `json:"bytes_sent" example:"98765"` // Total bytes sent to client - Uptime string `json:"uptime" example:"1h15m30s"` // Tunnel connection uptime - Status string `json:"status" example:"connected" enums:"connected,disconnected"` // Current tunnel status -} - -// ServerStats represents server-wide statistics -type ServerStats struct { - ActiveTunnels int `json:"active_tunnels" example:"2"` // Number of currently active tunnel connections - TotalRequests int64 `json:"total_requests" example:"1542"` // Total number of HTTP requests processed - CacheHits int64 `json:"cache_hits" example:"1401"` // Number of cache hits for hostname resolution - CacheMisses int64 `json:"cache_misses" example:"141"` // Number of cache misses for hostname resolution - CacheHitRate string `json:"cache_hit_rate" example:"90.9%"` // Cache hit rate percentage - Uptime string `json:"uptime" example:"2h15m30s"` // Server uptime duration - RouterStats map[string]interface{} `json:"router_stats"` // Detailed router statistics -} - -// HealthData represents health check response data -type HealthData struct { - Status string `json:"status" example:"healthy"` // Service status - Timestamp string `json:"timestamp" example:"2025-08-08T20:11:00Z"` // Current timestamp - Uptime string `json:"uptime" example:"6.4281425s"` // Server uptime duration -} - -// TunnelStats represents detailed tunnel statistics -type TunnelStats struct { - TunnelID string `json:"tunnel_id" example:"my-tunnel"` // Tunnel identifier - CreatedAt time.Time `json:"created_at" example:"2025-08-08T18:30:00Z"` // Creation timestamp - Uptime string `json:"uptime" example:"1h15m30s"` // Connection uptime - MessagesReceived int64 `json:"messages_received" example:"45"` // Messages received count - MessagesSent int64 `json:"messages_sent" example:"45"` // Messages sent count - BytesReceived int64 `json:"bytes_received" example:"12543"` // Bytes received total - BytesSent int64 `json:"bytes_sent" example:"98765"` // Bytes sent total - Status string `json:"status" example:"connected"` // Connection status - ConnectionInfo map[string]interface{} `json:"connection_info"` // Connection details -} - -// TunnelDeleteData represents tunnel deletion response data -type TunnelDeleteData struct { - Message string `json:"message" example:"tunnel deleted successfully"` // Success message - TunnelID string `json:"tunnel_id" example:"my-tunnel"` // Deleted tunnel ID -} - -// DetailedTunnelMetrics represents comprehensive tunnel metrics -type DetailedTunnelMetrics struct { - TunnelID string `json:"tunnel_id" example:"my-tunnel"` // Tunnel identifier - TotalRequests int64 `json:"total_requests" example:"1542"` // Total requests processed - TotalRequestsSuccess int64 `json:"total_requests_success" example:"1498"` // Successful requests - TotalRequestsError int64 `json:"total_requests_error" example:"44"` // Failed requests - TotalBytesIn int64 `json:"total_bytes_in" example:"125430"` // Total bytes received - TotalBytesOut int64 `json:"total_bytes_out" example:"987650"` // Total bytes sent - AverageResponseTime float64 `json:"average_response_time_ms" example:"25.5"` // Average response time in ms - MinResponseTime float64 `json:"min_response_time_ms" example:"5.2"` // Minimum response time in ms - MaxResponseTime float64 `json:"max_response_time_ms" example:"150.8"` // Maximum response time in ms - RequestsPerSecond float64 `json:"requests_per_second" example:"12.4"` // Requests per second - ConnectionsActive int `json:"connections_active" example:"1"` // Active connections - ConnectionsTotal int64 `json:"connections_total" example:"3"` // Total connections made - ReconnectCount int64 `json:"reconnect_count" example:"2"` // Number of reconnections - TimeoutCount int64 `json:"timeout_count" example:"5"` // Number of timeouts - ConnectionErrors int64 `json:"connection_errors" example:"2"` // Connection errors - DataErrors int64 `json:"data_errors" example:"1"` // Data errors - ClientIP string `json:"client_ip" example:"192.168.1.100:52341"` // Client IP address - UserAgent string `json:"user_agent" example:"FunnelClient/1.0"` // Client user agent - LastActivity string `json:"last_activity" example:"2025-08-08T20:30:00Z"` // Last activity time - CreatedAt string `json:"created_at" example:"2025-08-08T18:30:00Z"` // Creation time -} - -// HistoricalMetricsResponse represents historical tunnel metrics -type HistoricalMetricsResponse struct { - TunnelID string `json:"tunnel_id" example:"my-tunnel"` // Tunnel identifier - TimeRange string `json:"time_range" example:"24h"` // Time range of data - Interval string `json:"interval" example:"1m"` // Data point interval - DataPoints []HistoricalDataPoint `json:"data_points"` // Historical data points -} - -// ServerMetricsResponse represents comprehensive server metrics -type ServerMetricsResponse struct { - TotalTunnels int64 `json:"total_tunnels" example:"15"` // Total tunnels created - ActiveTunnels int `json:"active_tunnels" example:"8"` // Currently active tunnels - TotalRequests int64 `json:"total_requests" example:"125430"` // Total requests processed - TotalBytesTransferred int64 `json:"total_bytes_transferred" example:"5670000"` // Total bytes transferred - AverageResponseTime float64 `json:"average_response_time_ms" example:"28.7"` // Server average response time - ServerRequestsPerSec float64 `json:"server_requests_per_second" example:"45.2"` // Server-wide RPS - MemoryUsage int64 `json:"memory_usage_bytes" example:"67108864"` // Memory usage in bytes - CPUUsage float64 `json:"cpu_usage_percent" example:"15.5"` // CPU usage percentage - NetworkConnections int `json:"network_connections" example:"12"` // Active network connections - TotalErrors int64 `json:"total_errors" example:"234"` // Total errors - ErrorRate float64 `json:"error_rate_percent" example:"0.18"` // Error rate percentage - ServerUptime string `json:"server_uptime" example:"5h23m15s"` // Server uptime -} - -// APIHandler handles REST API requests -type APIHandler struct { - server *Server - router *TunnelRouter - startTime time.Time -} - -// NewAPIHandler creates a new API handler -func NewAPIHandler(server *Server, router *TunnelRouter) *APIHandler { - return &APIHandler{ - server: server, - router: router, - startTime: time.Now(), - } -} - -// HandleAPIRequest routes API requests to appropriate handlers -func (api *APIHandler) HandleAPIRequest(w http.ResponseWriter, r *http.Request) { - logger := shared.GetLogger("server.api") - - logger.Info(). - Str("method", r.Method). - Str("path", r.URL.Path). - Str("remote_addr", r.RemoteAddr). - Msg("API request received") - - // Set common headers - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - - // Handle CORS preflight - if r.Method == "OPTIONS" { - w.WriteHeader(http.StatusOK) - return - } - - // Route based on path - path := strings.TrimPrefix(r.URL.Path, "/api") - - switch { - case path == "/health": - api.handleHealth(w, r) - case path == "/server/stats": - api.handleServerStats(w, r) - case path == "/tunnels": - api.handleTunnels(w, r) - case strings.HasPrefix(path, "/tunnels/"): - api.handleTunnelSpecific(w, r, path) - case path == "/metrics": - api.handleMetrics(w, r) - case path == "/metrics/historical": - api.handleHistoricalMetrics(w, r) - - case path == "/swagger/doc.json": - api.ServeSwaggerJSON(w, r) - default: - api.writeErrorResponse(w, "endpoint not found", http.StatusNotFound) - } -} - -// handleHealth returns a health check response -// -// @Summary Health check -// @Description Returns the health status of the server -// @Tags Server -// @Accept json -// @Produce json -// @Success 200 {object} APIResponse{data=HealthData} "Server is healthy" -// @Router /health [get] -func (api *APIHandler) handleHealth(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - api.writeErrorResponse(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - health := map[string]interface{}{ - "status": "healthy", - "timestamp": time.Now().UTC(), - "uptime": time.Since(api.startTime).String(), - } - - api.writeSuccessResponse(w, health) -} - -// handleServerStats returns server-wide statistics -// -// @Summary Get server statistics -// @Description Returns comprehensive server-wide statistics including tunnel count, request metrics, and cache performance -// @Tags Server -// @Accept json -// @Produce json -// @Success 200 {object} APIResponse{data=ServerStats} "Server statistics retrieved successfully" -// @Router /server/stats [get] -func (api *APIHandler) handleServerStats(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - api.writeErrorResponse(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - api.server.TunnelsMu.RLock() - activeTunnels := len(api.server.Tunnels) - api.server.TunnelsMu.RUnlock() - - routerStats := api.router.GetStats() - - stats := ServerStats{ - ActiveTunnels: activeTunnels, - TotalRequests: routerStats["total_requests"].(int64), - CacheHits: routerStats["cache_hits"].(int64), - CacheMisses: routerStats["cache_misses"].(int64), - CacheHitRate: routerStats["cache_hit_rate"].(string), - Uptime: time.Since(api.startTime).String(), - RouterStats: routerStats, - } - - api.writeSuccessResponse(w, stats) -} - -// handleTunnels handles requests to /api/tunnels -func (api *APIHandler) handleTunnels(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "GET": - api.handleListTunnels(w, r) - default: - api.writeErrorResponse(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleListTunnels returns a list of all active tunnels -// -// @Summary List active tunnels -// @Description Returns a list of all currently active tunnel connections -// @Tags Tunnels -// @Accept json -// @Produce json -// @Success 200 {object} APIResponse{data=[]TunnelInfo} "List of active tunnels" -// @Router /tunnels [get] -func (api *APIHandler) handleListTunnels(w http.ResponseWriter, r *http.Request) { - api.server.TunnelsMu.RLock() - defer api.server.TunnelsMu.RUnlock() - - tunnels := make([]TunnelInfo, 0, len(api.server.Tunnels)) - for _, tunnel := range api.server.Tunnels { - tunnelInfo := api.getTunnelInfo(tunnel) - tunnels = append(tunnels, tunnelInfo) - } - - api.writeSuccessResponse(w, tunnels) -} - -// handleTunnelSpecific handles requests to specific tunnel endpoints -func (api *APIHandler) handleTunnelSpecific(w http.ResponseWriter, r *http.Request, path string) { - // Extract tunnel ID from path: /tunnels/{id} or /tunnels/{id}/stats - pathParts := strings.Split(strings.TrimPrefix(path, "/tunnels/"), "/") - if len(pathParts) == 0 || pathParts[0] == "" { - api.writeErrorResponse(w, "tunnel ID required", http.StatusBadRequest) - return - } - - tunnelID := pathParts[0] - - // Validate tunnel ID format - if err := shared.ValidateTunnelID(tunnelID); err != nil { - api.writeErrorResponse(w, fmt.Sprintf("invalid tunnel ID format: %s", err.Error()), http.StatusBadRequest) - return - } - - tunnel, exists := api.server.GetTunnel(tunnelID) - if !exists { - api.writeErrorResponse(w, "tunnel not found", http.StatusNotFound) - return - } - - // Handle different endpoints - if len(pathParts) == 1 { - // /tunnels/{id} - switch r.Method { - case "GET": - api.handleGetTunnel(w, r, tunnel) - case "DELETE": - api.handleDeleteTunnel(w, r, tunnel) - default: - api.writeErrorResponse(w, "method not allowed", http.StatusMethodNotAllowed) - } - } else if len(pathParts) == 2 && pathParts[1] == "stats" { - // /tunnels/{id}/stats - switch r.Method { - case "GET": - api.handleGetTunnelStats(w, r, tunnel) - default: - api.writeErrorResponse(w, "method not allowed", http.StatusMethodNotAllowed) - } - } else if len(pathParts) == 2 && pathParts[1] == "metrics" { - // /tunnels/{id}/metrics - switch r.Method { - case "GET": - api.handleGetTunnelMetrics(w, r, tunnel) - default: - api.writeErrorResponse(w, "method not allowed", http.StatusMethodNotAllowed) - } - } else if len(pathParts) == 2 && pathParts[1] == "historical" { - // /tunnels/{id}/historical - switch r.Method { - case "GET": - api.handleGetTunnelHistorical(w, r, tunnel) - default: - api.writeErrorResponse(w, "method not allowed", http.StatusMethodNotAllowed) - } - } else { - api.writeErrorResponse(w, "endpoint not found", http.StatusNotFound) - } -} - -// handleGetTunnel returns information about a specific tunnel -// -// @Summary Get tunnel details -// @Description Returns detailed information about a specific tunnel -// @Tags Tunnels -// @Accept json -// @Produce json -// @Param tunnelId path string true "Tunnel ID (3-63 characters, lowercase letters, numbers, and hyphens only)" minlength(3) maxlength(63) -// @Success 200 {object} APIResponse{data=TunnelInfo} "Tunnel details retrieved successfully" -// @Failure 400 {object} APIResponse "Invalid tunnel ID format" -// @Failure 404 {object} APIResponse "Tunnel not found" -// @Router /tunnels/{tunnelId} [get] -func (api *APIHandler) handleGetTunnel(w http.ResponseWriter, r *http.Request, tunnel *Tunnel) { - tunnelInfo := api.getTunnelInfo(tunnel) - api.writeSuccessResponse(w, tunnelInfo) -} - -// handleDeleteTunnel forcibly closes a tunnel -// -// @Summary Delete tunnel -// @Description Forcibly closes a tunnel connection -// @Tags Tunnels -// @Accept json -// @Produce json -// @Param tunnelId path string true "Tunnel ID to delete" minlength(3) maxlength(63) -// @Success 200 {object} APIResponse{data=TunnelDeleteData} "Tunnel deleted successfully" -// @Failure 400 {object} APIResponse "Invalid tunnel ID format" -// @Failure 404 {object} APIResponse "Tunnel not found" -// @Router /tunnels/{tunnelId} [delete] -func (api *APIHandler) handleDeleteTunnel(w http.ResponseWriter, r *http.Request, tunnel *Tunnel) { - logger := shared.GetTunnelLogger("server.api", tunnel.ID) - - logger.Info(). - Str("remote_addr", r.RemoteAddr). - Msg("tunnel deletion requested via API") - - // Remove the tunnel - api.server.RemoveTunnel(tunnel.ID) - - response := map[string]interface{}{ - "message": "tunnel deleted successfully", - "tunnel_id": tunnel.ID, - } - - api.writeSuccessResponse(w, response) -} - -// handleGetTunnelStats returns detailed statistics for a specific tunnel -// -// @Summary Get tunnel statistics -// @Description Returns detailed statistics and metrics for a specific tunnel -// @Tags Tunnels -// @Accept json -// @Produce json -// @Param tunnelId path string true "Tunnel ID" minlength(3) maxlength(63) -// @Success 200 {object} APIResponse{data=TunnelStats} "Tunnel statistics retrieved successfully" -// @Failure 400 {object} APIResponse "Invalid tunnel ID format" -// @Failure 404 {object} APIResponse "Tunnel not found" -// @Router /tunnels/{tunnelId}/stats [get] -func (api *APIHandler) handleGetTunnelStats(w http.ResponseWriter, r *http.Request, tunnel *Tunnel) { - stats := map[string]interface{}{ - "tunnel_id": tunnel.ID, - "created_at": tunnel.createdAt, - "uptime": time.Since(tunnel.createdAt).String(), - "messages_received": tunnel.messagesReceived, - "messages_sent": tunnel.messagesSent, - "bytes_received": tunnel.bytesReceived, - "bytes_sent": tunnel.bytesSent, - "status": "connected", - "connection_info": map[string]interface{}{ - "remote_addr": tunnel.getRemoteAddr(), - }, - } - - api.writeSuccessResponse(w, stats) -} - -// getTunnelInfo converts a Tunnel to TunnelInfo for API responses -func (api *APIHandler) getTunnelInfo(tunnel *Tunnel) TunnelInfo { - return TunnelInfo{ - ID: tunnel.ID, - CreatedAt: tunnel.createdAt, - MessagesReceived: tunnel.messagesReceived, - MessagesSent: tunnel.messagesSent, - BytesReceived: tunnel.bytesReceived, - BytesSent: tunnel.bytesSent, - Uptime: time.Since(tunnel.createdAt).String(), - Status: "connected", - } -} - -// writeSuccessResponse writes a successful API response -func (api *APIHandler) writeSuccessResponse(w http.ResponseWriter, data interface{}) { - response := APIResponse{ - Success: true, - Data: data, - } - - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(response); err != nil { - logger := shared.GetLogger("server.api") - logger.Error().Err(err).Msg("failed to encode success response") - } -} - -// writeErrorResponse writes an error API response -func (api *APIHandler) writeErrorResponse(w http.ResponseWriter, message string, statusCode int) { - response := APIResponse{ - Success: false, - Error: message, - } - - w.WriteHeader(statusCode) - if err := json.NewEncoder(w).Encode(response); err != nil { - logger := shared.GetLogger("server.api") - logger.Error().Err(err).Msg("failed to encode error response") - } -} - -// ServeSwaggerJSON serves the swagger.json file for the Swagger UI -func (api *APIHandler) ServeSwaggerJSON(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - api.writeErrorResponse(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - logger := shared.GetLogger("server.api") - logger.Info(). - Str("remote_addr", r.RemoteAddr). - Msg("Swagger JSON requested") - - // Update swagger info with current request details - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - - docs.SwaggerInfo.Host = r.Host - docs.SwaggerInfo.Schemes = []string{scheme} - docs.SwaggerInfo.BasePath = "/api" - - // Get the swagger specification - doc, err := swag.ReadDoc("swagger") - if err != nil { - logger.Error().Err(err).Msg("failed to read swagger documentation") - api.writeErrorResponse(w, "failed to generate swagger documentation", http.StatusInternalServerError) - return - } - - // Set headers for JSON response - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Access-Control-Allow-Origin", "*") - w.WriteHeader(http.StatusOK) - - // Write the swagger JSON - if _, err := w.Write([]byte(doc)); err != nil { - logger.Error().Err(err).Msg("failed to write swagger documentation") - } -} - -// handleMetrics returns comprehensive server-wide metrics -// -// @Summary Get comprehensive server metrics -// @Description Returns detailed server-wide metrics including performance, resource usage, and aggregated statistics -// @Tags Server -// @Accept json -// @Produce json -// @Success 200 {object} APIResponse{data=ServerMetricsResponse} "Comprehensive server metrics" -// @Router /metrics [get] -func (api *APIHandler) handleMetrics(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - api.writeErrorResponse(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - api.server.TunnelsMu.RLock() - activeTunnels := len(api.server.Tunnels) - totalTunnels := int64(0) - totalRequests := int64(0) - totalBytes := int64(0) - totalErrors := int64(0) - - // Aggregate statistics from all tunnels - for _, tunnel := range api.server.Tunnels { - if tunnel.statistics != nil { - stats := tunnel.statistics.GetSnapshot() - totalRequests += stats.TotalRequests - totalBytes += stats.TotalBytesIn + stats.TotalBytesOut - totalErrors += stats.TotalRequestsError - } - totalTunnels++ - } - api.server.TunnelsMu.RUnlock() - - // Calculate server-wide metrics - uptime := time.Since(api.startTime) - serverRequestsPerSec := float64(0) - if uptime.Seconds() > 0 { - serverRequestsPerSec = float64(totalRequests) / uptime.Seconds() - } - - errorRate := float64(0) - if totalRequests > 0 { - errorRate = (float64(totalErrors) / float64(totalRequests)) * 100 - } - - metrics := ServerMetricsResponse{ - TotalTunnels: totalTunnels, - ActiveTunnels: activeTunnels, - TotalRequests: totalRequests, - TotalBytesTransferred: totalBytes, - AverageResponseTime: 0, // Could be calculated from tunnel averages - ServerRequestsPerSec: serverRequestsPerSec, - MemoryUsage: 0, // Would need runtime.ReadMemStats() - CPUUsage: 0, // Would need system monitoring - NetworkConnections: activeTunnels, - TotalErrors: totalErrors, - ErrorRate: errorRate, - ServerUptime: uptime.String(), - } - - api.writeSuccessResponse(w, metrics) -} - -// handleHistoricalMetrics returns historical server metrics -// -// @Summary Get historical server metrics -// @Description Returns time-series data for server-wide metrics over a specified time range -// @Tags Server -// @Accept json -// @Produce json -// @Param range query string false "Time range (1h, 24h, 7d)" default(24h) -// @Param interval query string false "Data interval (1m, 5m, 1h)" default(5m) -// @Success 200 {object} APIResponse{data=HistoricalMetricsResponse} "Historical server metrics" -// @Router /metrics/historical [get] -func (api *APIHandler) handleHistoricalMetrics(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - api.writeErrorResponse(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - // Parse query parameters - timeRange := r.URL.Query().Get("range") - if timeRange == "" { - timeRange = "24h" - } - - interval := r.URL.Query().Get("interval") - if interval == "" { - interval = "5m" - } - - // For now, return aggregated data from all tunnels - // In a production system, this would come from a time-series database - response := HistoricalMetricsResponse{ - TunnelID: "server", - TimeRange: timeRange, - Interval: interval, - DataPoints: []HistoricalDataPoint{}, // Would be populated from historical data - } - - api.writeSuccessResponse(w, response) -} - -// handleGetTunnelMetrics returns comprehensive metrics for a specific tunnel -// -// @Summary Get comprehensive tunnel metrics -// @Description Returns detailed performance and usage metrics for a specific tunnel -// @Tags Tunnels -// @Accept json -// @Produce json -// @Param tunnelId path string true "Tunnel ID" minlength(3) maxlength(63) -// @Success 200 {object} APIResponse{data=DetailedTunnelMetrics} "Comprehensive tunnel metrics" -// @Failure 400 {object} APIResponse "Invalid tunnel ID format" -// @Failure 404 {object} APIResponse "Tunnel not found" -// @Router /tunnels/{tunnelId}/metrics [get] -func (api *APIHandler) handleGetTunnelMetrics(w http.ResponseWriter, r *http.Request, tunnel *Tunnel) { - if tunnel.statistics == nil { - api.writeErrorResponse(w, "tunnel statistics not available", http.StatusInternalServerError) - return - } - - stats := tunnel.statistics.GetSnapshot() - - metrics := DetailedTunnelMetrics{ - TunnelID: tunnel.ID, - TotalRequests: stats.TotalRequests, - TotalRequestsSuccess: stats.TotalRequestsSuccess, - TotalRequestsError: stats.TotalRequestsError, - TotalBytesIn: stats.TotalBytesIn, - TotalBytesOut: stats.TotalBytesOut, - AverageResponseTime: stats.AverageResponseTime, - MinResponseTime: stats.MinResponseTime, - MaxResponseTime: stats.MaxResponseTime, - RequestsPerSecond: stats.RequestsPerSecond, - ConnectionsActive: stats.ConnectionsActive, - ConnectionsTotal: stats.ConnectionsTotal, - ReconnectCount: stats.ReconnectCount, - TimeoutCount: stats.TimeoutCount, - ConnectionErrors: stats.ConnectionErrors, - DataErrors: stats.DataErrors, - ClientIP: stats.ClientIP, - UserAgent: stats.UserAgent, - LastActivity: stats.LastActivity.Format(time.RFC3339), - CreatedAt: stats.CreatedAt.Format(time.RFC3339), - } - - api.writeSuccessResponse(w, metrics) -} - -// handleGetTunnelHistorical returns historical data for a specific tunnel -// -// @Summary Get tunnel historical data -// @Description Returns time-series data for a specific tunnel over a specified time range -// @Tags Tunnels -// @Accept json -// @Produce json -// @Param tunnelId path string true "Tunnel ID" minlength(3) maxlength(63) -// @Param range query string false "Time range (1h, 24h, 7d)" default(24h) -// @Param start query string false "Start time (RFC3339 format)" -// @Param end query string false "End time (RFC3339 format)" -// @Success 200 {object} APIResponse{data=HistoricalMetricsResponse} "Tunnel historical data" -// @Failure 400 {object} APIResponse "Invalid parameters" -// @Failure 404 {object} APIResponse "Tunnel not found" -// @Router /tunnels/{tunnelId}/historical [get] -func (api *APIHandler) handleGetTunnelHistorical(w http.ResponseWriter, r *http.Request, tunnel *Tunnel) { - if tunnel.historicalData == nil { - api.writeErrorResponse(w, "tunnel historical data not available", http.StatusInternalServerError) - return - } - - // Parse query parameters - timeRange := r.URL.Query().Get("range") - if timeRange == "" { - timeRange = "24h" - } - - startTimeStr := r.URL.Query().Get("start") - endTimeStr := r.URL.Query().Get("end") - - var dataPoints []HistoricalDataPoint - - if startTimeStr != "" && endTimeStr != "" { - // Use specific time range - startTime, err1 := time.Parse(time.RFC3339, startTimeStr) - endTime, err2 := time.Parse(time.RFC3339, endTimeStr) - - if err1 != nil || err2 != nil { - api.writeErrorResponse(w, "invalid time format, use RFC3339", http.StatusBadRequest) - return - } - - dataPoints = tunnel.historicalData.GetDataPointsInRange(startTime, endTime) - } else { - // Use all available data - dataPoints = tunnel.historicalData.GetDataPoints() - } - - response := HistoricalMetricsResponse{ - TunnelID: tunnel.ID, - TimeRange: timeRange, - Interval: tunnel.historicalData.Interval.String(), - DataPoints: dataPoints, - } - - api.writeSuccessResponse(w, response) -} diff --git a/server/auth.go b/server/auth.go deleted file mode 100644 index d3cb185..0000000 --- a/server/auth.go +++ /dev/null @@ -1,240 +0,0 @@ -package server - -import ( - "crypto/rand" - "crypto/sha256" - "crypto/subtle" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" - "time" - - "github.com/karol-broda/funnel/shared" -) - -type TokenRecord struct { - Name string `json:"name"` - Hash string `json:"hash"` - Prefix string `json:"prefix"` - CreatedAt time.Time `json:"created_at"` - Revoked bool `json:"revoked,omitempty"` -} - -type TokenStore struct { - path string - records []TokenRecord - mu sync.RWMutex - enabled bool -} - -func NewTokenStore(path string) (*TokenStore, error) { - logger := shared.GetLogger("server.auth") - - ts := &TokenStore{ - path: path, - records: []TokenRecord{}, - enabled: true, - } - - if path == "" { - ts.enabled = false - logger.Info().Msg("token store disabled - no path configured") - return ts, nil - } - - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, fmt.Errorf("failed to create token store directory: %w", err) - } - - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - logger.Info().Str("path", path).Msg("token store file not found, starting with empty store") - return ts, nil - } - return nil, fmt.Errorf("failed to read token store: %w", err) - } - - if err := json.Unmarshal(data, &ts.records); err != nil { - return nil, fmt.Errorf("failed to parse token store: %w", err) - } - - activeCount := 0 - for _, r := range ts.records { - if !r.Revoked { - activeCount++ - } - } - - logger.Info(). - Str("path", path). - Int("total_tokens", len(ts.records)). - Int("active_tokens", activeCount). - Msg("token store loaded") - - return ts, nil -} - -func (ts *TokenStore) IsEnabled() bool { - return ts.enabled -} - -func (ts *TokenStore) save() error { - if ts.path == "" { - return nil - } - - data, err := json.MarshalIndent(ts.records, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal tokens: %w", err) - } - - tmpPath := ts.path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0600); err != nil { - return fmt.Errorf("failed to write token store: %w", err) - } - - if err := os.Rename(tmpPath, ts.path); err != nil { - os.Remove(tmpPath) - return fmt.Errorf("failed to rename token store: %w", err) - } - - return nil -} - -func (ts *TokenStore) Create(name string) (string, error) { - logger := shared.GetLogger("server.auth") - - ts.mu.Lock() - defer ts.mu.Unlock() - - for _, r := range ts.records { - if r.Name == name && !r.Revoked { - return "", fmt.Errorf("token with name %q already exists", name) - } - } - - plainToken, err := generateSecureToken() - if err != nil { - return "", fmt.Errorf("failed to generate token: %w", err) - } - - record := TokenRecord{ - Name: name, - Hash: hashToken(plainToken), - Prefix: plainToken[:10], - CreatedAt: time.Now(), - Revoked: false, - } - - ts.records = append(ts.records, record) - - if err := ts.save(); err != nil { - ts.records = ts.records[:len(ts.records)-1] - return "", fmt.Errorf("failed to save token: %w", err) - } - - logger.Info(). - Str("name", name). - Str("prefix", record.Prefix). - Msg("token created") - - return plainToken, nil -} - -func (ts *TokenStore) Validate(plainToken string) (*TokenRecord, bool) { - if !ts.enabled { - return nil, true - } - - if plainToken == "" { - return nil, false - } - - hash := hashToken(plainToken) - - ts.mu.RLock() - defer ts.mu.RUnlock() - - for i := range ts.records { - if ts.records[i].Revoked { - continue - } - if subtle.ConstantTimeCompare([]byte(ts.records[i].Hash), []byte(hash)) == 1 { - return &ts.records[i], true - } - } - - return nil, false -} - -func (ts *TokenStore) Revoke(name string) error { - logger := shared.GetLogger("server.auth") - - ts.mu.Lock() - defer ts.mu.Unlock() - - found := false - for i := range ts.records { - if ts.records[i].Name == name && !ts.records[i].Revoked { - ts.records[i].Revoked = true - found = true - break - } - } - - if !found { - return fmt.Errorf("token %q not found or already revoked", name) - } - - if err := ts.save(); err != nil { - return fmt.Errorf("failed to save token store: %w", err) - } - - logger.Info().Str("name", name).Msg("token revoked") - return nil -} - -func (ts *TokenStore) List() []TokenRecord { - ts.mu.RLock() - defer ts.mu.RUnlock() - - result := make([]TokenRecord, 0) - for _, r := range ts.records { - if !r.Revoked { - result = append(result, r) - } - } - return result -} - -func (ts *TokenStore) Count() int { - ts.mu.RLock() - defer ts.mu.RUnlock() - - count := 0 - for _, r := range ts.records { - if !r.Revoked { - count++ - } - } - return count -} - -func generateSecureToken() (string, error) { - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return "sk_" + base64.RawURLEncoding.EncodeToString(bytes), nil -} - -func hashToken(token string) string { - h := sha256.Sum256([]byte(token)) - return hex.EncodeToString(h[:]) -} diff --git a/server/auth_test.go b/server/auth_test.go deleted file mode 100644 index 2d5932d..0000000 --- a/server/auth_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package server - -import ( - "os" - "path/filepath" - "testing" -) - -func TestTokenStore_CreateAndValidate(t *testing.T) { - tmpDir := t.TempDir() - storePath := filepath.Join(tmpDir, "tokens.json") - - store, err := NewTokenStore(storePath) - if err != nil { - t.Fatalf("failed to create token store: %v", err) - } - - plainToken, err := store.Create("test-token") - if err != nil { - t.Fatalf("failed to create token: %v", err) - } - - if plainToken == "" { - t.Fatal("expected non-empty token") - } - - if len(plainToken) < 10 { - t.Fatalf("token too short: %s", plainToken) - } - - if plainToken[:3] != "sk_" { - t.Fatalf("token should start with 'sk_', got: %s", plainToken[:3]) - } - - record, valid := store.Validate(plainToken) - if !valid { - t.Fatal("expected token to be valid") - } - - if record == nil { - t.Fatal("expected record to be non-nil") - } - - if record.Name != "test-token" { - t.Fatalf("expected name 'test-token', got: %s", record.Name) - } -} - -func TestTokenStore_InvalidToken(t *testing.T) { - tmpDir := t.TempDir() - storePath := filepath.Join(tmpDir, "tokens.json") - - store, err := NewTokenStore(storePath) - if err != nil { - t.Fatalf("failed to create token store: %v", err) - } - - _, err = store.Create("test-token") - if err != nil { - t.Fatalf("failed to create token: %v", err) - } - - record, valid := store.Validate("sk_invalid_token_that_does_not_exist") - if valid { - t.Fatal("expected invalid token to fail validation") - } - - if record != nil { - t.Fatal("expected record to be nil for invalid token") - } - - record, valid = store.Validate("") - if valid { - t.Fatal("expected empty token to fail validation") - } -} - -func TestTokenStore_Revoke(t *testing.T) { - tmpDir := t.TempDir() - storePath := filepath.Join(tmpDir, "tokens.json") - - store, err := NewTokenStore(storePath) - if err != nil { - t.Fatalf("failed to create token store: %v", err) - } - - plainToken, err := store.Create("test-token") - if err != nil { - t.Fatalf("failed to create token: %v", err) - } - - _, valid := store.Validate(plainToken) - if !valid { - t.Fatal("expected token to be valid before revoke") - } - - err = store.Revoke("test-token") - if err != nil { - t.Fatalf("failed to revoke token: %v", err) - } - - _, valid = store.Validate(plainToken) - if valid { - t.Fatal("expected token to be invalid after revoke") - } -} - -func TestTokenStore_DuplicateName(t *testing.T) { - tmpDir := t.TempDir() - storePath := filepath.Join(tmpDir, "tokens.json") - - store, err := NewTokenStore(storePath) - if err != nil { - t.Fatalf("failed to create token store: %v", err) - } - - _, err = store.Create("test-token") - if err != nil { - t.Fatalf("failed to create first token: %v", err) - } - - _, err = store.Create("test-token") - if err == nil { - t.Fatal("expected error when creating duplicate token name") - } -} - -func TestTokenStore_Persistence(t *testing.T) { - tmpDir := t.TempDir() - storePath := filepath.Join(tmpDir, "tokens.json") - - store1, err := NewTokenStore(storePath) - if err != nil { - t.Fatalf("failed to create token store: %v", err) - } - - plainToken, err := store1.Create("persistent-token") - if err != nil { - t.Fatalf("failed to create token: %v", err) - } - - store2, err := NewTokenStore(storePath) - if err != nil { - t.Fatalf("failed to reload token store: %v", err) - } - - record, valid := store2.Validate(plainToken) - if !valid { - t.Fatal("expected token to be valid after reload") - } - - if record.Name != "persistent-token" { - t.Fatalf("expected name 'persistent-token', got: %s", record.Name) - } -} - -func TestTokenStore_DisabledWithEmptyPath(t *testing.T) { - store, err := NewTokenStore("") - if err != nil { - t.Fatalf("failed to create disabled token store: %v", err) - } - - if store.IsEnabled() { - t.Fatal("expected store to be disabled with empty path") - } - - _, valid := store.Validate("any-token") - if !valid { - t.Fatal("expected validation to pass when store is disabled") - } - - _, valid = store.Validate("") - if !valid { - t.Fatal("expected validation to pass for empty token when store is disabled") - } -} - -func TestTokenStore_List(t *testing.T) { - tmpDir := t.TempDir() - storePath := filepath.Join(tmpDir, "tokens.json") - - store, err := NewTokenStore(storePath) - if err != nil { - t.Fatalf("failed to create token store: %v", err) - } - - tokens := store.List() - if len(tokens) != 0 { - t.Fatalf("expected 0 tokens, got: %d", len(tokens)) - } - - _, err = store.Create("token-1") - if err != nil { - t.Fatalf("failed to create token-1: %v", err) - } - - _, err = store.Create("token-2") - if err != nil { - t.Fatalf("failed to create token-2: %v", err) - } - - tokens = store.List() - if len(tokens) != 2 { - t.Fatalf("expected 2 tokens, got: %d", len(tokens)) - } - - err = store.Revoke("token-1") - if err != nil { - t.Fatalf("failed to revoke token-1: %v", err) - } - - tokens = store.List() - if len(tokens) != 1 { - t.Fatalf("expected 1 token after revoke, got: %d", len(tokens)) - } - - if tokens[0].Name != "token-2" { - t.Fatalf("expected remaining token to be 'token-2', got: %s", tokens[0].Name) - } -} - -func TestHashToken(t *testing.T) { - token := "sk_test_token_12345" - hash1 := hashToken(token) - hash2 := hashToken(token) - - if hash1 != hash2 { - t.Fatal("expected same hash for same token") - } - - if len(hash1) != 64 { - t.Fatalf("expected 64 character hex hash, got: %d", len(hash1)) - } - - differentHash := hashToken("sk_different_token") - if hash1 == differentHash { - t.Fatal("expected different hash for different token") - } -} - -func TestGenerateSecureToken(t *testing.T) { - token1, err := generateSecureToken() - if err != nil { - t.Fatalf("failed to generate token: %v", err) - } - - token2, err := generateSecureToken() - if err != nil { - t.Fatalf("failed to generate second token: %v", err) - } - - if token1 == token2 { - t.Fatal("expected unique tokens") - } - - if token1[:3] != "sk_" { - t.Fatalf("token should start with 'sk_', got: %s", token1[:3]) - } - - if len(token1) < 40 { - t.Fatalf("token too short: %d characters", len(token1)) - } -} - -func TestTokenStore_FilePermissions(t *testing.T) { - tmpDir := t.TempDir() - storePath := filepath.Join(tmpDir, "tokens.json") - - store, err := NewTokenStore(storePath) - if err != nil { - t.Fatalf("failed to create token store: %v", err) - } - - _, err = store.Create("test-token") - if err != nil { - t.Fatalf("failed to create token: %v", err) - } - - info, err := os.Stat(storePath) - if err != nil { - t.Fatalf("failed to stat token file: %v", err) - } - - perm := info.Mode().Perm() - if perm != 0600 { - t.Fatalf("expected file permissions 0600, got: %o", perm) - } -} diff --git a/server/certs.go b/server/certs.go deleted file mode 100644 index 9311e8d..0000000 --- a/server/certs.go +++ /dev/null @@ -1,569 +0,0 @@ -package server - -import ( - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/tls" - "crypto/x509" - "encoding/json" - "encoding/pem" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/go-acme/lego/v4/certcrypto" - "github.com/go-acme/lego/v4/certificate" - "github.com/go-acme/lego/v4/challenge" - "github.com/go-acme/lego/v4/lego" - "github.com/go-acme/lego/v4/providers/dns" - "github.com/go-acme/lego/v4/registration" - lru "github.com/hashicorp/golang-lru/v2" - "github.com/karol-broda/funnel/shared" - "github.com/rs/zerolog" -) - -type ProviderConfig struct { - Providers []DNSProviderConfig `json:"providers"` -} - -type DNSProviderConfig struct { - Name string `json:"name"` - Provider string `json:"provider"` - Domains []string `json:"domains"` - Config map[string]string `json:"config"` -} - -type User struct { - Email string - Registration *registration.Resource - key crypto.PrivateKey -} - -func (u *User) GetEmail() string { - return u.Email -} - -func (u *User) GetRegistration() *registration.Resource { - return u.Registration -} - -func (u *User) GetPrivateKey() crypto.PrivateKey { - return u.key -} - -type ProviderMux struct { - providers map[string]challenge.Provider - logger zerolog.Logger -} - -type providerCreator func(config DNSProviderConfig) (challenge.Provider, error) - -var providerCreateMutex sync.Mutex - -func NewProviderMux(config *ProviderConfig) (*ProviderMux, error) { - creator := func(pconf DNSProviderConfig) (challenge.Provider, error) { - providerCreateMutex.Lock() - defer providerCreateMutex.Unlock() - - for key, value := range pconf.Config { - os.Setenv(key, value) - } - - defer func() { - for key := range pconf.Config { - os.Unsetenv(key) - } - }() - - return dns.NewDNSChallengeProviderByName(pconf.Provider) - } - return newProviderMuxWithCreator(config, creator) -} - -func newProviderMuxWithCreator(config *ProviderConfig, create providerCreator) (*ProviderMux, error) { - logger := shared.GetLogger("certs.provider-mux") - - mux := &ProviderMux{ - providers: make(map[string]challenge.Provider), - logger: logger, - } - - for _, pconf := range config.Providers { - logger.Info(). - Str("provider_name", pconf.Name). - Str("provider_type", pconf.Provider). - Strs("domains", pconf.Domains). - Msg("configuring DNS provider") - - provider, err := create(pconf) - if err != nil { - return nil, fmt.Errorf("failed to create provider %s: %w", pconf.Name, err) - } - - for _, domain := range pconf.Domains { - normDomain := strings.ToLower(strings.TrimSpace(domain)) - if strings.HasPrefix(normDomain, "*.") { - baseDomain := normDomain[2:] - mux.providers[baseDomain] = provider - mux.providers[normDomain] = provider - } else { - mux.providers[normDomain] = provider - } - - logger.Debug(). - Str("domain", normDomain). - Str("provider", pconf.Name). - Msg("mapped domain to provider") - } - } - - logger.Info(). - Int("total_providers", len(config.Providers)). - Int("total_domain_mappings", len(mux.providers)). - Msg("provider multiplexer initialized") - - return mux, nil -} - -func (m *ProviderMux) FindManagedDomain(domain string) (string, bool) { - if _, ok := m.providers[domain]; ok { - return domain, true - } - - parts := strings.Split(domain, ".") - for i := 1; i < len(parts)-1; i++ { - parentDomain := strings.Join(parts[i:], ".") - if _, ok := m.providers[parentDomain]; ok { - return parentDomain, true - } - wildcardDomain := "*." + parentDomain - if _, ok := m.providers[wildcardDomain]; ok { - return wildcardDomain, true - } - } - - return "", false -} - -func (m *ProviderMux) Present(domain, token, keyAuth string) error { - provider, err := m.findProvider(domain) - if err != nil { - m.logger.Error().Err(err).Str("domain", domain).Msg("failed to find provider for domain") - return err - } - - m.logger.Debug(). - Str("domain", domain). - Str("token", token[:8]+"..."). - Msg("presenting DNS challenge") - - return provider.Present(domain, token, keyAuth) -} - -func (m *ProviderMux) CleanUp(domain, token, keyAuth string) error { - provider, err := m.findProvider(domain) - if err != nil { - m.logger.Error().Err(err).Str("domain", domain).Msg("failed to find provider for domain") - return err - } - - m.logger.Debug(). - Str("domain", domain). - Str("token", token[:8]+"..."). - Msg("cleaning up DNS challenge") - - return provider.CleanUp(domain, token, keyAuth) -} - -func (m *ProviderMux) findProvider(domain string) (challenge.Provider, error) { - challengeDomain := domain - prefix := "_acme-challenge." - if strings.HasPrefix(domain, prefix) { - challengeDomain = domain[len(prefix):] - } - - if provider, ok := m.providers[challengeDomain]; ok { - return provider, nil - } - - wildcardDomain := "*." + challengeDomain - if provider, ok := m.providers[wildcardDomain]; ok { - return provider, nil - } - - parts := strings.Split(challengeDomain, ".") - for i := 1; i < len(parts); i++ { - parentDomain := strings.Join(parts[i:], ".") - if provider, ok := m.providers[parentDomain]; ok { - return provider, nil - } - - wildcardParent := "*." + parentDomain - if provider, ok := m.providers[wildcardParent]; ok { - return provider, nil - } - } - - return nil, fmt.Errorf("no DNS provider found for domain: %s", domain) -} - -type CertificateManager struct { - user *User - client *lego.Client - providerMux *ProviderMux - certificates *lru.Cache[string, *tls.Certificate] - certMutex sync.Mutex - certDir string - logger zerolog.Logger - providerConfig *ProviderConfig -} - -func NewCertificateManager(email, certDir, configPath string) (*CertificateManager, error) { - const lruCacheSize = 512 - logger := shared.GetLogger("certs.manager") - - config, err := loadProviderConfig(configPath) - if err != nil { - return nil, fmt.Errorf("failed to load provider config: %w", err) - } - - providerMux, err := NewProviderMux(config) - if err != nil { - return nil, fmt.Errorf("failed to create provider multiplexer: %w", err) - } - - if err := os.MkdirAll(certDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create certificate directory: %w", err) - } - - certCache, err := lru.New[string, *tls.Certificate](lruCacheSize) - if err != nil { - return nil, fmt.Errorf("failed to create lru cache: %w", err) - } - - keyPath := filepath.Join(certDir, "account.key") - var privateKey crypto.PrivateKey - keyBytes, err := os.ReadFile(keyPath) - if err != nil { - if os.IsNotExist(err) { - logger.Info().Str("path", keyPath).Msg("no user private key found, generating a new one") - newPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, fmt.Errorf("failed to generate private key: %w", err) - } - privateKey = newPrivateKey - - pemKeyBytes, err := x509.MarshalECPrivateKey(newPrivateKey) - if err != nil { - return nil, fmt.Errorf("failed to marshal private key: %w", err) - } - pemKey := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: pemKeyBytes}) - if err := os.WriteFile(keyPath, pemKey, 0600); err != nil { - return nil, fmt.Errorf("failed to save private key: %w", err) - } - } else { - return nil, fmt.Errorf("failed to read private key: %w", err) - } - } else { - logger.Info().Str("path", keyPath).Msg("loading existing user private key") - block, _ := pem.Decode(keyBytes) - if block == nil { - return nil, fmt.Errorf("failed to decode pem block from private key") - } - p, err := x509.ParseECPrivateKey(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse private key: %w", err) - } - privateKey = p - } - - acmeUser := &User{Email: email, key: privateKey} - - regPath := filepath.Join(certDir, "account.json") - regBytes, err := os.ReadFile(regPath) - if err != nil { - if os.IsNotExist(err) { - logger.Info().Msg("no registration found, creating new one") - tempConfig := lego.NewConfig(acmeUser) - tempConfig.CADirURL = lego.LEDirectoryProduction - tempConfig.Certificate.KeyType = certcrypto.EC256 - tempClient, err := lego.NewClient(tempConfig) - if err != nil { - return nil, fmt.Errorf("failed to create temp lego client: %w", err) - } - - reg, err := tempClient.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) - if err != nil { - return nil, fmt.Errorf("failed to register user: %w", err) - } - acmeUser.Registration = reg - - regJSON, err := json.MarshalIndent(reg, "", " ") - if err != nil { - return nil, fmt.Errorf("failed to marshal registration: %w", err) - } - if err := os.WriteFile(regPath, regJSON, 0600); err != nil { - return nil, fmt.Errorf("failed to save registration: %w", err) - } - logger.Info().Str("uri", reg.URI).Msg("new user registered") - } else { - return nil, fmt.Errorf("failed to read registration file: %w", err) - } - } else { - var reg registration.Resource - if err := json.Unmarshal(regBytes, ®); err != nil { - return nil, fmt.Errorf("failed to unmarshal registration file: %w", err) - } - acmeUser.Registration = ® - logger.Info().Str("uri", reg.URI).Msg("user registration loaded from disk") - } - - finalConfig := lego.NewConfig(acmeUser) - finalConfig.Certificate.KeyType = certcrypto.EC256 - finalConfig.CADirURL = lego.LEDirectoryProduction - - client, err := lego.NewClient(finalConfig) - if err != nil { - return nil, fmt.Errorf("failed to create lego client: %w", err) - } - - err = client.Challenge.SetDNS01Provider(providerMux) - if err != nil { - return nil, fmt.Errorf("failed to set dns-01 provider: %w", err) - } - - cm := &CertificateManager{ - user: acmeUser, - client: client, - providerMux: providerMux, - certificates: certCache, - certDir: certDir, - logger: logger, - providerConfig: config, - } - - logger.Info(). - Str("email", email). - Str("cert_dir", certDir). - Str("ca_url", finalConfig.CADirURL). - Msg("certificate manager initialized") - - return cm, nil -} - -func (cm *CertificateManager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { - domain := strings.ToLower(hello.ServerName) - if domain == "" { - cm.logger.Warn().Msg("missing server name in tls handshake (sni), aborting handshake") - return nil, nil - } - - baseDomain, ok := cm.providerMux.FindManagedDomain(domain) - if !ok { - return nil, fmt.Errorf("domain %q is not configured for management", domain) - } - cm.logger.Debug().Str("domain", domain).Str("base_domain", baseDomain).Msg("certificate requested") - - cert, ok := cm.certificates.Get(baseDomain) - if ok && cm.isCertificateValid(cert) { - cm.logger.Debug().Str("domain", domain).Msg("using cached certificate (in-memory)") - return cert, nil - } - - cert, err := cm.loadCertificateFromDisk(baseDomain) - if err == nil && cm.isCertificateValid(cert) { - cm.logger.Info().Str("base_domain", baseDomain).Msg("loaded valid certificate from disk") - cm.certificates.Add(baseDomain, cert) - return cert, nil - } - - cm.logger.Info().Str("domain", domain).Msg("no valid certificate found in cache or on disk, obtaining new one") - return cm.obtainCertificate(baseDomain) -} - -func (cm *CertificateManager) obtainCertificate(baseDomain string) (*tls.Certificate, error) { - cm.certMutex.Lock() - defer cm.certMutex.Unlock() - - if cert, ok := cm.certificates.Get(baseDomain); ok && cm.isCertificateValid(cert) { - cm.logger.Debug().Str("domain", baseDomain).Msg("certificate was obtained by another goroutine while waiting for lock") - return cert, nil - } - - var domains []string - if strings.HasPrefix(baseDomain, "*.") { - domains = []string{baseDomain, strings.TrimPrefix(baseDomain, "*.")} - } else { - domains = []string{baseDomain} - } - - cm.logger.Info().Strs("domains", domains).Msg("requesting new certificate from let's encrypt") - - request := certificate.ObtainRequest{ - Domains: domains, - Bundle: true, - } - - resource, err := cm.client.Certificate.Obtain(request) - if err != nil { - return nil, fmt.Errorf("failed to obtain certificate: %w", err) - } - - cm.saveCertificateToDisk(baseDomain, resource) - - tlsCert, err := tls.X509KeyPair(resource.Certificate, resource.PrivateKey) - if err != nil { - return nil, fmt.Errorf("failed to create tls key pair from obtained certificate: %w", err) - } - - if len(tlsCert.Certificate) > 0 { - x509Cert, err := x509.ParseCertificate(tlsCert.Certificate[0]) - if err != nil { - return nil, fmt.Errorf("failed to parse obtained certificate leaf: %w", err) - } - tlsCert.Leaf = x509Cert - } - - cm.certificates.Add(baseDomain, &tlsCert) - - cm.logger.Info().Str("domain", baseDomain).Time("expiry", tlsCert.Leaf.NotAfter).Msg("successfully obtained and cached certificate") - return &tlsCert, nil -} - -func (cm *CertificateManager) saveCertificateToDisk(baseDomain string, resource *certificate.Resource) { - certPath := filepath.Join(cm.certDir, baseDomain+".crt") - keyPath := filepath.Join(cm.certDir, baseDomain+".key") - - err := os.WriteFile(certPath, resource.Certificate, 0644) - if err != nil { - cm.logger.Error().Err(err).Str("path", certPath).Msg("failed to save certificate to disk") - } - - err = os.WriteFile(keyPath, resource.PrivateKey, 0600) - if err != nil { - cm.logger.Error().Err(err).Str("path", keyPath).Msg("failed to save private key to disk") - } - - cm.logger.Info(). - Str("domain", baseDomain). - Str("cert_path", certPath). - Str("key_path", keyPath). - Msg("certificate saved to disk") -} - -func (cm *CertificateManager) isCertificateValid(cert *tls.Certificate) bool { - if len(cert.Certificate) == 0 { - return false - } - - x509Cert, err := x509.ParseCertificate(cert.Certificate[0]) - if err != nil { - cm.logger.Error().Err(err).Msg("failed to parse certificate") - return false - } - - expirationThreshold := time.Now().Add(30 * 24 * time.Hour) - - if time.Now().After(x509Cert.NotAfter) { - cm.logger.Debug().Time("expiry", x509Cert.NotAfter).Msg("certificate is expired") - return false - } - - if x509Cert.NotAfter.Before(expirationThreshold) { - cm.logger.Debug().Time("expiry", x509Cert.NotAfter).Msg("certificate expires soon") - return false - } - - return true -} - -func (cm *CertificateManager) PreloadCertificates() error { - cm.logger.Info().Msg("starting certificate preload") - - managedDomains := make(map[string]struct{}) - for _, p := range cm.providerConfig.Providers { - for _, d := range p.Domains { - managedDomains[strings.ToLower(strings.TrimSpace(d))] = struct{}{} - } - } - - for domain := range managedDomains { - cm.logger.Info().Str("domain", domain).Msg("checking certificate during preload") - - cert, ok := cm.certificates.Get(domain) - if ok && cm.isCertificateValid(cert) { - cm.logger.Info().Str("domain", domain).Msg("certificate is already in cache and valid") - continue - } - - cert, err := cm.loadCertificateFromDisk(domain) - if err == nil && cm.isCertificateValid(cert) { - cm.logger.Info().Str("domain", domain).Msg("loaded valid certificate from disk into cache") - cm.certificates.Add(domain, cert) - continue - } - - cm.logger.Info().Str("domain", domain).Msg("no valid certificate on disk, obtaining new one") - _, err = cm.obtainCertificate(domain) - if err != nil { - cm.logger.Error().Err(err).Str("domain", domain).Msg("failed to obtain certificate during preload") - return fmt.Errorf("failed to obtain certificate for %s: %w", domain, err) - } else { - cm.logger.Info().Str("domain", domain).Msg("successfully obtained and cached certificate") - } - } - - cm.logger.Info().Msg("certificate preload finished successfully") - return nil -} - -func loadProviderConfig(configPath string) (*ProviderConfig, error) { - logger := shared.GetLogger("certs.config") - data, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) - } - - var config ProviderConfig - if err := json.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse config JSON: %w", err) - } - - logger.Info().Str("path", configPath).Int("providers", len(config.Providers)).Msg("loaded DNS provider config") - return &config, nil -} - -func (cm *CertificateManager) loadCertificateFromDisk(baseDomain string) (*tls.Certificate, error) { - certPath := filepath.Join(cm.certDir, baseDomain+".crt") - keyPath := filepath.Join(cm.certDir, baseDomain+".key") - - certBytes, err := os.ReadFile(certPath) - if err != nil { - return nil, err - } - - keyBytes, err := os.ReadFile(keyPath) - if err != nil { - return nil, err - } - - tlsCert, err := tls.X509KeyPair(certBytes, keyBytes) - if err != nil { - return nil, err - } - - if len(tlsCert.Certificate) > 0 { - x509Cert, err := x509.ParseCertificate(tlsCert.Certificate[0]) - if err != nil { - return nil, fmt.Errorf("failed to parse certificate to get leaf: %w", err) - } - tlsCert.Leaf = x509Cert - } - - return &tlsCert, nil -} diff --git a/server/certs_test.go b/server/certs_test.go deleted file mode 100644 index 4558d4f..0000000 --- a/server/certs_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package server - -import ( - "fmt" - "testing" - - "github.com/go-acme/lego/v4/challenge" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type mockProvider struct { - name string - presentFn func(domain, token, keyAuth string) error - cleanUpFn func(domain, token, keyAuth string) error -} - -func (m *mockProvider) Present(domain, token, keyAuth string) error { - if m.presentFn != nil { - return m.presentFn(domain, token, keyAuth) - } - return nil -} - -func (m *mockProvider) CleanUp(domain, token, keyAuth string) error { - if m.cleanUpFn != nil { - return m.cleanUpFn(domain, token, keyAuth) - } - return nil -} - -func TestNewProviderMux(t *testing.T) { - mockProviderCreator := func(pconf DNSProviderConfig) (challenge.Provider, error) { - if pconf.Provider == "unsupported" { - return nil, fmt.Errorf("unsupported DNS provider: %s", pconf.Provider) - } - return &mockProvider{name: pconf.Name}, nil - } - - tests := []struct { - name string - config *ProviderConfig - expectedError string - validate func(t *testing.T, mux *ProviderMux) - }{ - { - name: "valid config with multiple providers and domains", - config: &ProviderConfig{ - Providers: []DNSProviderConfig{ - { - Name: "aws-prod", - Provider: "route53", - Domains: []string{"example.com", "*.example.com"}, - Config: map[string]string{"AWS_REGION": "us-east-1"}, - }, - { - Name: "cloudflare-main", - Provider: "cloudflare", - Domains: []string{"myapp.org", "*.myapp.org"}, - Config: map[string]string{"CLOUDFLARE_DNS_API_TOKEN": "secret"}, - }, - }, - }, - validate: func(t *testing.T, mux *ProviderMux) { - require.NotNil(t, mux) - assert.Equal(t, 4, len(mux.providers)) - - p1 := mux.providers["example.com"] - p2 := mux.providers["*.example.com"] - p3 := mux.providers["myapp.org"] - p4 := mux.providers["*.myapp.org"] - - require.NotNil(t, p1) - require.NotNil(t, p2) - require.NotNil(t, p3) - require.NotNil(t, p4) - - assert.True(t, p1 == p2, "Expected same provider instance for example.com and *.example.com") - assert.True(t, p3 == p4, "Expected same provider instance for myapp.org and *.myapp.org") - assert.False(t, p1 == p3, "Expected different provider instances for different providers") - - assert.Equal(t, "aws-prod", p1.(*mockProvider).name) - assert.Equal(t, "cloudflare-main", p3.(*mockProvider).name) - }, - }, - { - name: "unsupported provider", - config: &ProviderConfig{ - Providers: []DNSProviderConfig{ - { - Name: "unsupported", - Provider: "unsupported", - Domains: []string{"test.com"}, - }, - }, - }, - expectedError: "unsupported DNS provider: unsupported", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - mux, err := newProviderMuxWithCreator(tc.config, mockProviderCreator) - - if tc.expectedError != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.expectedError) - assert.Nil(t, mux) - } else { - require.NoError(t, err) - tc.validate(t, mux) - } - }) - } -} - -func TestProviderMux_FindProvider(t *testing.T) { - mockProviderCreator := func(pconf DNSProviderConfig) (challenge.Provider, error) { - return &mockProvider{name: pconf.Name}, nil - } - - config := &ProviderConfig{ - Providers: []DNSProviderConfig{ - { - Name: "aws-prod", - Provider: "route53", - Domains: []string{"example.com", "*.example.com"}, - }, - { - Name: "cloudflare-main", - Provider: "cloudflare", - Domains: []string{"myapp.org"}, - }, - { - Name: "aws-staging", - Provider: "route53", - Domains: []string{"staging.example.com", "*.staging.example.com"}, - }, - }, - } - - mux, err := newProviderMuxWithCreator(config, mockProviderCreator) - require.NoError(t, err) - require.NotNil(t, mux) - - tests := []struct { - domain string - expectedProvider string - expectError bool - }{ - {domain: "example.com", expectedProvider: "aws-prod"}, - {domain: "www.example.com", expectedProvider: "aws-prod"}, - {domain: "test.example.com", expectedProvider: "aws-prod"}, - {domain: "_acme-challenge.example.com", expectedProvider: "aws-prod"}, - {domain: "_acme-challenge.www.example.com", expectedProvider: "aws-prod"}, - {domain: "myapp.org", expectedProvider: "cloudflare-main"}, - {domain: "staging.example.com", expectedProvider: "aws-staging"}, - {domain: "api.staging.example.com", expectedProvider: "aws-staging"}, - {domain: "unknown.com", expectError: true}, - {domain: "example.org", expectError: true}, - } - - for _, tc := range tests { - t.Run(tc.domain, func(t *testing.T) { - provider, err := mux.findProvider(tc.domain) - if tc.expectError { - require.Error(t, err) - assert.Nil(t, provider) - } else { - require.NoError(t, err) - require.NotNil(t, provider) - assert.Equal(t, tc.expectedProvider, provider.(*mockProvider).name) - } - }) - } -} diff --git a/server/docs/docs.go b/server/docs/docs.go deleted file mode 100644 index 2eaf2bf..0000000 --- a/server/docs/docs.go +++ /dev/null @@ -1,972 +0,0 @@ -// Package docs Code generated by swaggo/swag. DO NOT EDIT -package docs - -import "github.com/swaggo/swag" - -const docTemplate = `{ - "schemes": {{ marshal .Schemes }}, - "swagger": "2.0", - "info": { - "description": "{{escape .Description}}", - "title": "{{.Title}}", - "contact": { - "name": "Funnel Support", - "url": "https://github.com/karol-broda/funnel" - }, - "license": { - "name": "MIT", - "url": "https://github.com/karol-broda/funnel/blob/master/LICENSE.md" - }, - "version": "{{.Version}}" - }, - "host": "{{.Host}}", - "basePath": "{{.BasePath}}", - "paths": { - "/health": { - "get": { - "description": "Returns the health status of the server", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Health check", - "responses": { - "200": { - "description": "Server is healthy", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HealthData" - } - } - } - ] - } - } - } - } - }, - "/metrics": { - "get": { - "description": "Returns detailed server-wide metrics including performance, resource usage, and aggregated statistics", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get comprehensive server metrics", - "responses": { - "200": { - "description": "Comprehensive server metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.ServerMetricsResponse" - } - } - } - ] - } - } - } - } - }, - "/metrics/historical": { - "get": { - "description": "Returns time-series data for server-wide metrics over a specified time range", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get historical server metrics", - "parameters": [ - { - "type": "string", - "default": "24h", - "description": "Time range (1h, 24h, 7d)", - "name": "range", - "in": "query" - }, - { - "type": "string", - "default": "5m", - "description": "Data interval (1m, 5m, 1h)", - "name": "interval", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Historical server metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HistoricalMetricsResponse" - } - } - } - ] - } - } - } - } - }, - "/server/stats": { - "get": { - "description": "Returns comprehensive server-wide statistics including tunnel count, request metrics, and cache performance", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get server statistics", - "responses": { - "200": { - "description": "Server statistics retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.ServerStats" - } - } - } - ] - } - } - } - } - }, - "/tunnels": { - "get": { - "description": "Returns a list of all currently active tunnel connections", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "List active tunnels", - "responses": { - "200": { - "description": "List of active tunnels", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/server.TunnelInfo" - } - } - } - } - ] - } - } - } - } - }, - "/tunnels/{tunnelId}": { - "get": { - "description": "Returns detailed information about a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel details", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID (3-63 characters, lowercase letters, numbers, and hyphens only)", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel details retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelInfo" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - }, - "delete": { - "description": "Forcibly closes a tunnel connection", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Delete tunnel", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID to delete", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel deleted successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelDeleteData" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - } - }, - "/tunnels/{tunnelId}/historical": { - "get": { - "description": "Returns time-series data for a specific tunnel over a specified time range", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel historical data", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - }, - { - "type": "string", - "default": "24h", - "description": "Time range (1h, 24h, 7d)", - "name": "range", - "in": "query" - }, - { - "type": "string", - "description": "Start time (RFC3339 format)", - "name": "start", - "in": "query" - }, - { - "type": "string", - "description": "End time (RFC3339 format)", - "name": "end", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Tunnel historical data", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HistoricalMetricsResponse" - } - } - } - ] - } - }, - "400": { - "description": "Invalid parameters", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - } - }, - "/tunnels/{tunnelId}/metrics": { - "get": { - "description": "Returns detailed performance and usage metrics for a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get comprehensive tunnel metrics", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Comprehensive tunnel metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.DetailedTunnelMetrics" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - } - }, - "/tunnels/{tunnelId}/stats": { - "get": { - "description": "Returns detailed statistics and metrics for a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel statistics", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel statistics retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelStats" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - } - } - }, - "definitions": { - "server.APIResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data (present on success)" - }, - "error": { - "description": "Error message (present on failure)", - "type": "string", - "example": "error message" - }, - "success": { - "description": "Indicates if the request was successful", - "type": "boolean", - "example": true - } - } - }, - "server.DetailedTunnelMetrics": { - "type": "object", - "properties": { - "average_response_time_ms": { - "description": "Average response time in ms", - "type": "number", - "example": 25.5 - }, - "client_ip": { - "description": "Client IP address", - "type": "string", - "example": "192.168.1.100:52341" - }, - "connection_errors": { - "description": "Connection errors", - "type": "integer", - "example": 2 - }, - "connections_active": { - "description": "Active connections", - "type": "integer", - "example": 1 - }, - "connections_total": { - "description": "Total connections made", - "type": "integer", - "example": 3 - }, - "created_at": { - "description": "Creation time", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "data_errors": { - "description": "Data errors", - "type": "integer", - "example": 1 - }, - "last_activity": { - "description": "Last activity time", - "type": "string", - "example": "2025-08-08T20:30:00Z" - }, - "max_response_time_ms": { - "description": "Maximum response time in ms", - "type": "number", - "example": 150.8 - }, - "min_response_time_ms": { - "description": "Minimum response time in ms", - "type": "number", - "example": 5.2 - }, - "reconnect_count": { - "description": "Number of reconnections", - "type": "integer", - "example": 2 - }, - "requests_per_second": { - "description": "Requests per second", - "type": "number", - "example": 12.4 - }, - "timeout_count": { - "description": "Number of timeouts", - "type": "integer", - "example": 5 - }, - "total_bytes_in": { - "description": "Total bytes received", - "type": "integer", - "example": 125430 - }, - "total_bytes_out": { - "description": "Total bytes sent", - "type": "integer", - "example": 987650 - }, - "total_requests": { - "description": "Total requests processed", - "type": "integer", - "example": 1542 - }, - "total_requests_error": { - "description": "Failed requests", - "type": "integer", - "example": 44 - }, - "total_requests_success": { - "description": "Successful requests", - "type": "integer", - "example": 1498 - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "user_agent": { - "description": "Client user agent", - "type": "string", - "example": "FunnelClient/1.0" - } - } - }, - "server.HealthData": { - "type": "object", - "properties": { - "status": { - "description": "Service status", - "type": "string", - "example": "healthy" - }, - "timestamp": { - "description": "Current timestamp", - "type": "string", - "example": "2025-08-08T20:11:00Z" - }, - "uptime": { - "description": "Server uptime duration", - "type": "string", - "example": "6.4281425s" - } - } - }, - "server.HistoricalDataPoint": { - "type": "object", - "properties": { - "active_connections": { - "type": "integer" - }, - "bytes_in": { - "type": "integer" - }, - "bytes_out": { - "type": "integer" - }, - "error_count": { - "type": "integer" - }, - "request_count": { - "type": "integer" - }, - "response_time_ms": { - "type": "number" - }, - "timestamp": { - "type": "string" - } - } - }, - "server.HistoricalMetricsResponse": { - "type": "object", - "properties": { - "data_points": { - "description": "Historical data points", - "type": "array", - "items": { - "$ref": "#/definitions/server.HistoricalDataPoint" - } - }, - "interval": { - "description": "Data point interval", - "type": "string", - "example": "1m" - }, - "time_range": { - "description": "Time range of data", - "type": "string", - "example": "24h" - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" - } - } - }, - "server.ServerMetricsResponse": { - "type": "object", - "properties": { - "active_tunnels": { - "description": "Currently active tunnels", - "type": "integer", - "example": 8 - }, - "average_response_time_ms": { - "description": "Server average response time", - "type": "number", - "example": 28.7 - }, - "cpu_usage_percent": { - "description": "CPU usage percentage", - "type": "number", - "example": 15.5 - }, - "error_rate_percent": { - "description": "Error rate percentage", - "type": "number", - "example": 0.18 - }, - "memory_usage_bytes": { - "description": "Memory usage in bytes", - "type": "integer", - "example": 67108864 - }, - "network_connections": { - "description": "Active network connections", - "type": "integer", - "example": 12 - }, - "server_requests_per_second": { - "description": "Server-wide RPS", - "type": "number", - "example": 45.2 - }, - "server_uptime": { - "description": "Server uptime", - "type": "string", - "example": "5h23m15s" - }, - "total_bytes_transferred": { - "description": "Total bytes transferred", - "type": "integer", - "example": 5670000 - }, - "total_errors": { - "description": "Total errors", - "type": "integer", - "example": 234 - }, - "total_requests": { - "description": "Total requests processed", - "type": "integer", - "example": 125430 - }, - "total_tunnels": { - "description": "Total tunnels created", - "type": "integer", - "example": 15 - } - } - }, - "server.ServerStats": { - "type": "object", - "properties": { - "active_tunnels": { - "description": "Number of currently active tunnel connections", - "type": "integer", - "example": 2 - }, - "cache_hit_rate": { - "description": "Cache hit rate percentage", - "type": "string", - "example": "90.9%" - }, - "cache_hits": { - "description": "Number of cache hits for hostname resolution", - "type": "integer", - "example": 1401 - }, - "cache_misses": { - "description": "Number of cache misses for hostname resolution", - "type": "integer", - "example": 141 - }, - "router_stats": { - "description": "Detailed router statistics", - "type": "object", - "additionalProperties": true - }, - "total_requests": { - "description": "Total number of HTTP requests processed", - "type": "integer", - "example": 1542 - }, - "uptime": { - "description": "Server uptime duration", - "type": "string", - "example": "2h15m30s" - } - } - }, - "server.TunnelDeleteData": { - "type": "object", - "properties": { - "message": { - "description": "Success message", - "type": "string", - "example": "tunnel deleted successfully" - }, - "tunnel_id": { - "description": "Deleted tunnel ID", - "type": "string", - "example": "my-tunnel" - } - } - }, - "server.TunnelInfo": { - "type": "object", - "properties": { - "bytes_received": { - "description": "Total bytes received from client", - "type": "integer", - "example": 12543 - }, - "bytes_sent": { - "description": "Total bytes sent to client", - "type": "integer", - "example": 98765 - }, - "created_at": { - "description": "Tunnel creation timestamp", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "id": { - "description": "Unique tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "messages_received": { - "description": "Number of messages received from client", - "type": "integer", - "example": 45 - }, - "messages_sent": { - "description": "Number of messages sent to client", - "type": "integer", - "example": 45 - }, - "status": { - "description": "Current tunnel status", - "type": "string", - "enum": [ - "connected", - "disconnected" - ], - "example": "connected" - }, - "uptime": { - "description": "Tunnel connection uptime", - "type": "string", - "example": "1h15m30s" - } - } - }, - "server.TunnelStats": { - "type": "object", - "properties": { - "bytes_received": { - "description": "Bytes received total", - "type": "integer", - "example": 12543 - }, - "bytes_sent": { - "description": "Bytes sent total", - "type": "integer", - "example": 98765 - }, - "connection_info": { - "description": "Connection details", - "type": "object", - "additionalProperties": true - }, - "created_at": { - "description": "Creation timestamp", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "messages_received": { - "description": "Messages received count", - "type": "integer", - "example": 45 - }, - "messages_sent": { - "description": "Messages sent count", - "type": "integer", - "example": 45 - }, - "status": { - "description": "Connection status", - "type": "string", - "example": "connected" - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "uptime": { - "description": "Connection uptime", - "type": "string", - "example": "1h15m30s" - } - } - } - }, - "tags": [ - { - "description": "Server health and statistics endpoints", - "name": "Server" - }, - { - "description": "Tunnel management and monitoring endpoints", - "name": "Tunnels" - } - ] -}` - -// SwaggerInfo holds exported Swagger Info so clients can modify it -var SwaggerInfo = &swag.Spec{ - Version: "1.0", - Host: "localhost:8080", - BasePath: "/api", - Schemes: []string{"http", "https"}, - Title: "Funnel Server API", - Description: "REST API for tunnel management and monitoring in the funnel tunneling solution\n\nThis API provides endpoints for:\n- Health checking\n- Server statistics and monitoring\n- Tunnel management (list, view, delete)\n- Individual tunnel statistics", - InfoInstanceName: "swagger", - SwaggerTemplate: docTemplate, - LeftDelim: "{{", - RightDelim: "}}", -} - -func init() { - swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) -} diff --git a/server/docs/swagger.json b/server/docs/swagger.json deleted file mode 100644 index 153b88d..0000000 --- a/server/docs/swagger.json +++ /dev/null @@ -1,952 +0,0 @@ -{ - "schemes": [ - "http", - "https" - ], - "swagger": "2.0", - "info": { - "description": "REST API for tunnel management and monitoring in the funnel tunneling solution\n\nThis API provides endpoints for:\n- Health checking\n- Server statistics and monitoring\n- Tunnel management (list, view, delete)\n- Individual tunnel statistics", - "title": "Funnel Server API", - "contact": { - "name": "Funnel Support", - "url": "https://github.com/karol-broda/funnel" - }, - "license": { - "name": "MIT", - "url": "https://github.com/karol-broda/funnel/blob/master/LICENSE.md" - }, - "version": "1.0" - }, - "host": "localhost:8080", - "basePath": "/api", - "paths": { - "/health": { - "get": { - "description": "Returns the health status of the server", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Health check", - "responses": { - "200": { - "description": "Server is healthy", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HealthData" - } - } - } - ] - } - } - } - } - }, - "/metrics": { - "get": { - "description": "Returns detailed server-wide metrics including performance, resource usage, and aggregated statistics", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get comprehensive server metrics", - "responses": { - "200": { - "description": "Comprehensive server metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.ServerMetricsResponse" - } - } - } - ] - } - } - } - } - }, - "/metrics/historical": { - "get": { - "description": "Returns time-series data for server-wide metrics over a specified time range", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get historical server metrics", - "parameters": [ - { - "type": "string", - "default": "24h", - "description": "Time range (1h, 24h, 7d)", - "name": "range", - "in": "query" - }, - { - "type": "string", - "default": "5m", - "description": "Data interval (1m, 5m, 1h)", - "name": "interval", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Historical server metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HistoricalMetricsResponse" - } - } - } - ] - } - } - } - } - }, - "/server/stats": { - "get": { - "description": "Returns comprehensive server-wide statistics including tunnel count, request metrics, and cache performance", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get server statistics", - "responses": { - "200": { - "description": "Server statistics retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.ServerStats" - } - } - } - ] - } - } - } - } - }, - "/tunnels": { - "get": { - "description": "Returns a list of all currently active tunnel connections", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "List active tunnels", - "responses": { - "200": { - "description": "List of active tunnels", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/server.TunnelInfo" - } - } - } - } - ] - } - } - } - } - }, - "/tunnels/{tunnelId}": { - "get": { - "description": "Returns detailed information about a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel details", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID (3-63 characters, lowercase letters, numbers, and hyphens only)", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel details retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelInfo" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - }, - "delete": { - "description": "Forcibly closes a tunnel connection", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Delete tunnel", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID to delete", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel deleted successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelDeleteData" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - } - }, - "/tunnels/{tunnelId}/historical": { - "get": { - "description": "Returns time-series data for a specific tunnel over a specified time range", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel historical data", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - }, - { - "type": "string", - "default": "24h", - "description": "Time range (1h, 24h, 7d)", - "name": "range", - "in": "query" - }, - { - "type": "string", - "description": "Start time (RFC3339 format)", - "name": "start", - "in": "query" - }, - { - "type": "string", - "description": "End time (RFC3339 format)", - "name": "end", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Tunnel historical data", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HistoricalMetricsResponse" - } - } - } - ] - } - }, - "400": { - "description": "Invalid parameters", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - } - }, - "/tunnels/{tunnelId}/metrics": { - "get": { - "description": "Returns detailed performance and usage metrics for a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get comprehensive tunnel metrics", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Comprehensive tunnel metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.DetailedTunnelMetrics" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - } - }, - "/tunnels/{tunnelId}/stats": { - "get": { - "description": "Returns detailed statistics and metrics for a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel statistics", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel statistics retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelStats" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - } - } - }, - "definitions": { - "server.APIResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data (present on success)" - }, - "error": { - "description": "Error message (present on failure)", - "type": "string", - "example": "error message" - }, - "success": { - "description": "Indicates if the request was successful", - "type": "boolean", - "example": true - } - } - }, - "server.DetailedTunnelMetrics": { - "type": "object", - "properties": { - "average_response_time_ms": { - "description": "Average response time in ms", - "type": "number", - "example": 25.5 - }, - "client_ip": { - "description": "Client IP address", - "type": "string", - "example": "192.168.1.100:52341" - }, - "connection_errors": { - "description": "Connection errors", - "type": "integer", - "example": 2 - }, - "connections_active": { - "description": "Active connections", - "type": "integer", - "example": 1 - }, - "connections_total": { - "description": "Total connections made", - "type": "integer", - "example": 3 - }, - "created_at": { - "description": "Creation time", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "data_errors": { - "description": "Data errors", - "type": "integer", - "example": 1 - }, - "last_activity": { - "description": "Last activity time", - "type": "string", - "example": "2025-08-08T20:30:00Z" - }, - "max_response_time_ms": { - "description": "Maximum response time in ms", - "type": "number", - "example": 150.8 - }, - "min_response_time_ms": { - "description": "Minimum response time in ms", - "type": "number", - "example": 5.2 - }, - "reconnect_count": { - "description": "Number of reconnections", - "type": "integer", - "example": 2 - }, - "requests_per_second": { - "description": "Requests per second", - "type": "number", - "example": 12.4 - }, - "timeout_count": { - "description": "Number of timeouts", - "type": "integer", - "example": 5 - }, - "total_bytes_in": { - "description": "Total bytes received", - "type": "integer", - "example": 125430 - }, - "total_bytes_out": { - "description": "Total bytes sent", - "type": "integer", - "example": 987650 - }, - "total_requests": { - "description": "Total requests processed", - "type": "integer", - "example": 1542 - }, - "total_requests_error": { - "description": "Failed requests", - "type": "integer", - "example": 44 - }, - "total_requests_success": { - "description": "Successful requests", - "type": "integer", - "example": 1498 - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "user_agent": { - "description": "Client user agent", - "type": "string", - "example": "FunnelClient/1.0" - } - } - }, - "server.HealthData": { - "type": "object", - "properties": { - "status": { - "description": "Service status", - "type": "string", - "example": "healthy" - }, - "timestamp": { - "description": "Current timestamp", - "type": "string", - "example": "2025-08-08T20:11:00Z" - }, - "uptime": { - "description": "Server uptime duration", - "type": "string", - "example": "6.4281425s" - } - } - }, - "server.HistoricalDataPoint": { - "type": "object", - "properties": { - "active_connections": { - "type": "integer" - }, - "bytes_in": { - "type": "integer" - }, - "bytes_out": { - "type": "integer" - }, - "error_count": { - "type": "integer" - }, - "request_count": { - "type": "integer" - }, - "response_time_ms": { - "type": "number" - }, - "timestamp": { - "type": "string" - } - } - }, - "server.HistoricalMetricsResponse": { - "type": "object", - "properties": { - "data_points": { - "description": "Historical data points", - "type": "array", - "items": { - "$ref": "#/definitions/server.HistoricalDataPoint" - } - }, - "interval": { - "description": "Data point interval", - "type": "string", - "example": "1m" - }, - "time_range": { - "description": "Time range of data", - "type": "string", - "example": "24h" - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" - } - } - }, - "server.ServerMetricsResponse": { - "type": "object", - "properties": { - "active_tunnels": { - "description": "Currently active tunnels", - "type": "integer", - "example": 8 - }, - "average_response_time_ms": { - "description": "Server average response time", - "type": "number", - "example": 28.7 - }, - "cpu_usage_percent": { - "description": "CPU usage percentage", - "type": "number", - "example": 15.5 - }, - "error_rate_percent": { - "description": "Error rate percentage", - "type": "number", - "example": 0.18 - }, - "memory_usage_bytes": { - "description": "Memory usage in bytes", - "type": "integer", - "example": 67108864 - }, - "network_connections": { - "description": "Active network connections", - "type": "integer", - "example": 12 - }, - "server_requests_per_second": { - "description": "Server-wide RPS", - "type": "number", - "example": 45.2 - }, - "server_uptime": { - "description": "Server uptime", - "type": "string", - "example": "5h23m15s" - }, - "total_bytes_transferred": { - "description": "Total bytes transferred", - "type": "integer", - "example": 5670000 - }, - "total_errors": { - "description": "Total errors", - "type": "integer", - "example": 234 - }, - "total_requests": { - "description": "Total requests processed", - "type": "integer", - "example": 125430 - }, - "total_tunnels": { - "description": "Total tunnels created", - "type": "integer", - "example": 15 - } - } - }, - "server.ServerStats": { - "type": "object", - "properties": { - "active_tunnels": { - "description": "Number of currently active tunnel connections", - "type": "integer", - "example": 2 - }, - "cache_hit_rate": { - "description": "Cache hit rate percentage", - "type": "string", - "example": "90.9%" - }, - "cache_hits": { - "description": "Number of cache hits for hostname resolution", - "type": "integer", - "example": 1401 - }, - "cache_misses": { - "description": "Number of cache misses for hostname resolution", - "type": "integer", - "example": 141 - }, - "router_stats": { - "description": "Detailed router statistics", - "type": "object", - "additionalProperties": true - }, - "total_requests": { - "description": "Total number of HTTP requests processed", - "type": "integer", - "example": 1542 - }, - "uptime": { - "description": "Server uptime duration", - "type": "string", - "example": "2h15m30s" - } - } - }, - "server.TunnelDeleteData": { - "type": "object", - "properties": { - "message": { - "description": "Success message", - "type": "string", - "example": "tunnel deleted successfully" - }, - "tunnel_id": { - "description": "Deleted tunnel ID", - "type": "string", - "example": "my-tunnel" - } - } - }, - "server.TunnelInfo": { - "type": "object", - "properties": { - "bytes_received": { - "description": "Total bytes received from client", - "type": "integer", - "example": 12543 - }, - "bytes_sent": { - "description": "Total bytes sent to client", - "type": "integer", - "example": 98765 - }, - "created_at": { - "description": "Tunnel creation timestamp", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "id": { - "description": "Unique tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "messages_received": { - "description": "Number of messages received from client", - "type": "integer", - "example": 45 - }, - "messages_sent": { - "description": "Number of messages sent to client", - "type": "integer", - "example": 45 - }, - "status": { - "description": "Current tunnel status", - "type": "string", - "enum": [ - "connected", - "disconnected" - ], - "example": "connected" - }, - "uptime": { - "description": "Tunnel connection uptime", - "type": "string", - "example": "1h15m30s" - } - } - }, - "server.TunnelStats": { - "type": "object", - "properties": { - "bytes_received": { - "description": "Bytes received total", - "type": "integer", - "example": 12543 - }, - "bytes_sent": { - "description": "Bytes sent total", - "type": "integer", - "example": 98765 - }, - "connection_info": { - "description": "Connection details", - "type": "object", - "additionalProperties": true - }, - "created_at": { - "description": "Creation timestamp", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "messages_received": { - "description": "Messages received count", - "type": "integer", - "example": 45 - }, - "messages_sent": { - "description": "Messages sent count", - "type": "integer", - "example": 45 - }, - "status": { - "description": "Connection status", - "type": "string", - "example": "connected" - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "uptime": { - "description": "Connection uptime", - "type": "string", - "example": "1h15m30s" - } - } - } - }, - "tags": [ - { - "description": "Server health and statistics endpoints", - "name": "Server" - }, - { - "description": "Tunnel management and monitoring endpoints", - "name": "Tunnels" - } - ] -} \ No newline at end of file diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml deleted file mode 100644 index e3967b3..0000000 --- a/server/docs/swagger.yaml +++ /dev/null @@ -1,658 +0,0 @@ -basePath: /api -definitions: - server.APIResponse: - properties: - data: - description: Response data (present on success) - error: - description: Error message (present on failure) - example: error message - type: string - success: - description: Indicates if the request was successful - example: true - type: boolean - type: object - server.DetailedTunnelMetrics: - properties: - average_response_time_ms: - description: Average response time in ms - example: 25.5 - type: number - client_ip: - description: Client IP address - example: 192.168.1.100:52341 - type: string - connection_errors: - description: Connection errors - example: 2 - type: integer - connections_active: - description: Active connections - example: 1 - type: integer - connections_total: - description: Total connections made - example: 3 - type: integer - created_at: - description: Creation time - example: "2025-08-08T18:30:00Z" - type: string - data_errors: - description: Data errors - example: 1 - type: integer - last_activity: - description: Last activity time - example: "2025-08-08T20:30:00Z" - type: string - max_response_time_ms: - description: Maximum response time in ms - example: 150.8 - type: number - min_response_time_ms: - description: Minimum response time in ms - example: 5.2 - type: number - reconnect_count: - description: Number of reconnections - example: 2 - type: integer - requests_per_second: - description: Requests per second - example: 12.4 - type: number - timeout_count: - description: Number of timeouts - example: 5 - type: integer - total_bytes_in: - description: Total bytes received - example: 125430 - type: integer - total_bytes_out: - description: Total bytes sent - example: 987650 - type: integer - total_requests: - description: Total requests processed - example: 1542 - type: integer - total_requests_error: - description: Failed requests - example: 44 - type: integer - total_requests_success: - description: Successful requests - example: 1498 - type: integer - tunnel_id: - description: Tunnel identifier - example: my-tunnel - type: string - user_agent: - description: Client user agent - example: FunnelClient/1.0 - type: string - type: object - server.HealthData: - properties: - status: - description: Service status - example: healthy - type: string - timestamp: - description: Current timestamp - example: "2025-08-08T20:11:00Z" - type: string - uptime: - description: Server uptime duration - example: 6.4281425s - type: string - type: object - server.HistoricalDataPoint: - properties: - active_connections: - type: integer - bytes_in: - type: integer - bytes_out: - type: integer - error_count: - type: integer - request_count: - type: integer - response_time_ms: - type: number - timestamp: - type: string - type: object - server.HistoricalMetricsResponse: - properties: - data_points: - description: Historical data points - items: - $ref: '#/definitions/server.HistoricalDataPoint' - type: array - interval: - description: Data point interval - example: 1m - type: string - time_range: - description: Time range of data - example: 24h - type: string - tunnel_id: - description: Tunnel identifier - example: my-tunnel - type: string - type: object - server.ServerMetricsResponse: - properties: - active_tunnels: - description: Currently active tunnels - example: 8 - type: integer - average_response_time_ms: - description: Server average response time - example: 28.7 - type: number - cpu_usage_percent: - description: CPU usage percentage - example: 15.5 - type: number - error_rate_percent: - description: Error rate percentage - example: 0.18 - type: number - memory_usage_bytes: - description: Memory usage in bytes - example: 67108864 - type: integer - network_connections: - description: Active network connections - example: 12 - type: integer - server_requests_per_second: - description: Server-wide RPS - example: 45.2 - type: number - server_uptime: - description: Server uptime - example: 5h23m15s - type: string - total_bytes_transferred: - description: Total bytes transferred - example: 5670000 - type: integer - total_errors: - description: Total errors - example: 234 - type: integer - total_requests: - description: Total requests processed - example: 125430 - type: integer - total_tunnels: - description: Total tunnels created - example: 15 - type: integer - type: object - server.ServerStats: - properties: - active_tunnels: - description: Number of currently active tunnel connections - example: 2 - type: integer - cache_hit_rate: - description: Cache hit rate percentage - example: 90.9% - type: string - cache_hits: - description: Number of cache hits for hostname resolution - example: 1401 - type: integer - cache_misses: - description: Number of cache misses for hostname resolution - example: 141 - type: integer - router_stats: - additionalProperties: true - description: Detailed router statistics - type: object - total_requests: - description: Total number of HTTP requests processed - example: 1542 - type: integer - uptime: - description: Server uptime duration - example: 2h15m30s - type: string - type: object - server.TunnelDeleteData: - properties: - message: - description: Success message - example: tunnel deleted successfully - type: string - tunnel_id: - description: Deleted tunnel ID - example: my-tunnel - type: string - type: object - server.TunnelInfo: - properties: - bytes_received: - description: Total bytes received from client - example: 12543 - type: integer - bytes_sent: - description: Total bytes sent to client - example: 98765 - type: integer - created_at: - description: Tunnel creation timestamp - example: "2025-08-08T18:30:00Z" - type: string - id: - description: Unique tunnel identifier - example: my-tunnel - type: string - messages_received: - description: Number of messages received from client - example: 45 - type: integer - messages_sent: - description: Number of messages sent to client - example: 45 - type: integer - status: - description: Current tunnel status - enum: - - connected - - disconnected - example: connected - type: string - uptime: - description: Tunnel connection uptime - example: 1h15m30s - type: string - type: object - server.TunnelStats: - properties: - bytes_received: - description: Bytes received total - example: 12543 - type: integer - bytes_sent: - description: Bytes sent total - example: 98765 - type: integer - connection_info: - additionalProperties: true - description: Connection details - type: object - created_at: - description: Creation timestamp - example: "2025-08-08T18:30:00Z" - type: string - messages_received: - description: Messages received count - example: 45 - type: integer - messages_sent: - description: Messages sent count - example: 45 - type: integer - status: - description: Connection status - example: connected - type: string - tunnel_id: - description: Tunnel identifier - example: my-tunnel - type: string - uptime: - description: Connection uptime - example: 1h15m30s - type: string - type: object -host: localhost:8080 -info: - contact: - name: Funnel Support - url: https://github.com/karol-broda/funnel - description: |- - REST API for tunnel management and monitoring in the funnel tunneling solution - - This API provides endpoints for: - - Health checking - - Server statistics and monitoring - - Tunnel management (list, view, delete) - - Individual tunnel statistics - license: - name: MIT - url: https://github.com/karol-broda/funnel/blob/master/LICENSE.md - title: Funnel Server API - version: "1.0" -paths: - /health: - get: - consumes: - - application/json - description: Returns the health status of the server - produces: - - application/json - responses: - "200": - description: Server is healthy - schema: - allOf: - - $ref: '#/definitions/server.APIResponse' - - properties: - data: - $ref: '#/definitions/server.HealthData' - type: object - summary: Health check - tags: - - Server - /metrics: - get: - consumes: - - application/json - description: Returns detailed server-wide metrics including performance, resource - usage, and aggregated statistics - produces: - - application/json - responses: - "200": - description: Comprehensive server metrics - schema: - allOf: - - $ref: '#/definitions/server.APIResponse' - - properties: - data: - $ref: '#/definitions/server.ServerMetricsResponse' - type: object - summary: Get comprehensive server metrics - tags: - - Server - /metrics/historical: - get: - consumes: - - application/json - description: Returns time-series data for server-wide metrics over a specified - time range - parameters: - - default: 24h - description: Time range (1h, 24h, 7d) - in: query - name: range - type: string - - default: 5m - description: Data interval (1m, 5m, 1h) - in: query - name: interval - type: string - produces: - - application/json - responses: - "200": - description: Historical server metrics - schema: - allOf: - - $ref: '#/definitions/server.APIResponse' - - properties: - data: - $ref: '#/definitions/server.HistoricalMetricsResponse' - type: object - summary: Get historical server metrics - tags: - - Server - /server/stats: - get: - consumes: - - application/json - description: Returns comprehensive server-wide statistics including tunnel count, - request metrics, and cache performance - produces: - - application/json - responses: - "200": - description: Server statistics retrieved successfully - schema: - allOf: - - $ref: '#/definitions/server.APIResponse' - - properties: - data: - $ref: '#/definitions/server.ServerStats' - type: object - summary: Get server statistics - tags: - - Server - /tunnels: - get: - consumes: - - application/json - description: Returns a list of all currently active tunnel connections - produces: - - application/json - responses: - "200": - description: List of active tunnels - schema: - allOf: - - $ref: '#/definitions/server.APIResponse' - - properties: - data: - items: - $ref: '#/definitions/server.TunnelInfo' - type: array - type: object - summary: List active tunnels - tags: - - Tunnels - /tunnels/{tunnelId}: - delete: - consumes: - - application/json - description: Forcibly closes a tunnel connection - parameters: - - description: Tunnel ID to delete - in: path - maxLength: 63 - minLength: 3 - name: tunnelId - required: true - type: string - produces: - - application/json - responses: - "200": - description: Tunnel deleted successfully - schema: - allOf: - - $ref: '#/definitions/server.APIResponse' - - properties: - data: - $ref: '#/definitions/server.TunnelDeleteData' - type: object - "400": - description: Invalid tunnel ID format - schema: - $ref: '#/definitions/server.APIResponse' - "404": - description: Tunnel not found - schema: - $ref: '#/definitions/server.APIResponse' - summary: Delete tunnel - tags: - - Tunnels - get: - consumes: - - application/json - description: Returns detailed information about a specific tunnel - parameters: - - description: Tunnel ID (3-63 characters, lowercase letters, numbers, and hyphens - only) - in: path - maxLength: 63 - minLength: 3 - name: tunnelId - required: true - type: string - produces: - - application/json - responses: - "200": - description: Tunnel details retrieved successfully - schema: - allOf: - - $ref: '#/definitions/server.APIResponse' - - properties: - data: - $ref: '#/definitions/server.TunnelInfo' - type: object - "400": - description: Invalid tunnel ID format - schema: - $ref: '#/definitions/server.APIResponse' - "404": - description: Tunnel not found - schema: - $ref: '#/definitions/server.APIResponse' - summary: Get tunnel details - tags: - - Tunnels - /tunnels/{tunnelId}/historical: - get: - consumes: - - application/json - description: Returns time-series data for a specific tunnel over a specified - time range - parameters: - - description: Tunnel ID - in: path - maxLength: 63 - minLength: 3 - name: tunnelId - required: true - type: string - - default: 24h - description: Time range (1h, 24h, 7d) - in: query - name: range - type: string - - description: Start time (RFC3339 format) - in: query - name: start - type: string - - description: End time (RFC3339 format) - in: query - name: end - type: string - produces: - - application/json - responses: - "200": - description: Tunnel historical data - schema: - allOf: - - $ref: '#/definitions/server.APIResponse' - - properties: - data: - $ref: '#/definitions/server.HistoricalMetricsResponse' - type: object - "400": - description: Invalid parameters - schema: - $ref: '#/definitions/server.APIResponse' - "404": - description: Tunnel not found - schema: - $ref: '#/definitions/server.APIResponse' - summary: Get tunnel historical data - tags: - - Tunnels - /tunnels/{tunnelId}/metrics: - get: - consumes: - - application/json - description: Returns detailed performance and usage metrics for a specific tunnel - parameters: - - description: Tunnel ID - in: path - maxLength: 63 - minLength: 3 - name: tunnelId - required: true - type: string - produces: - - application/json - responses: - "200": - description: Comprehensive tunnel metrics - schema: - allOf: - - $ref: '#/definitions/server.APIResponse' - - properties: - data: - $ref: '#/definitions/server.DetailedTunnelMetrics' - type: object - "400": - description: Invalid tunnel ID format - schema: - $ref: '#/definitions/server.APIResponse' - "404": - description: Tunnel not found - schema: - $ref: '#/definitions/server.APIResponse' - summary: Get comprehensive tunnel metrics - tags: - - Tunnels - /tunnels/{tunnelId}/stats: - get: - consumes: - - application/json - description: Returns detailed statistics and metrics for a specific tunnel - parameters: - - description: Tunnel ID - in: path - maxLength: 63 - minLength: 3 - name: tunnelId - required: true - type: string - produces: - - application/json - responses: - "200": - description: Tunnel statistics retrieved successfully - schema: - allOf: - - $ref: '#/definitions/server.APIResponse' - - properties: - data: - $ref: '#/definitions/server.TunnelStats' - type: object - "400": - description: Invalid tunnel ID format - schema: - $ref: '#/definitions/server.APIResponse' - "404": - description: Tunnel not found - schema: - $ref: '#/definitions/server.APIResponse' - summary: Get tunnel statistics - tags: - - Tunnels -schemes: -- http -- https -swagger: "2.0" -tags: -- description: Server health and statistics endpoints - name: Server -- description: Tunnel management and monitoring endpoints - name: Tunnels diff --git a/server/go.mod b/server/go.mod deleted file mode 100644 index 215632c..0000000 --- a/server/go.mod +++ /dev/null @@ -1,214 +0,0 @@ -module github.com/karol-broda/funnel/server - -go 1.24.4 - -require ( - github.com/go-acme/lego/v4 v4.23.1 - github.com/google/uuid v1.6.0 - github.com/gorilla/websocket v1.5.3 - github.com/hashicorp/golang-lru/v2 v2.0.7 - github.com/karol-broda/funnel/shared v0.0.0-00010101000000-000000000000 - github.com/rs/zerolog v1.34.0 - github.com/stretchr/testify v1.10.0 - github.com/swaggo/http-swagger/v2 v2.0.2 - github.com/swaggo/swag v1.16.6 -) - -require ( - cloud.google.com/go/auth v0.15.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect - cloud.google.com/go/compute/metadata v0.6.0 // indirect - github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect - github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect - github.com/Azure/go-autorest v14.2.0+incompatible // indirect - github.com/Azure/go-autorest/autorest v0.11.30 // indirect - github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect - github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect - github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect - github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect - github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect - github.com/Azure/go-autorest/logger v0.2.1 // indirect - github.com/Azure/go-autorest/tracing v0.6.0 // indirect - github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 // indirect - github.com/KyleBanks/depth v1.2.1 // indirect - github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect - github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect - github.com/aliyun/alibaba-cloud-sdk-go v1.63.100 // indirect - github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect - github.com/aws/aws-sdk-go-v2/config v1.29.9 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.62 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.1 // indirect - github.com/aws/aws-sdk-go-v2/service/route53 v1.50.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 // indirect - github.com/aws/smithy-go v1.22.2 // indirect - github.com/baidubce/bce-sdk-go v0.9.223 // indirect - github.com/benbjohnson/clock v1.3.0 // indirect - github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/civo/civogo v0.3.11 // indirect - github.com/cloudflare/cloudflare-go v0.115.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dimchansky/utfbom v1.1.1 // indirect - github.com/dnsimple/dnsimple-go v1.7.0 // indirect - github.com/exoscale/egoscale/v3 v3.1.13 // indirect - github.com/fatih/structs v1.1.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.2 // indirect - github.com/ghodss/yaml v1.0.0 // indirect - github.com/go-errors/errors v1.0.1 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/jsonreference v0.20.0 // indirect - github.com/go-openapi/spec v0.20.6 // indirect - github.com/go-openapi/swag v0.19.15 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.16.0 // indirect - github.com/go-resty/resty/v2 v2.16.5 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect - github.com/goccy/go-json v0.10.5 // indirect - github.com/gofrs/flock v0.12.1 // indirect - github.com/golang-jwt/jwt/v4 v4.5.1 // indirect - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect - github.com/google/go-querystring v1.1.0 // indirect - github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.14.1 // indirect - github.com/gophercloud/gophercloud v1.14.1 // indirect - github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect - github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.141 // indirect - github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect - github.com/infobloxopen/infoblox-go-client/v2 v2.9.0 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect - github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect - github.com/kylelemons/godebug v1.1.0 // indirect - github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect - github.com/labbsr0x/goh v1.0.1 // indirect - github.com/leodido/go-urn v1.2.4 // indirect - github.com/linode/linodego v1.48.1 // indirect - github.com/liquidweb/liquidweb-cli v0.6.9 // indirect - github.com/liquidweb/liquidweb-go v1.6.4 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mailru/easyjson v0.7.6 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/miekg/dns v1.1.64 // indirect - github.com/mimuret/golang-iij-dpf v0.9.1 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect - github.com/nrdcg/auroradns v1.1.0 // indirect - github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 // indirect - github.com/nrdcg/desec v0.10.0 // indirect - github.com/nrdcg/dnspod-go v0.4.0 // indirect - github.com/nrdcg/freemyip v0.3.0 // indirect - github.com/nrdcg/goacmedns v0.2.0 // indirect - github.com/nrdcg/goinwx v0.10.0 // indirect - github.com/nrdcg/mailinabox v0.2.0 // indirect - github.com/nrdcg/namesilo v0.2.1 // indirect - github.com/nrdcg/nodion v0.1.0 // indirect - github.com/nrdcg/porkbun v0.4.0 // indirect - github.com/nzdjb/go-metaname v1.0.0 // indirect - github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect - github.com/oracle/oci-go-sdk/v65 v65.87.0 // indirect - github.com/ovh/go-ovh v1.7.0 // indirect - github.com/patrickmn/go-cache v2.1.0+incompatible // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect - github.com/peterhellberg/link v1.2.0 // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/pquerna/otp v1.4.0 // indirect - github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect - github.com/sacloud/api-client-go v0.2.10 // indirect - github.com/sacloud/go-http v0.1.8 // indirect - github.com/sacloud/iaas-api-go v1.14.0 // indirect - github.com/sacloud/packages-go v0.0.10 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/scaleway/scaleway-sdk-go v1.0.0-beta.32 // indirect - github.com/selectel/domains-go v1.1.0 // indirect - github.com/selectel/go-selvpcclient/v3 v3.2.1 // indirect - github.com/shopspring/decimal v1.3.1 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect - github.com/softlayer/softlayer-go v1.1.7 // indirect - github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect - github.com/sony/gobreaker v0.5.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect - github.com/spf13/viper v1.18.2 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - github.com/swaggo/files/v2 v2.0.0 // indirect - github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128 // indirect - github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128 // indirect - github.com/tjfoc/gmsm v1.4.1 // indirect - github.com/transip/gotransip/v6 v6.26.0 // indirect - github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec // indirect - github.com/vinyldns/go-vinyldns v0.9.16 // indirect - github.com/volcengine/volc-sdk-golang v1.0.199 // indirect - github.com/vultr/govultr/v3 v3.17.0 // indirect - github.com/yandex-cloud/go-genproto v0.0.0-20250319153614-fb9d3e5eb01a // indirect - github.com/yandex-cloud/go-sdk v0.0.0-20250320143332-9cbcfc5de4ae // indirect - go.mongodb.org/mongo-driver v1.13.1 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect - go.opentelemetry.io/otel v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - go.uber.org/ratelimit v0.3.0 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/oauth2 v0.28.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect - golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.32.0 // indirect - google.golang.org/api v0.227.0 // indirect - google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect - google.golang.org/grpc v1.71.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/ns1/ns1-go.v2 v2.13.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) - -replace github.com/karol-broda/funnel/shared => ../shared diff --git a/server/go.sum b/server/go.sum deleted file mode 100644 index 0f2499a..0000000 --- a/server/go.sum +++ /dev/null @@ -1,1424 +0,0 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= -cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= -cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= -cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 h1:Dy3M9aegiI7d7PF1LUdjbVigJReo+QOceYsMyFh9qoE= -github.com/AdamSLevy/jsonrpc2/v14 v14.1.0/go.mod h1:ZakZtbCXxCz82NJvq7MoREtiQesnDfrtF6RFUGzQfLo= -github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= -github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= -github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 h1:zLzoX5+W2l95UJoVwiyNS4dX8vHyQ6x2xRLoBBL9wMk= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= -github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= -github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= -github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= -github.com/Azure/go-autorest/autorest v0.11.30 h1:iaZ1RGz/ALZtN5eq4Nr1SOFSlf2E4pDI3Tcsl+dZPVE= -github.com/Azure/go-autorest/autorest v0.11.30/go.mod h1:t1kpPIOpIVX7annvothKvb0stsrXa37i7b+xpmBW8Fs= -github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= -github.com/Azure/go-autorest/autorest/adal v0.9.22 h1:/GblQdIudfEM3AWWZ0mrYJQSd7JS4S/Mbzh6F0ov0Xc= -github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= -github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= -github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= -github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 h1:w77/uPk80ZET2F+AfQExZyEWtn+0Rk/uw17m9fv5Ajc= -github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= -github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= -github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= -github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= -github.com/Azure/go-autorest/autorest/mocks v0.4.2 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= -github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= -github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc= -github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M= -github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= -github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= -github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= -github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= -github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= -github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/HdrHistogram/hdrhistogram-go v1.1.0/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= -github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= -github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= -github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= -github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 h1:xPMsUicZ3iosVPSIP7bW5EcGUzjiiMl1OYTe14y/R24= -github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87/go.mod h1:iGLljf5n9GjT6kc0HBvyI1nOKnGQbNB66VzSNbK5iks= -github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= -github.com/Shopify/sarama v1.30.1/go.mod h1:hGgx05L/DiW8XYBXeJdKIN6V2QUy2H6JqME5VT1NLRw= -github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= -github.com/Shopify/toxiproxy/v2 v2.1.6-0.20210914104332-15ea381dcdae/go.mod h1:/cvHQkZ1fst0EmZnA5dFtiQdWCNCFYzb+uE2vqVgvx0= -github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= -github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= -github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= -github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 h1:F1j7z+/DKEsYqZNoxC6wvfmaiDneLsQOFQmuq9NADSY= -github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2/go.mod h1:QlXr/TrICfQ/ANa76sLeQyhAJyNR9sEcfNuZBkY9jgY= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/aliyun/alibaba-cloud-sdk-go v1.63.100 h1:yUkCbrSM1cWtgBfRVKMQtdt22KhDvKY7g4V+92eG9wA= -github.com/aliyun/alibaba-cloud-sdk-go v1.63.100/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= -github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= -github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/config v1.29.9 h1:Kg+fAYNaJeGXp1vmjtidss8O2uXIsXwaRqsQJKXVr+0= -github.com/aws/aws-sdk-go-v2/config v1.29.9/go.mod h1:oU3jj2O53kgOU4TXq/yipt6ryiooYjlkqqVaZk7gY/U= -github.com/aws/aws-sdk-go-v2/credentials v1.17.62 h1:fvtQY3zFzYJ9CfixuAQ96IxDrBajbBWGqjNTCa79ocU= -github.com/aws/aws-sdk-go-v2/credentials v1.17.62/go.mod h1:ElETBxIQqcxej++Cs8GyPBbgMys5DgQPTwo7cUPDKt8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.1 h1:0j58UseBtLuBcP6nY2z4SM1qZEvLF0ylyH6+ggnphLg= -github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.1/go.mod h1:Qy22QnQSdHbZwMZrarsWZBIuK51isPlkD+Z4sztxX0o= -github.com/aws/aws-sdk-go-v2/service/route53 v1.50.0 h1:/nkJHXtJXJeelXHqG0898+fWKgvfaXBhGzbCsSmn9j8= -github.com/aws/aws-sdk-go-v2/service/route53 v1.50.0/go.mod h1:kGYOjvTa0Vw0qxrqrOLut1vMnui6qLxqv/SX3vYeM8Y= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 h1:8JdC7Gr9NROg1Rusk25IcZeTO59zLxsKgE0gkh5O6h0= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.1/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1 h1:KwuLovgQPcdjNMfFt9OhUd9a2OwcOKhxfvF4glTzLuA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= -github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= -github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= -github.com/baidubce/bce-sdk-go v0.9.223 h1:vvDeIemf7ePPP59nLHCntQ/vS++ok2HKbRPgmz1VZKU= -github.com/baidubce/bce-sdk-go v0.9.223/go.mod h1:zbYJMQwE4IZuyrJiFO8tO8NbtYiKTFTbwh4eIsqjVdg= -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= -github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= -github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= -github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/c-bata/go-prompt v0.2.5/go.mod h1:vFnjEGDIIA/Lib7giyE4E9c50Lvl8j0S+7FVlAwDAVw= -github.com/casbin/casbin/v2 v2.37.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg= -github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= -github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= -github.com/civo/civogo v0.3.11 h1:mON/fyrV946Sbk6paRtOSGsN+asCgCmHCgArf5xmGxM= -github.com/civo/civogo v0.3.11/go.mod h1:7+GeeFwc4AYTULaEshpT2vIcl3Qq8HPoxA17viX3l6g= -github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= -github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= -github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= -github.com/dnsimple/dnsimple-go v1.7.0 h1:JKu9xJtZ3SqOC+BuYgAWeab7+EEx0sz422vu8j611ZY= -github.com/dnsimple/dnsimple-go v1.7.0/go.mod h1:EKpuihlWizqYafSnQHGCd/gyvy3HkEQJ7ODB4KdV8T8= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= -github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= -github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/exoscale/egoscale/v3 v3.1.13 h1:CAGC7QRjp2AiGj01agsSD0VKCp4OZmW5f51vV2IguNQ= -github.com/exoscale/egoscale/v3 v3.1.13/go.mod h1:t9+MpSEam94na48O/xgvvPFpQPRiwZ3kBN4/UuQtKco= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= -github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= -github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= -github.com/franela/goblin v0.0.0-20210519012713-85d372ac71e2/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo= -github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= -github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= -github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-acme/lego/v4 v4.23.1 h1:lZ5fGtGESA2L9FB8dNTvrQUq3/X4QOb8ExkKyY7LSV4= -github.com/go-acme/lego/v4 v4.23.1/go.mod h1:7UMVR7oQbIYw6V7mTgGwi4Er7B6Ww0c+c8feiBM0EgI= -github.com/go-cmd/cmd v1.0.5/go.mod h1:y8q8qlK5wQibcw63djSl/ntiHUHXHGdCkPk0j4QeW4s= -github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= -github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs= -github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= -github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= -github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= -github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE= -github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= -github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= -github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw= -github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA= -github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= -github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= -github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= -github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= -github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= -github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= -github.com/gophercloud/gophercloud v1.14.1 h1:DTCNaTVGl8/cFu58O1JwWgis9gtISAFONqpMKNg/Vpw= -github.com/gophercloud/gophercloud v1.14.1/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= -github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 h1:sH7xkTfYzxIEgzq1tDHIMKRh1vThOEOGNsettdEeLbE= -github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56/go.mod h1:VSalo4adEk+3sNkmVJLnhHoOyOYYS8sTWLG4mv5BKto= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= -github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= -github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= -github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= -github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= -github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= -github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.141 h1:8i57QAi5u+iPAYze92bkIvZoHiS0J45ndul5glr/NE8= -github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.141/go.mod h1:Y/+YLCFCJtS29i2MbYPTUlNNfwXvkzEsZKR0imY/2aY= -github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df h1:MZf03xP9WdakyXhOWuAD5uPK3wHh96wCsqe3hCMKh8E= -github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df/go.mod h1:QMZY7/J/KSQEhKWFeDesPjMj+wCHReeknARU3wqlyN4= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/infobloxopen/infoblox-go-client/v2 v2.9.0 h1:wS8kTlQVeVbrepeY83s9X+XdSa6Qah5KO+tdW+zRQXU= -github.com/infobloxopen/infoblox-go-client/v2 v2.9.0/go.mod h1:NeNJpz09efw/edzqkVivGv1bWqBXTomqYBRFbP+XBqg= -github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= -github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= -github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= -github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= -github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= -github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= -github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= -github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc= -github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= -github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= -github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= -github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= -github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= -github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= -github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= -github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00= -github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/labbsr0x/bindman-dns-webhook v1.0.2 h1:I7ITbmQPAVwrDdhd6dHKi+MYJTJqPCK0jE6YNBAevnk= -github.com/labbsr0x/bindman-dns-webhook v1.0.2/go.mod h1:p6b+VCXIR8NYKpDr8/dg1HKfQoRHCdcsROXKvmoehKA= -github.com/labbsr0x/goh v1.0.1 h1:97aBJkDjpyBZGPbQuOK5/gHcSFbcr5aRsq3RSRJFpPk= -github.com/labbsr0x/goh v1.0.1/go.mod h1:8K2UhVoaWXcCU7Lxoa2omWnC8gyW8px7/lmO61c027w= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= -github.com/linode/linodego v1.48.1 h1:Ojw1S+K5jJr1dggO8/H6r4FINxXnJbOU5GkbpaTfmhU= -github.com/linode/linodego v1.48.1/go.mod h1:fc3t60If8X+yZTFAebhCnNDFrhwQhq9HDU92WnBousQ= -github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738/go.mod h1:0sYF9rMXb0vlG+4SzdiGMXHheCZxjguMq+Zb4S2BfBs= -github.com/liquidweb/liquidweb-cli v0.6.9 h1:acbIvdRauiwbxIsOCEMXGwF75aSJDbDiyAWPjVnwoYM= -github.com/liquidweb/liquidweb-cli v0.6.9/go.mod h1:cE1uvQ+x24NGUL75D0QagOFCG8Wdvmwu8aL9TLmA/eQ= -github.com/liquidweb/liquidweb-go v1.6.4 h1:6S0m3hHSpiLqGD7AFSb7lH/W/qr1wx+tKil9fgIbjMc= -github.com/liquidweb/liquidweb-go v1.6.4/go.mod h1:B934JPIIcdA+uTq2Nz5PgOtG6CuCaEvQKe/Ge/5GgZ4= -github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/magiconair/properties v1.8.4/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= -github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= -github.com/miekg/dns v1.1.47/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= -github.com/miekg/dns v1.1.64 h1:wuZgD9wwCE6XMT05UU/mlSko71eRSXEAm2EbjQXLKnQ= -github.com/miekg/dns v1.1.64/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= -github.com/mimuret/golang-iij-dpf v0.9.1 h1:Gj6EhHJkOhr+q2RnvRPJsPMcjuVnWPSccEHyoEehU34= -github.com/mimuret/golang-iij-dpf v0.9.1/go.mod h1:sl9KyOkESib9+KRD3HaGpgi1xk7eoN2+d96LCLsME2M= -github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= -github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= -github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 h1:o6uBwrhM5C8Ll3MAAxrQxRHEu7FkapwTuI2WmL1rw4g= -github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04/go.mod h1:5sN+Lt1CaY4wsPvgQH/jsuJi4XO2ssZbdsIizr4CVC8= -github.com/nats-io/jwt v1.2.2/go.mod h1:/xX356yQA6LuXI9xWW7mZNpxgF2mBmGecH+Fj34sP5Q= -github.com/nats-io/jwt/v2 v2.0.3/go.mod h1:VRP+deawSXyhNjXmxPCHskrR6Mq50BqpEI5SEcNiGlY= -github.com/nats-io/nats-server/v2 v2.5.0/go.mod h1:Kj86UtrXAL6LwYRA6H4RqzkHhK0Vcv2ZnKD5WbQ1t3g= -github.com/nats-io/nats.go v1.12.1/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w= -github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= -github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4= -github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nrdcg/auroradns v1.1.0 h1:KekGh8kmf2MNwqZVVYo/fw/ZONt8QMEmbMFOeljteWo= -github.com/nrdcg/auroradns v1.1.0/go.mod h1:O7tViUZbAcnykVnrGkXzIJTHoQCHcgalgAe6X1mzHfk= -github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 h1:ouZ2JWDl8IW5k1qugYbmpbmW8hn85Ig6buSMBRlz3KI= -github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3/go.mod h1:ZwadWt7mVhMHMbAQ1w8IhDqtWO3eWqWq72W7trnaiE8= -github.com/nrdcg/desec v0.10.0 h1:qrEDiqnsvNU9QE7lXIXi/tIHAfyaFXKxF2/8/52O8uM= -github.com/nrdcg/desec v0.10.0/go.mod h1:5+4vyhMRTs49V9CNoODF/HwT8Mwxv9DJ6j+7NekUnBs= -github.com/nrdcg/dnspod-go v0.4.0 h1:c/jn1mLZNKF3/osJ6mz3QPxTudvPArXTjpkmYj0uK6U= -github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ= -github.com/nrdcg/freemyip v0.3.0 h1:0D2rXgvLwe2RRaVIjyUcQ4S26+cIS2iFwnhzDsEuuwc= -github.com/nrdcg/freemyip v0.3.0/go.mod h1:c1PscDvA0ukBF0dwelU/IwOakNKnVxetpAQ863RMJoM= -github.com/nrdcg/goacmedns v0.2.0 h1:ADMbThobzEMnr6kg2ohs4KGa3LFqmgiBA22/6jUWJR0= -github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg= -github.com/nrdcg/goinwx v0.10.0 h1:6W630bjDxQD6OuXKqrFRYVpTt0G/9GXXm3CeOrN0zJM= -github.com/nrdcg/goinwx v0.10.0/go.mod h1:mnMSTi7CXBu2io4DzdOBoGFA1XclD0sEPWJaDhNgkA4= -github.com/nrdcg/mailinabox v0.2.0 h1:IKq8mfKiVwNW2hQii/ng1dJ4yYMMv3HAP3fMFIq2CFk= -github.com/nrdcg/mailinabox v0.2.0/go.mod h1:0yxqeYOiGyxAu7Sb94eMxHPIOsPYXAjTeA9ZhePhGnc= -github.com/nrdcg/namesilo v0.2.1 h1:kLjCjsufdW/IlC+iSfAqj0iQGgKjlbUUeDJio5Y6eMg= -github.com/nrdcg/namesilo v0.2.1/go.mod h1:lwMvfQTyYq+BbjJd30ylEG4GPSS6PII0Tia4rRpRiyw= -github.com/nrdcg/nodion v0.1.0 h1:zLKaqTn2X0aDuBHHfyA1zFgeZfiCpmu/O9DM73okavw= -github.com/nrdcg/nodion v0.1.0/go.mod h1:inbuh3neCtIWlMPZHtEpe43TmRXxHV6+hk97iCZicms= -github.com/nrdcg/porkbun v0.4.0 h1:rWweKlwo1PToQ3H+tEO9gPRW0wzzgmI/Ob3n2Guticw= -github.com/nrdcg/porkbun v0.4.0/go.mod h1:/QMskrHEIM0IhC/wY7iTCUgINsxdT2WcOphktJ9+Q54= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/nzdjb/go-metaname v1.0.0 h1:sNASlZC1RM3nSudtBTE1a3ZVTDyTpjqI5WXRPrdZ9Hg= -github.com/nzdjb/go-metaname v1.0.0/go.mod h1:0GR0LshZax1Lz4VrOrfNSE4dGvTp7HGjiemdczXT2H4= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.20.1 h1:YlVIbqct+ZmnEph770q9Q7NVAz4wwIiVNahee6JyUzo= -github.com/onsi/ginkgo/v2 v2.20.1/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= -github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= -github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= -github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= -github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= -github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE= -github.com/oracle/oci-go-sdk/v65 v65.87.0 h1:CeVuK8t0dYODGT3P9IDhz4vyXF8poYE1ijoiO5vrKl0= -github.com/oracle/oci-go-sdk/v65 v65.87.0/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0= -github.com/ovh/go-ovh v1.7.0 h1:V14nF7FwDjQrZt9g7jzcvAAQ3HN6DNShRFRMC3jLoPw= -github.com/ovh/go-ovh v1.7.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= -github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/performancecopilot/speed/v4 v4.0.0/go.mod h1:qxrSyuDGrTOWfV+uKRFhfxw6h/4HXRGUiZiufxo49BM= -github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= -github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= -github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= -github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= -github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= -github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= -github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= -github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= -github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= -github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= -github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= -github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= -github.com/regfish/regfish-dnsapi-go v0.1.1 h1:TJFtbePHkd47q5GZwYl1h3DIYXmoxdLjW/SBsPtB5IE= -github.com/regfish/regfish-dnsapi-go v0.1.1/go.mod h1:ubIgXSfqarSnl3XHSn8hIFwFF3h0yrq0ZiWD93Y2VjY= -github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sacloud/api-client-go v0.2.10 h1:+rv3jDohD+pkdYwOTBiB+jZsM0xK3AxadXRzhp3q66c= -github.com/sacloud/api-client-go v0.2.10/go.mod h1:Jj3CTy2+O4bcMedVDXlbHuqqche85HEPuVXoQFhLaRc= -github.com/sacloud/go-http v0.1.8 h1:ynreWA/vnM8G2ksbMlmefBHsXURKPz49qlPRqQ9IQdw= -github.com/sacloud/go-http v0.1.8/go.mod h1:7TL7TN1fnPKHsMifIqURDkGujnKViCgEz5Ei/LQdFK8= -github.com/sacloud/iaas-api-go v1.14.0 h1:xjkFWqdo4ilTrKPNNYBNWR/CZ/kVRsJrdAHAad6J/AQ= -github.com/sacloud/iaas-api-go v1.14.0/go.mod h1:C8os2Mnj0TOmMdSllwhaDWKMVG2ysFnpe69kyA4M3V0= -github.com/sacloud/packages-go v0.0.10 h1:UiQGjy8LretewkRhsuna1TBM9Vz/l9FoYpQx+D+AOck= -github.com/sacloud/packages-go v0.0.10/go.mod h1:f8QITBh9z4IZc4yE9j21Q8b0sXEMwRlRmhhjWeDVTYs= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.32 h1:4+LP7qmsLSGbmc66m1s5dKRMBwztRppfxFKlYqYte/c= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.32/go.mod h1:kzh+BSAvpoyHHdHBCDhmSWtBc1NbLMZ2lWHqnBoxFks= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/selectel/domains-go v1.1.0 h1:futG50J43ALLKQAnZk9H9yOtLGnSUh7c5hSvuC5gSHo= -github.com/selectel/domains-go v1.1.0/go.mod h1:SugRKfq4sTpnOHquslCpzda72wV8u0cMBHx0C0l+bzA= -github.com/selectel/go-selvpcclient/v3 v3.2.1 h1:ny6WIAMiHzKxOgOEnwcWE79wIQij1AHHylzPA41MXCw= -github.com/selectel/go-selvpcclient/v3 v3.2.1/go.mod h1:3EfSf8aEWyhspOGbvZ6mvnFg7JN5uckxNyBFPGWsXNQ= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= -github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= -github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 h1:hp2CYQUINdZMHdvTdXtPOY2ainKl4IoMcpAXEf2xj3Q= -github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/smartystreets/gunit v1.0.4 h1:tpTjnuH7MLlqhoD21vRoMZbMIi5GmBsAJDFyF67GhZA= -github.com/smartystreets/gunit v1.0.4/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ= -github.com/softlayer/softlayer-go v1.1.7 h1:SgTL+pQZt1h+5QkAhVmHORM/7N9c1X0sljJhuOIHxWE= -github.com/softlayer/softlayer-go v1.1.7/go.mod h1:WeJrBLoTJcaT8nO1azeyHyNpo/fDLtbpbvh+pzts+Qw= -github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e h1:3OgWYFw7jxCZPcvAg+4R8A50GZ+CCkARF10lxu2qDsQ= -github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e/go.mod h1:fKZCUVdirrxrBpwd9wb+lSoVixvpwAu8eHzbQB2tums= -github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= -github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= -github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= -github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/afero v1.4.1/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= -github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= -github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= -github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= -github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= -github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= -github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ= -github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= -github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128 h1:NGnqDc8FQL0YdiCHgTO4Wkso6ToD8rE3JW9VOzoPBNA= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128 h1:mrJ5Fbkd7sZIJ5F6oRfh5zebPQaudPH9Y0+GUmFytYU= -github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128/go.mod h1:zbsYIBT+VTX4z4ocjTAdLBIWyNYj3z0BRqd0iPdnjsk= -github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= -github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/transip/gotransip/v6 v6.26.0 h1:Aejfvh8rSp8Mj2GX/RpdBjMCv+Iy/DmgfNgczPDP550= -github.com/transip/gotransip/v6 v6.26.0/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s= -github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= -github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= -github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= -github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= -github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= -github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec h1:2s/ghQ8wKE+UzD/hf3P4Gd1j0JI9ncbxv+nsypPoUYI= -github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec/go.mod h1:BZr7Qs3ku1ckpqed8tCRSqTlp8NAeZfAVpfx4OzXMss= -github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= -github.com/vinyldns/go-vinyldns v0.9.16 h1:GZJStDkcCk1F1AcRc64LuuMh+ENL8pHA0CVd4ulRMcQ= -github.com/vinyldns/go-vinyldns v0.9.16/go.mod h1:5qIJOdmzAnatKjurI+Tl4uTus7GJKJxb+zitufjHs3Q= -github.com/volcengine/volc-sdk-golang v1.0.199 h1:zv9QOqTl/IsLwtfC37GlJtcz6vMAHi+pjq8ILWjLYUc= -github.com/volcengine/volc-sdk-golang v1.0.199/go.mod h1:stZX+EPgv1vF4nZwOlEe8iGcriUPRBKX8zA19gXycOQ= -github.com/vultr/govultr/v3 v3.17.0 h1:His5Jh5N8KKqaJxfy3uG6jQbLXy0TmQhNxOiRvkKk00= -github.com/vultr/govultr/v3 v3.17.0/go.mod h1:q34Wd76upKmf+vxFMgaNMH3A8BbsPBmSYZUGC8oZa5w= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= -github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= -github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/yandex-cloud/go-genproto v0.0.0-20250319153614-fb9d3e5eb01a h1:YO8gGyAV4N5SR3NzloZ1128IahSpXWr78oU7aEe7f04= -github.com/yandex-cloud/go-genproto v0.0.0-20250319153614-fb9d3e5eb01a/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= -github.com/yandex-cloud/go-sdk v0.0.0-20250320143332-9cbcfc5de4ae h1:x+uGuST05LVlgCxF5TsP8kQCCTW7uIeAQJ1dKtSmWqE= -github.com/yandex-cloud/go-sdk v0.0.0-20250320143332-9cbcfc5de4ae/go.mod h1:V71iJlJnS/NtNNdg/B7SwccBS19aXxwY3fv/wut9D74= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0= -go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= -go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -go.uber.org/ratelimit v0.3.0 h1:IdZd9wqvFXnvLvSEBo0KPcGfkoBGNkpTHlrE3Rcjkjw= -go.uber.org/ratelimit v0.3.0/go.mod h1:So5LG7CV1zWpY1sHe+DXTJqQvOx+FFPFaAs2SnoyBaI= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210920023735-84f357641f63/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU= -golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= -golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= -golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201110211018-35f3e6cf4a65/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= -gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= -gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc= -google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 h1:Q3nlH8iSQSRUwOskjbcSMcF2jiYMNiQYZ0c2KEJLKKU= -google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38/go.mod h1:xBI+tzfqGGN2JBeSebfKXFSdBpWVQ7sLW40PTupVRm4= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= -google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= -gopkg.in/h2non/gock.v1 v1.0.15 h1:SzLqcIlb/fDfg7UvukMpNcWsu7sI5tWwL+KCATZqks0= -gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= -gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/ns1/ns1-go.v2 v2.13.0 h1:I5NNqI9Bi1SGK92TVkOvLTwux5LNrix/99H2datVh48= -gopkg.in/ns1/ns1-go.v2 v2.13.0/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= -gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/server/router.go b/server/router.go deleted file mode 100644 index 6274fc2..0000000 --- a/server/router.go +++ /dev/null @@ -1,550 +0,0 @@ -package server - -import ( - "fmt" - "io" - "net/http" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/google/uuid" - "github.com/karol-broda/funnel/shared" -) - -type TunnelRouter struct { - server *Server - - hostCache sync.Map // map[string]string (host -> subdomain) - - requests int64 - cacheHits int64 - cacheMisses int64 - - bufferPool sync.Pool - apiHandler *APIHandler -} - -func NewTunnelRouter(server *Server) *TunnelRouter { - logger := shared.GetLogger("server.router") - - router := &TunnelRouter{ - server: server, - bufferPool: sync.Pool{ - New: func() interface{} { - b := make([]byte, 1024) - return &b - }, - }, - } - - logger.Info(). - Int("buffer_pool_size", 1024). - Msg("tunnel router initialized") - - return router -} - -func (tr *TunnelRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { - requestStart := time.Now() - requestUUID, err := uuid.NewV7() - if err != nil { - requestUUID = uuid.New() - } - requestID := requestUUID.String() - - logger := shared.GetLogger("server.router") - - atomic.AddInt64(&tr.requests, 1) - currentRequests := atomic.LoadInt64(&tr.requests) - - logger.Info(). - Str("request_id", requestID). - Str("method", r.Method). - Str("host", r.Host). - Str("path", r.URL.Path). - Str("remote_addr", r.RemoteAddr). - Str("user_agent", r.UserAgent()). - Int64("total_requests", currentRequests). - Msg("incoming HTTP request") - - // handle websocket upgrade requests first - if len(r.Header["Upgrade"]) > 0 && r.Header["Upgrade"][0] == "websocket" { - logger.Debug().Str("request_id", requestID).Msg("routing to websocket handler") - tr.server.HandleWebSocket(w, r) - return - } - - // handle swagger UI requests - if strings.HasPrefix(r.URL.Path, "/swagger/") { - logger.Debug().Str("request_id", requestID).Msg("routing to Swagger UI handler") - tr.ServeSwaggerUI(w, r) - return - } - - // handle API requests - if strings.HasPrefix(r.URL.Path, "/api/") { - logger.Debug().Str("request_id", requestID).Msg("routing to API handler") - if tr.apiHandler != nil { - tr.apiHandler.HandleAPIRequest(w, r) - } else { - http.Error(w, "API handler not initialized", http.StatusInternalServerError) - } - return - } - - subdomainStart := time.Now() - subdomain := tr.getSubdomain(r.Host) - subdomainDuration := time.Since(subdomainStart) - - if subdomain == "" { - processingDuration := time.Since(requestStart) - logger.Warn(). - Str("request_id", requestID). - Str("host", r.Host). - Dur("processing_time", processingDuration). - Msg("tunnel not found - invalid host") - http.Error(w, "tunnel not found", http.StatusNotFound) - return - } - - logger.Debug(). - Str("request_id", requestID). - Str("subdomain", subdomain). - Dur("subdomain_extraction_time", subdomainDuration). - Msg("extracted subdomain from host") - - tunnel, exists := tr.server.GetTunnel(subdomain) - if !exists { - processingDuration := time.Since(requestStart) - logger.Warn(). - Str("request_id", requestID). - Str("subdomain", subdomain). - Dur("processing_time", processingDuration). - Msg("tunnel not found - no active tunnel") - http.Error(w, "tunnel not found", http.StatusNotFound) - return - } - - logger.Debug(). - Str("request_id", requestID). - Str("tunnel_id", subdomain). - Msg("found active tunnel, routing request") - - tr.handleTunnelRequest(w, r, tunnel, requestID, requestStart) -} - -func (tr *TunnelRouter) getSubdomain(host string) string { - logger := shared.GetLogger("server.router") - - if cached, ok := tr.hostCache.Load(host); ok { - atomic.AddInt64(&tr.cacheHits, 1) - hits := atomic.LoadInt64(&tr.cacheHits) - logger.Debug(). - Str("host", host). - Str("cached_subdomain", cached.(string)). - Int64("total_cache_hits", hits). - Msg("subdomain cache hit") - return cached.(string) - } - - atomic.AddInt64(&tr.cacheMisses, 1) - misses := atomic.LoadInt64(&tr.cacheMisses) - - logger.Debug(). - Str("host", host). - Int64("total_cache_misses", misses). - Msg("subdomain cache miss") - - subdomain := tr.extractSubdomain(host) - - if subdomain != "" { - tr.hostCache.Store(host, subdomain) - logger.Debug(). - Str("host", host). - Str("extracted_subdomain", subdomain). - Msg("subdomain cached") - } else { - logger.Debug(). - Str("host", host). - Msg("no valid subdomain found") - } - - return subdomain -} - -func (tr *TunnelRouter) extractSubdomain(host string) string { - if len(host) == 0 { - return "" - } - - hostLen := len(host) - for i := hostLen - 1; i >= 0; i-- { - if host[i] == ':' { - host = host[:i] - break - } - if host[i] < '0' || host[i] > '9' { - break - } - } - - for i := 0; i < len(host); i++ { - if host[i] == '.' { - if i == 0 { - return "" - } - return host[:i] - } - } - - return "" -} - -func (tr *TunnelRouter) handleTunnelRequest(w http.ResponseWriter, r *http.Request, tunnel *Tunnel, requestID string, requestStart time.Time) { - logger := shared.GetRequestLogger("server.router", tunnel.ID, requestID) - - bodyReadStart := time.Now() - body, err := tr.readBody(r) - bodyReadDuration := time.Since(bodyReadStart) - - if err != nil { - processingDuration := time.Since(requestStart) - logger.Error().Err(err). - Dur("body_read_duration", bodyReadDuration). - Dur("total_processing_time", processingDuration). - Msg("failed to read request body") - http.Error(w, "failed to read body", http.StatusInternalServerError) - return - } - - logger.Debug(). - Int("body_size", len(body)). - Dur("body_read_time", bodyReadDuration). - Msg("request body read successfully") - - headers := tr.prepareForwardingHeaders(r) - - msg := &shared.Message{ - Type: "request", - TunnelID: tunnel.ID, - RequestID: requestID, - Method: r.Method, - Path: r.URL.String(), - Headers: headers, - Body: body, - } - - channelStart := time.Now() - respChan := tunnel.registerResponseChannel(msg.RequestID) - defer tunnel.unregisterResponseChannel(msg.RequestID) - channelDuration := time.Since(channelStart) - - sendStart := time.Now() - if err := tunnel.SendMessage(msg); err != nil { - sendDuration := time.Since(sendStart) - processingDuration := time.Since(requestStart) - logger.Error().Err(err). - Dur("send_duration", sendDuration). - Dur("total_processing_time", processingDuration). - Msg("failed to send message to tunnel") - http.Error(w, "tunnel connection lost", http.StatusBadGateway) - return - } - sendDuration := time.Since(sendStart) - - logger.Debug(). - Dur("channel_setup_time", channelDuration). - Dur("message_send_time", sendDuration). - Msg("request forwarded to tunnel") - - waitStart := time.Now() - timeout := 30 * time.Second - - select { - case resp := <-respChan: - waitDuration := time.Since(waitStart) - if resp != nil { - logger.Info(). - Int("status_code", resp.Status). - Int("response_size", len(resp.Body)). - Dur("tunnel_response_time", waitDuration). - Dur("total_request_time", time.Since(requestStart)). - Msg("received response from tunnel") - tr.writeResponse(w, resp, requestID, requestStart) - } else { - processingDuration := time.Since(requestStart) - logger.Error(). - Dur("wait_duration", waitDuration). - Dur("total_processing_time", processingDuration). - Msg("received nil response from tunnel") - http.Error(w, "tunnel connection lost", http.StatusBadGateway) - } - case <-time.After(timeout): - processingDuration := time.Since(requestStart) - logger.Error(). - Dur("timeout_duration", timeout). - Dur("wait_duration", time.Since(waitStart)). - Dur("total_processing_time", processingDuration). - Msg("request timeout waiting for tunnel response") - http.Error(w, "request timed out", http.StatusGatewayTimeout) - - case <-r.Context().Done(): - processingDuration := time.Since(requestStart) - logger.Warn(). - Dur("wait_duration", time.Since(waitStart)). - Dur("total_processing_time", processingDuration). - Msg("client closed connection") - http.Error(w, "client closed connection", 499) - } -} - -func (tr *TunnelRouter) prepareForwardingHeaders(r *http.Request) map[string][]string { - logger := shared.GetLogger("server.router") - - headers := make(map[string][]string) - for k, v := range r.Header { - headers[k] = make([]string, len(v)) - copy(headers[k], v) - } - - clientIP := tr.getClientIP(r) - - currentIP := r.RemoteAddr - if colonIndex := strings.LastIndex(currentIP, ":"); colonIndex != -1 { - currentIP = currentIP[:colonIndex] - } - if strings.HasPrefix(currentIP, "[") && strings.HasSuffix(currentIP, "]") { - currentIP = currentIP[1 : len(currentIP)-1] - } - - if existingForwardedFor := headers["X-Forwarded-For"]; len(existingForwardedFor) > 0 { - headers["X-Forwarded-For"] = []string{existingForwardedFor[0] + ", " + currentIP} - } else { - headers["X-Forwarded-For"] = []string{currentIP} - } - - if r.Host != "" { - headers["X-Forwarded-Host"] = []string{r.Host} - } - - proto := "http" - if r.TLS != nil { - proto = "https" - } - if existingProto := r.Header.Get("X-Forwarded-Proto"); existingProto != "" { - proto = existingProto - } - headers["X-Forwarded-Proto"] = []string{proto} - - headers["X-Real-IP"] = []string{clientIP} - - if r.Header.Get("X-Forwarded-Server") == "" { - headers["X-Forwarded-Server"] = []string{r.Host} - } - - logger.Debug(). - Str("client_ip", clientIP). - Str("forwarded_host", r.Host). - Str("forwarded_proto", proto). - Str("forwarded_for", headers["X-Forwarded-For"][0]). - Msg("prepared forwarding headers") - - return headers -} - -func (tr *TunnelRouter) getClientIP(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - if commaIndex := strings.Index(xff, ","); commaIndex != -1 { - return strings.TrimSpace(xff[:commaIndex]) - } - return strings.TrimSpace(xff) - } - - if xri := r.Header.Get("X-Real-IP"); xri != "" { - return strings.TrimSpace(xri) - } - - if xf := r.Header.Get("X-Forwarded"); xf != "" { - if forIndex := strings.Index(xf, "for="); forIndex != -1 { - start := forIndex + 4 - end := strings.Index(xf[start:], ";") - if end == -1 { - end = len(xf) - } else { - end += start - } - return strings.TrimSpace(xf[start:end]) - } - } - - ip := r.RemoteAddr - - if strings.HasPrefix(ip, "[") { - endBracket := strings.Index(ip, "]") - if endBracket != -1 { - return ip[1:endBracket] - } - } - - if colonIndex := strings.LastIndex(ip, ":"); colonIndex != -1 { - if strings.Count(ip, ":") > 1 { - return ip - } - return ip[:colonIndex] - } - - return ip -} - -func (tr *TunnelRouter) readBody(r *http.Request) ([]byte, error) { - logger := shared.GetLogger("server.router") - - if r == nil || r.Body == nil { - logger.Debug().Msg("no request body to read") - return nil, nil - } - - defer r.Body.Close() - - if r.ContentLength == 0 { - logger.Debug().Msg("zero content length, skipping body read") - return nil, nil - } - - if r.ContentLength > 0 && r.ContentLength < 1024 { - logger.Debug(). - Int64("content_length", r.ContentLength). - Msg("using buffer pool for small body") - - bufPtr := tr.bufferPool.Get().(*[]byte) - buf := *bufPtr - defer func() { - for i := range buf { - buf[i] = 0 - } - tr.bufferPool.Put(bufPtr) - }() - - n, err := io.ReadFull(r.Body, buf[:r.ContentLength]) - if err != nil { - logger.Error().Err(err).Int("bytes_read", n).Msg("failed to read small body") - return nil, err - } - return buf[:n], nil - } - - logger.Debug(). - Int64("content_length", r.ContentLength). - Msg("reading large body with ReadAll") - - body, err := io.ReadAll(r.Body) - if err != nil { - logger.Error().Err(err). - Int64("content_length", r.ContentLength). - Msg("error reading large body") - return nil, err - } - - logger.Debug(). - Int("bytes_read", len(body)). - Msg("large body read successfully") - return body, nil -} - -func (tr *TunnelRouter) writeResponse(w http.ResponseWriter, resp *shared.Message, requestID string, requestStart time.Time) { - logger := shared.GetLogger("server.router") - - writeStart := time.Now() - - headerCount := 0 - for k, values := range resp.Headers { - headerCount += len(values) - for _, v := range values { - w.Header().Add(k, v) - } - } - - if resp.Status > 0 { - w.WriteHeader(resp.Status) - } - - bytesWritten := 0 - if len(resp.Body) > 0 { - n, err := w.Write(resp.Body) - bytesWritten = n - if err != nil { - logger.Error().Err(err). - Str("request_id", requestID). - Int("bytes_written", bytesWritten). - Msg("error writing response body") - return - } - } - - writeDuration := time.Since(writeStart) - totalDuration := time.Since(requestStart) - - logger.Info(). - Str("request_id", requestID). - Int("status_code", resp.Status). - Int("header_count", headerCount). - Int("bytes_written", bytesWritten). - Dur("write_duration", writeDuration). - Dur("total_request_duration", totalDuration). - Msg("response written successfully") -} - -func (tr *TunnelRouter) InvalidateCache(tunnelID string) { - logger := shared.GetLogger("server.router") - - invalidatedCount := 0 - tr.hostCache.Range(func(key, value interface{}) bool { - if value.(string) == tunnelID { - tr.hostCache.Delete(key) - invalidatedCount++ - } - return true - }) - - logger.Info(). - Str("tunnel_id", tunnelID). - Int("invalidated_entries", invalidatedCount). - Msg("cache invalidated for tunnel") -} - -func (tr *TunnelRouter) GetStats() map[string]interface{} { - requests := atomic.LoadInt64(&tr.requests) - hits := atomic.LoadInt64(&tr.cacheHits) - misses := atomic.LoadInt64(&tr.cacheMisses) - - hitRate := float64(0) - if hits+misses > 0 { - hitRate = float64(hits) / float64(hits+misses) * 100 - } - - stats := map[string]interface{}{ - "total_requests": requests, - "cache_hits": hits, - "cache_misses": misses, - "cache_hit_rate": fmt.Sprintf("%.1f%%", hitRate), - } - - logger := shared.GetLogger("server.router") - logger.Debug(). - Int64("total_requests", requests). - Int64("cache_hits", hits). - Int64("cache_misses", misses). - Float64("hit_rate", hitRate). - Msg("router statistics requested") - - return stats -} - -// SetAPIHandler sets the API handler for the router -func (tr *TunnelRouter) SetAPIHandler(apiHandler *APIHandler) { - logger := shared.GetLogger("server.router") - tr.apiHandler = apiHandler - logger.Debug().Msg("API handler configured for router") -} diff --git a/server/router_bench_test.go b/server/router_bench_test.go deleted file mode 100644 index 4820355..0000000 --- a/server/router_bench_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package server - -import ( - "strings" - "testing" -) - -func BenchmarkSubdomainExtraction(b *testing.B) { - server := NewServer() - tunnelRouter := NewTunnelRouter(server) - testHost := "K7mN9pXq.localhost:8080" - - b.Run("TunnelRouter", func(b *testing.B) { - b.ReportAllocs() - for i := 0; i < b.N; i++ { - tunnelRouter.extractSubdomain(testHost) - } - }) - - b.Run("StringsSplit", func(b *testing.B) { - b.ReportAllocs() - for i := 0; i < b.N; i++ { - host := testHost - if idx := strings.Index(host, ":"); idx != -1 { - host = host[:idx] - } - parts := strings.Split(host, ".") - if len(parts) >= 2 { - _ = parts[0] - } - } - }) -} - -func BenchmarkCacheEfficiency(b *testing.B) { - server := NewServer() - tunnelRouter := NewTunnelRouter(server) - - testHost := "cached-tunnel.localhost:8080" - tunnelRouter.getSubdomain(testHost) - - b.ResetTimer() - b.ReportAllocs() - - for i := 0; i < b.N; i++ { - tunnelRouter.getSubdomain(testHost) - } -} - -func TestTunnelRouterFunctionality(t *testing.T) { - server := NewServer() - tunnelRouter := NewTunnelRouter(server) - - tests := []struct { - host string - expected string - }{ - {"K7mN9pXq.localhost:8080", "K7mN9pXq"}, - {"test-app.example.com", "test-app"}, - {"abc123.localhost", "abc123"}, - {"localhost:8080", ""}, - {"invalid", ""}, - {".localhost", ""}, - {"", ""}, - } - - for _, tt := range tests { - result := tunnelRouter.getSubdomain(tt.host) - if result != tt.expected { - t.Errorf("getSubdomain(%s) = %s, want %s", tt.host, result, tt.expected) - } - } -} - -func TestTunnelRouterStats(t *testing.T) { - server := NewServer() - tunnelRouter := NewTunnelRouter(server) - - host1 := "test1.localhost:8080" - host2 := "test2.localhost:8080" - - tunnelRouter.getSubdomain(host1) - tunnelRouter.getSubdomain(host2) - - tunnelRouter.getSubdomain(host1) - - stats := tunnelRouter.GetStats() - - if stats["cache_hits"].(int64) != 1 { - t.Errorf("Expected 1 cache hit, got %v", stats["cache_hits"]) - } - - if stats["cache_misses"].(int64) != 2 { - t.Errorf("Expected 2 cache misses, got %v", stats["cache_misses"]) - } -} diff --git a/server/router_header_test.go b/server/router_header_test.go deleted file mode 100644 index 815cdd0..0000000 --- a/server/router_header_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package server - -import ( - "crypto/tls" - "net/http/httptest" - "testing" -) - -func TestTunnelRouter_getClientIP(t *testing.T) { - server := &Server{ - Tunnels: make(map[string]*Tunnel), - } - router := NewTunnelRouter(server) - - tests := []struct { - name string - headers map[string]string - remoteAddr string - expectedIP string - }{ - { - name: "X-Forwarded-For single IP", - headers: map[string]string{ - "X-Forwarded-For": "192.168.1.100", - }, - remoteAddr: "10.0.0.1:12345", - expectedIP: "192.168.1.100", - }, - { - name: "X-Forwarded-For multiple IPs", - headers: map[string]string{ - "X-Forwarded-For": "192.168.1.100, 10.0.0.50, 172.16.0.1", - }, - remoteAddr: "10.0.0.1:12345", - expectedIP: "192.168.1.100", - }, - { - name: "X-Real-IP when no X-Forwarded-For", - headers: map[string]string{ - "X-Real-IP": "203.0.113.45", - }, - remoteAddr: "10.0.0.1:12345", - expectedIP: "203.0.113.45", - }, - { - name: "X-Forwarded header format", - headers: map[string]string{ - "X-Forwarded": "for=198.51.100.17;proto=http", - }, - remoteAddr: "10.0.0.1:12345", - expectedIP: "198.51.100.17", - }, - { - name: "X-Forwarded header with semicolon", - headers: map[string]string{ - "X-Forwarded": "for=198.51.100.17;host=example.com;proto=https", - }, - remoteAddr: "10.0.0.1:12345", - expectedIP: "198.51.100.17", - }, - { - name: "fallback to RemoteAddr with port", - headers: map[string]string{}, - remoteAddr: "203.0.113.195:54321", - expectedIP: "203.0.113.195", - }, - { - name: "fallback to RemoteAddr IPv6 with brackets", - headers: map[string]string{}, - remoteAddr: "[2001:db8::1]:8080", - expectedIP: "2001:db8::1", - }, - { - name: "fallback to RemoteAddr IPv6 without port", - headers: map[string]string{}, - remoteAddr: "2001:db8::1", - expectedIP: "2001:db8::1", - }, - { - name: "X-Forwarded-For takes precedence over X-Real-IP", - headers: map[string]string{ - "X-Forwarded-For": "192.168.1.100", - "X-Real-IP": "203.0.113.45", - }, - remoteAddr: "10.0.0.1:12345", - expectedIP: "192.168.1.100", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "http://example.com/test", nil) - req.RemoteAddr = tt.remoteAddr - - for k, v := range tt.headers { - req.Header.Set(k, v) - } - - result := router.getClientIP(req) - if result != tt.expectedIP { - t.Errorf("getClientIP() = %v, want %v", result, tt.expectedIP) - } - }) - } -} - -func TestTunnelRouter_prepareForwardingHeaders(t *testing.T) { - server := &Server{ - Tunnels: make(map[string]*Tunnel), - } - router := NewTunnelRouter(server) - - tests := []struct { - name string - originalHeaders map[string][]string - host string - remoteAddr string - tls bool - expectedHeaders map[string]string - }{ - { - name: "basic forwarding headers", - originalHeaders: map[string][]string{ - "User-Agent": {"TestClient/1.0"}, - "Accept": {"application/json"}, - "Content-Type": {"application/json"}, - }, - host: "api.example.com", - remoteAddr: "192.168.1.100:12345", - tls: false, - expectedHeaders: map[string]string{ - "X-Forwarded-For": "192.168.1.100", - "X-Forwarded-Host": "api.example.com", - "X-Forwarded-Proto": "http", - "X-Real-IP": "192.168.1.100", - "X-Forwarded-Server": "api.example.com", - "User-Agent": "TestClient/1.0", - "Accept": "application/json", - "Content-Type": "application/json", - }, - }, - { - name: "HTTPS request", - originalHeaders: map[string][]string{ - "User-Agent": {"TestClient/1.0"}, - }, - host: "secure.example.com", - remoteAddr: "203.0.113.45:54321", - tls: true, - expectedHeaders: map[string]string{ - "X-Forwarded-For": "203.0.113.45", - "X-Forwarded-Host": "secure.example.com", - "X-Forwarded-Proto": "https", - "X-Real-IP": "203.0.113.45", - "X-Forwarded-Server": "secure.example.com", - "User-Agent": "TestClient/1.0", - }, - }, - { - name: "existing X-Forwarded-For chain", - originalHeaders: map[string][]string{ - "X-Forwarded-For": {"192.168.1.50, 10.0.0.25"}, - "User-Agent": {"TestClient/1.0"}, - }, - host: "chain.example.com", - remoteAddr: "172.16.0.1:8080", - tls: false, - expectedHeaders: map[string]string{ - "X-Forwarded-For": "192.168.1.50, 10.0.0.25, 172.16.0.1", - "X-Forwarded-Host": "chain.example.com", - "X-Forwarded-Proto": "http", - "X-Real-IP": "192.168.1.50", - "X-Forwarded-Server": "chain.example.com", - "User-Agent": "TestClient/1.0", - }, - }, - { - name: "existing X-Forwarded-Proto preserved", - originalHeaders: map[string][]string{ - "X-Forwarded-Proto": {"https"}, - "User-Agent": {"TestClient/1.0"}, - }, - host: "proxy.example.com", - remoteAddr: "198.51.100.1:443", - tls: false, - expectedHeaders: map[string]string{ - "X-Forwarded-For": "198.51.100.1", - "X-Forwarded-Host": "proxy.example.com", - "X-Forwarded-Proto": "https", - "X-Real-IP": "198.51.100.1", - "X-Forwarded-Server": "proxy.example.com", - "User-Agent": "TestClient/1.0", - }, - }, - { - name: "existing X-Forwarded-Server preserved", - originalHeaders: map[string][]string{ - "X-Forwarded-Server": {"upstream.example.com"}, - "User-Agent": {"TestClient/1.0"}, - }, - host: "current.example.com", - remoteAddr: "203.0.113.100:9000", - tls: false, - expectedHeaders: map[string]string{ - "X-Forwarded-For": "203.0.113.100", - "X-Forwarded-Host": "current.example.com", - "X-Forwarded-Proto": "http", - "X-Real-IP": "203.0.113.100", - "X-Forwarded-Server": "upstream.example.com", - "User-Agent": "TestClient/1.0", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", "http://"+tt.host+"/test", nil) - req.Host = tt.host - req.RemoteAddr = tt.remoteAddr - if tt.tls { - req.TLS = &tls.ConnectionState{} - } - - for k, values := range tt.originalHeaders { - for _, v := range values { - req.Header.Add(k, v) - } - } - - result := router.prepareForwardingHeaders(req) - - for expectedKey, expectedValue := range tt.expectedHeaders { - if values, exists := result[expectedKey]; !exists { - t.Errorf("Expected header %s not found", expectedKey) - } else if len(values) == 0 || values[0] != expectedValue { - t.Errorf("Header %s = %v, want %v", expectedKey, values, []string{expectedValue}) - } - } - - if len(result["X-Forwarded-Host"]) > 0 && result["X-Forwarded-Host"][0] != tt.host { - t.Errorf("X-Forwarded-Host = %v, want %v", result["X-Forwarded-Host"][0], tt.host) - } - }) - } -} - -func TestTunnelRouter_prepareForwardingHeaders_EmptyHost(t *testing.T) { - server := &Server{ - Tunnels: make(map[string]*Tunnel), - } - router := NewTunnelRouter(server) - - req := httptest.NewRequest("GET", "http://example.com/test", nil) - req.Host = "" - req.RemoteAddr = "192.168.1.1:12345" - - result := router.prepareForwardingHeaders(req) - - if _, exists := result["X-Forwarded-Host"]; exists { - t.Error("X-Forwarded-Host should not be set when host is empty") - } - - if len(result["X-Forwarded-For"]) == 0 || result["X-Forwarded-For"][0] != "192.168.1.1" { - t.Errorf("X-Forwarded-For = %v, want %v", result["X-Forwarded-For"], []string{"192.168.1.1"}) - } -} diff --git a/server/server.go b/server/server.go deleted file mode 100644 index 2e7fa44..0000000 --- a/server/server.go +++ /dev/null @@ -1,76 +0,0 @@ -package server - -import ( - "net/http" - "sync" - - "github.com/karol-broda/funnel/shared" - - "github.com/gorilla/websocket" -) - -type Server struct { - Tunnels map[string]*Tunnel - TunnelsMu sync.RWMutex - Upgrader websocket.Upgrader - router RouterInterface - tokenStore *TokenStore -} - -type RouterInterface interface { - InvalidateCache(tunnelID string) -} - -func NewServer() *Server { - logger := shared.GetLogger("server") - - server := &Server{ - Tunnels: make(map[string]*Tunnel), - Upgrader: websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true - }, - ReadBufferSize: 1024 * 64, - WriteBufferSize: 1024 * 64, - }, - } - - logger.Info(). - Int("read_buffer_size", server.Upgrader.ReadBufferSize). - Int("write_buffer_size", server.Upgrader.WriteBufferSize). - Bool("check_origin_disabled", true). - Msg("server initialized") - - return server -} - -func (s *Server) SetRouter(router RouterInterface) { - logger := shared.GetLogger("server") - - s.router = router - logger.Debug().Msg("router configured for server") -} - -func (s *Server) SetTokenStore(tokenStore *TokenStore) { - logger := shared.GetLogger("server") - - s.tokenStore = tokenStore - if tokenStore.IsEnabled() { - logger.Info(). - Int("active_tokens", tokenStore.Count()). - Msg("token store configured - authentication enabled") - } else { - logger.Warn().Msg("token store not configured - authentication disabled") - } -} - -func (s *Server) GetTokenStore() *TokenStore { - return s.tokenStore -} - -func (s *Server) ValidateToken(token string) (*TokenRecord, bool) { - if s.tokenStore == nil { - return nil, true - } - return s.tokenStore.Validate(token) -} diff --git a/server/statistics.go b/server/statistics.go deleted file mode 100644 index 8546432..0000000 --- a/server/statistics.go +++ /dev/null @@ -1,242 +0,0 @@ -package server - -import ( - "sync" - "time" -) - -type TunnelStatistics struct { - TotalRequests int64 `json:"total_requests"` - TotalRequestsSuccess int64 `json:"total_requests_success"` - TotalRequestsError int64 `json:"total_requests_error"` - TotalBytesIn int64 `json:"total_bytes_in"` - TotalBytesOut int64 `json:"total_bytes_out"` - - AverageResponseTime float64 `json:"average_response_time_ms"` - MinResponseTime float64 `json:"min_response_time_ms"` - MaxResponseTime float64 `json:"max_response_time_ms"` - RequestsPerSecond float64 `json:"requests_per_second"` - - ConnectionsActive int `json:"connections_active"` - ConnectionsTotal int64 `json:"connections_total"` - ReconnectCount int64 `json:"reconnect_count"` - LastActivity time.Time `json:"last_activity"` - - TimeoutCount int64 `json:"timeout_count"` - ConnectionErrors int64 `json:"connection_errors"` - DataErrors int64 `json:"data_errors"` - - ClientIP string `json:"client_ip"` - UserAgent string `json:"user_agent,omitempty"` - - CreatedAt time.Time `json:"created_at"` - LastUpdated time.Time `json:"last_updated"` - - mu sync.RWMutex -} - -type HistoricalDataPoint struct { - Timestamp time.Time `json:"timestamp"` - RequestCount int64 `json:"request_count"` - BytesIn int64 `json:"bytes_in"` - BytesOut int64 `json:"bytes_out"` - ResponseTime float64 `json:"response_time_ms"` - ErrorCount int64 `json:"error_count"` - ActiveConnections int `json:"active_connections"` -} - -type TunnelHistoricalData struct { - TunnelID string `json:"tunnel_id"` - DataPoints []HistoricalDataPoint `json:"data_points"` - MaxPoints int `json:"max_points"` - Interval time.Duration `json:"interval_seconds"` - mu sync.RWMutex -} - -type ServerMetrics struct { - TotalTunnels int64 `json:"total_tunnels"` - ActiveTunnels int `json:"active_tunnels"` - TotalRequests int64 `json:"total_requests"` - TotalBytesTransferred int64 `json:"total_bytes_transferred"` - - AverageResponseTime float64 `json:"average_response_time_ms"` - ServerRequestsPerSec float64 `json:"server_requests_per_second"` - - MemoryUsage int64 `json:"memory_usage_bytes"` - CPUUsage float64 `json:"cpu_usage_percent"` - NetworkConnections int `json:"network_connections"` - - TotalErrors int64 `json:"total_errors"` - ErrorRate float64 `json:"error_rate_percent"` - - ServerUptime string `json:"server_uptime"` - LastUpdated time.Time `json:"last_updated"` - - mu sync.RWMutex -} - -func NewTunnelStatistics(tunnelID, clientIP, userAgent string) *TunnelStatistics { - now := time.Now() - return &TunnelStatistics{ - ClientIP: clientIP, - UserAgent: userAgent, - CreatedAt: now, - LastUpdated: now, - LastActivity: now, - MinResponseTime: -1, - ConnectionsActive: 1, - ConnectionsTotal: 1, - } -} - -func (ts *TunnelStatistics) RecordRequest(responseTimeMs float64, success bool, bytesIn, bytesOut int64) { - ts.mu.Lock() - defer ts.mu.Unlock() - - now := time.Now() - ts.TotalRequests++ - ts.TotalBytesIn += bytesIn - ts.TotalBytesOut += bytesOut - ts.LastActivity = now - ts.LastUpdated = now - - if success { - ts.TotalRequestsSuccess++ - } else { - ts.TotalRequestsError++ - } - - if ts.MinResponseTime == -1 || responseTimeMs < ts.MinResponseTime { - ts.MinResponseTime = responseTimeMs - } - if responseTimeMs > ts.MaxResponseTime { - ts.MaxResponseTime = responseTimeMs - } - - if ts.TotalRequests == 1 { - ts.AverageResponseTime = responseTimeMs - } else { - ts.AverageResponseTime = (ts.AverageResponseTime*float64(ts.TotalRequests-1) + responseTimeMs) / float64(ts.TotalRequests) - } - - duration := now.Sub(ts.CreatedAt).Seconds() - if duration > 0 { - ts.RequestsPerSecond = float64(ts.TotalRequests) / duration - } -} - -func (ts *TunnelStatistics) RecordError(errorType string) { - ts.mu.Lock() - defer ts.mu.Unlock() - - ts.LastUpdated = time.Now() - - switch errorType { - case "timeout": - ts.TimeoutCount++ - case "connection": - ts.ConnectionErrors++ - case "data": - ts.DataErrors++ - } -} - -func (ts *TunnelStatistics) RecordReconnect() { - ts.mu.Lock() - defer ts.mu.Unlock() - - ts.ReconnectCount++ - ts.ConnectionsTotal++ - ts.LastUpdated = time.Now() -} - -func (ts *TunnelStatistics) GetSnapshot() TunnelStatistics { - ts.mu.RLock() - defer ts.mu.RUnlock() - - return *ts -} - -func NewTunnelHistoricalData(tunnelID string, maxPoints int, interval time.Duration) *TunnelHistoricalData { - return &TunnelHistoricalData{ - TunnelID: tunnelID, - DataPoints: make([]HistoricalDataPoint, 0, maxPoints), - MaxPoints: maxPoints, - Interval: interval, - } -} - -func (thd *TunnelHistoricalData) AddDataPoint(stats TunnelStatistics) { - thd.mu.Lock() - defer thd.mu.Unlock() - - point := HistoricalDataPoint{ - Timestamp: time.Now(), - RequestCount: stats.TotalRequests, - BytesIn: stats.TotalBytesIn, - BytesOut: stats.TotalBytesOut, - ResponseTime: stats.AverageResponseTime, - ErrorCount: stats.TotalRequestsError, - ActiveConnections: stats.ConnectionsActive, - } - - thd.DataPoints = append(thd.DataPoints, point) - - if len(thd.DataPoints) > thd.MaxPoints { - thd.DataPoints = thd.DataPoints[len(thd.DataPoints)-thd.MaxPoints:] - } -} - -func (thd *TunnelHistoricalData) GetDataPoints() []HistoricalDataPoint { - thd.mu.RLock() - defer thd.mu.RUnlock() - - result := make([]HistoricalDataPoint, len(thd.DataPoints)) - copy(result, thd.DataPoints) - return result -} - -func (thd *TunnelHistoricalData) GetDataPointsInRange(start, end time.Time) []HistoricalDataPoint { - thd.mu.RLock() - defer thd.mu.RUnlock() - - var result []HistoricalDataPoint - for _, point := range thd.DataPoints { - if point.Timestamp.After(start) && point.Timestamp.Before(end) { - result = append(result, point) - } - } - return result -} - -func NewServerMetrics(startTime time.Time) *ServerMetrics { - return &ServerMetrics{ - LastUpdated: time.Now(), - } -} - -func (sm *ServerMetrics) UpdateServerMetrics(activeTunnels int, totalRequests int64, routerStats map[string]interface{}, startTime time.Time) { - sm.mu.Lock() - defer sm.mu.Unlock() - - sm.ActiveTunnels = activeTunnels - sm.TotalRequests = totalRequests - sm.ServerUptime = time.Since(startTime).String() - sm.LastUpdated = time.Now() - - uptime := time.Since(startTime).Seconds() - if uptime > 0 { - sm.ServerRequestsPerSec = float64(totalRequests) / uptime - } - - if totalRequests > 0 { - sm.ErrorRate = (float64(sm.TotalErrors) / float64(totalRequests)) * 100 - } -} - -func (sm *ServerMetrics) GetSnapshot() ServerMetrics { - sm.mu.RLock() - defer sm.mu.RUnlock() - - return *sm -} diff --git a/server/swagger.go b/server/swagger.go deleted file mode 100644 index 050ac11..0000000 --- a/server/swagger.go +++ /dev/null @@ -1,30 +0,0 @@ -package server - -import ( - "net/http" - "strings" - - "github.com/karol-broda/funnel/shared" - httpSwagger "github.com/swaggo/http-swagger/v2" -) - -func (tr *TunnelRouter) ServeSwaggerUI(w http.ResponseWriter, r *http.Request) { - logger := shared.GetLogger("server.swagger") - - logger.Info(). - Str("method", r.Method). - Str("path", r.URL.Path). - Str("remote_addr", r.RemoteAddr). - Msg("Swagger UI request received") - - path := strings.TrimPrefix(r.URL.Path, "/swagger") - - swaggerHandler := httpSwagger.Handler( - httpSwagger.URL("/api/swagger/doc.json"), - ) - - newPath := "/swagger" + path - r.URL.Path = newPath - - swaggerHandler.ServeHTTP(w, r) -} diff --git a/server/tunnel.go b/server/tunnel.go deleted file mode 100644 index 9c47c8c..0000000 --- a/server/tunnel.go +++ /dev/null @@ -1,411 +0,0 @@ -package server - -import ( - "fmt" - "sync" - "time" - - "github.com/karol-broda/funnel/shared" - - "github.com/gorilla/websocket" -) - -type Tunnel struct { - ID string - conn *websocket.Conn - incomingMessages chan *shared.Message - outgoingMessages chan *shared.Message - server *Server - closeOnce sync.Once - runOnce sync.Once - removed bool - removedMu sync.Mutex - - ResponseChannels map[string]chan *shared.Message - ResponseMu sync.RWMutex - - createdAt time.Time - messagesReceived int64 - messagesSent int64 - bytesReceived int64 - bytesSent int64 - - statistics *TunnelStatistics - historicalData *TunnelHistoricalData -} - -func (s *Server) GetTunnel(id string) (*Tunnel, bool) { - s.TunnelsMu.RLock() - defer s.TunnelsMu.RUnlock() - tunnel, exists := s.Tunnels[id] - return tunnel, exists -} - -func (s *Server) AddTunnel(id string, conn *websocket.Conn, wg *sync.WaitGroup) *Tunnel { - logger := shared.GetTunnelLogger("server.tunnel", id) - - clientIP := "unknown" - userAgent := "" - if conn != nil && conn.RemoteAddr() != nil { - clientIP = conn.RemoteAddr().String() - } - - tunnel := &Tunnel{ - ID: id, - conn: conn, - ResponseChannels: make(map[string]chan *shared.Message), - incomingMessages: make(chan *shared.Message, 100), - outgoingMessages: make(chan *shared.Message, 100), - server: s, - createdAt: time.Now(), - statistics: NewTunnelStatistics(id, clientIP, userAgent), - historicalData: NewTunnelHistoricalData(id, 1440, time.Minute), // 24 hours of minute-by-minute data - } - - s.TunnelsMu.Lock() - tunnelCount := len(s.Tunnels) - s.Tunnels[id] = tunnel - s.TunnelsMu.Unlock() - - logger.Info(). - Int("total_tunnels", tunnelCount+1). - Int("incoming_buffer_size", cap(tunnel.incomingMessages)). - Int("outgoing_buffer_size", cap(tunnel.outgoingMessages)). - Msg("tunnel added to server") - - return tunnel -} - -func (s *Server) RemoveTunnel(id string) { - s.TunnelsMu.Lock() - tunnel, exists := s.Tunnels[id] - delete(s.Tunnels, id) - remainingTunnels := len(s.Tunnels) - s.TunnelsMu.Unlock() - - if exists { - tunnel.removedMu.Lock() - tunnel.removed = true - tunnel.removedMu.Unlock() - - tunnel.closeConnection() - - if s.router != nil { - s.router.InvalidateCache(id) - } - - logger := shared.GetTunnelLogger("server.tunnel", id) - lifetime := time.Since(tunnel.createdAt) - logger.Info(). - Int("remaining_tunnels", remainingTunnels). - Dur("tunnel_lifetime", lifetime). - Int64("messages_received", tunnel.messagesReceived). - Int64("messages_sent", tunnel.messagesSent). - Int64("bytes_received", tunnel.bytesReceived). - Int64("bytes_sent", tunnel.bytesSent). - Msg("tunnel removed from server") - } else { - logger := shared.GetTunnelLogger("server.tunnel", id) - logger.Warn().Msg("attempted to remove non-existent tunnel") - } -} - -func (s *Server) TunnelExists(id string) bool { - s.TunnelsMu.RLock() - defer s.TunnelsMu.RUnlock() - _, exists := s.Tunnels[id] - return exists -} - -func (t *Tunnel) readMessages() { - defer func() { - if r := recover(); r != nil { - logger := shared.GetTunnelLogger("server.tunnel", t.ID) - logger.Error().Msgf("recovered in readmessages: %v", r) - } - if t.incomingMessages != nil { - close(t.incomingMessages) - } - t.closeConnection() - }() - - logger := shared.GetTunnelLogger("server.tunnel", t.ID) - logger.Debug().Msg("starting message reader goroutine") - - if t.conn == nil { - logger.Error().Msg("tunnel connection is nil, cannot read messages") - return - } - - for { - var msg shared.Message - - readStart := time.Now() - if t.conn == nil { - logger.Error().Msg("tunnel connection became nil during read") - return - } - err := t.conn.ReadJSON(&msg) - readDuration := time.Since(readStart) - - if err != nil { - logger.Error().Err(err). - Dur("read_duration", readDuration). - Int64("total_messages_received", t.messagesReceived). - Msg("websocket read failed") - t.closeConnection() - return - } - - t.messagesReceived++ - messageSize := int64(len(msg.Body)) - t.bytesReceived += messageSize - - logger.Debug(). - Str("message_type", msg.Type). - Str("request_id", msg.RequestID). - Int64("message_size", messageSize). - Dur("read_duration", readDuration). - Msg("message received from client") - - select { - case t.incomingMessages <- &msg: - default: - logger.Warn(). - Str("message_type", msg.Type). - Int("queue_capacity", cap(t.incomingMessages)). - Msg("message channel full, dropping message") - } - } -} - -func (t *Tunnel) writeMessages() { - defer func() { - if r := recover(); r != nil { - logger := shared.GetTunnelLogger("server.tunnel", t.ID) - logger.Error().Msgf("recovered in writemessages: %v", r) - } - t.closeConnection() - }() - - logger := shared.GetTunnelLogger("server.tunnel", t.ID) - logger.Debug().Msg("starting message writer goroutine") - - if t.conn == nil { - logger.Error().Msg("tunnel connection is nil, cannot write messages") - return - } - - for msg := range t.outgoingMessages { - writeStart := time.Now() - if t.conn == nil { - logger.Error().Msg("tunnel connection became nil during write") - return - } - err := t.conn.WriteJSON(msg) - writeDuration := time.Since(writeStart) - - if err != nil { - logger.Error().Err(err). - Str("message_type", msg.Type). - Str("request_id", msg.RequestID). - Dur("write_duration", writeDuration). - Msg("websocket write failed") - t.closeConnection() - return - } - - t.messagesSent++ - messageSize := int64(len(msg.Body)) - t.bytesSent += messageSize - - logger.Debug(). - Str("message_type", msg.Type). - Str("request_id", msg.RequestID). - Int64("message_size", messageSize). - Dur("write_duration", writeDuration). - Msg("message sent to client") - } -} - -func (t *Tunnel) SendMessage(msg *shared.Message) error { - if t.conn == nil { - return fmt.Errorf("tunnel connection is nil") - } - - if t.outgoingMessages == nil { - return fmt.Errorf("tunnel is closed, dropping message") - } - - logger := shared.GetTunnelLogger("server.tunnel", t.ID) - if msg.TunnelID != t.ID { - logger.Error(). - Str("expected_tunnel_id", t.ID). - Str("message_tunnel_id", msg.TunnelID). - Str("request_id", msg.RequestID). - Msg("message dropped: tunnel id mismatch") - return fmt.Errorf("message dropped: tunnel id mismatch") - } - - select { - case t.outgoingMessages <- msg: - logger.Debug(). - Str("message_type", msg.Type). - Str("request_id", msg.RequestID). - Int("queue_len", len(t.outgoingMessages)). - Msg("message queued for sending") - return nil - default: - logger.Error(). - Str("message_type", msg.Type). - Str("request_id", msg.RequestID). - Int("queue_capacity", cap(t.outgoingMessages)). - Msg("outgoing message queue full, dropping message") - return fmt.Errorf("outgoing message queue full") - } -} - -func (t *Tunnel) routeMessages() { - logger := shared.GetTunnelLogger("server.tunnel", t.ID) - logger.Debug().Msg("starting message router goroutine") - - defer func() { - t.ResponseMu.Lock() - if len(t.ResponseChannels) > 0 { - logger.Warn(). - Int("closed_channels", len(t.ResponseChannels)). - Msg("cleaned up pending response channels") - for id, ch := range t.ResponseChannels { - close(ch) - delete(t.ResponseChannels, id) - } - } - t.ResponseMu.Unlock() - logger.Debug().Msg("message router stopped") - }() - - for msg := range t.incomingMessages { - switch msg.Type { - case "response": - t.ResponseMu.RLock() - if respChan, ok := t.ResponseChannels[msg.RequestID]; ok { - select { - case respChan <- msg: - default: - logger.Warn(). - Str("request_id", msg.RequestID). - Msg("response channel was full, response dropped") - } - } - t.ResponseMu.RUnlock() - case "ping": - t.SendMessage(&shared.Message{Type: "pong"}) - default: - logger.Debug(). - Str("message_type", msg.Type). - Str("request_id", msg.RequestID). - Msg("unhandled message type in router") - } - } -} - -func (t *Tunnel) registerResponseChannel(requestID string) chan *shared.Message { - respChan := make(chan *shared.Message, 1) - t.ResponseMu.Lock() - t.ResponseChannels[requestID] = respChan - t.ResponseMu.Unlock() - logger := shared.GetTunnelLogger("server.tunnel", t.ID) - logger.Debug(). - Str("request_id", requestID). - Msg("response channel registered") - return respChan -} - -func (t *Tunnel) unregisterResponseChannel(requestID string) { - t.ResponseMu.Lock() - defer t.ResponseMu.Unlock() - delete(t.ResponseChannels, requestID) -} - -func (t *Tunnel) Run() { - t.runOnce.Do(func() { - defer func() { - if r := recover(); r != nil { - logger := shared.GetTunnelLogger("server.tunnel", t.ID) - logger.Error().Msgf("recovered in run: %v", r) - } - t.closeConnection() - t.removedMu.Lock() - if !t.removed && t.server != nil { - t.removed = true - t.removedMu.Unlock() - t.server.RemoveTunnel(t.ID) - } else { - t.removedMu.Unlock() - } - }() - - logger := shared.GetTunnelLogger("server.tunnel", t.ID) - logger.Debug().Msg("tunnel is now running") - - if t.conn == nil { - logger.Warn().Msg("tunnel has a nil connection, not starting goroutines") - return - } - - done := make(chan struct{}) - var wg sync.WaitGroup - wg.Add(3) - - go func() { - defer wg.Done() - t.readMessages() - }() - - go func() { - defer wg.Done() - t.writeMessages() - }() - - go func() { - defer wg.Done() - t.routeMessages() - }() - - go func() { - wg.Wait() - close(done) - }() - - <-done - logger.Debug().Msg("tunnel has stopped") - }) -} - -func (t *Tunnel) getRemoteAddr() string { - if t.conn != nil && t.conn.RemoteAddr() != nil { - return t.conn.RemoteAddr().String() - } - return "unknown" -} - -// GetRemoteAddr returns the remote address of the tunnel connection -func (t *Tunnel) GetRemoteAddr() string { - return t.getRemoteAddr() -} - -// GetCreatedAt returns the creation time of the tunnel -func (t *Tunnel) GetCreatedAt() time.Time { - return t.createdAt -} - -func (t *Tunnel) closeConnection() { - t.closeOnce.Do(func() { - if t.conn != nil { - t.conn.Close() - } - if t.outgoingMessages != nil { - close(t.outgoingMessages) - } - }) -} diff --git a/server/tunnel_test.go b/server/tunnel_test.go deleted file mode 100644 index 5b269d4..0000000 --- a/server/tunnel_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package server - -import ( - "fmt" - "sync" - "testing" - - "github.com/karol-broda/funnel/shared" -) - -func TestServerTunnelManagement(t *testing.T) { - server := NewServer() - - t.Run("add tunnel", func(t *testing.T) { - tunnelID := "test-tunnel-123" - tunnel := server.AddTunnel(tunnelID, nil, nil) - defer server.RemoveTunnel(tunnelID) - - if tunnel == nil { - t.Error("expected tunnel to be created") - return - } - - if tunnel.ID != tunnelID { - t.Errorf("expected tunnel ID %s, got %s", tunnelID, tunnel.ID) - } - - if tunnel.conn != nil { - t.Error("expected tunnel connection to be nil for this test") - } - - if tunnel.ResponseChannels == nil { - t.Error("response channels not initialized") - } - - if tunnel.createdAt.IsZero() { - t.Error("created timestamp not set") - } - }) - - t.Run("get tunnel", func(t *testing.T) { - tunnelID := "test-tunnel-get" - server.AddTunnel(tunnelID, nil, nil) - defer server.RemoveTunnel(tunnelID) - - tunnel, exists := server.GetTunnel(tunnelID) - if !exists { - t.Error("expected tunnel to exist") - } - - if tunnel.ID != tunnelID { - t.Errorf("expected tunnel ID %s, got %s", tunnelID, tunnel.ID) - } - - _, exists = server.GetTunnel("non-existent") - if exists { - t.Error("non-existent tunnel should not exist") - } - }) - - t.Run("tunnel exists", func(t *testing.T) { - tunnelID := "test-tunnel-exists" - - if server.TunnelExists(tunnelID) { - t.Error("tunnel should not exist before creation") - } - - server.AddTunnel(tunnelID, nil, nil) - defer server.RemoveTunnel(tunnelID) - - if !server.TunnelExists(tunnelID) { - t.Error("tunnel should exist after creation") - } - }) - - t.Run("remove tunnel", func(t *testing.T) { - tunnelID := "test-tunnel-remove" - server.AddTunnel(tunnelID, nil, nil) - - if !server.TunnelExists(tunnelID) { - t.Error("tunnel should exist before removal") - } - - server.RemoveTunnel(tunnelID) - - if server.TunnelExists(tunnelID) { - t.Error("tunnel should not exist after removal") - } - - server.RemoveTunnel("non-existent-tunnel") - }) -} - -func TestTunnelConcurrency(t *testing.T) { - server := NewServer() - numTunnels := 100 - - t.Run("concurrent add", func(t *testing.T) { - var wg sync.WaitGroup - for i := 0; i < numTunnels; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - tunnelID := fmt.Sprintf("concurrent-tunnel-%d", i) - server.AddTunnel(tunnelID, nil, nil) - }(i) - } - wg.Wait() - - server.TunnelsMu.RLock() - count := len(server.Tunnels) - server.TunnelsMu.RUnlock() - - if count != numTunnels { - t.Errorf("expected %d tunnels, got %d", numTunnels, count) - } - - for i := 0; i < numTunnels; i++ { - tunnelID := fmt.Sprintf("concurrent-tunnel-%d", i) - server.RemoveTunnel(tunnelID) - } - }) - - t.Run("concurrent access", func(t *testing.T) { - var wg sync.WaitGroup - for i := 0; i < numTunnels; i++ { - tunnelID := fmt.Sprintf("concurrent-tunnel-%d", i) - server.AddTunnel(tunnelID, nil, nil) - defer server.RemoveTunnel(tunnelID) - } - - for i := 0; i < numTunnels*2; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - tunnelID := fmt.Sprintf("concurrent-tunnel-%d", i%numTunnels) - _, exists := server.GetTunnel(tunnelID) - if !exists { - t.Errorf("tunnel %s should exist", tunnelID) - } - }(i) - } - wg.Wait() - }) - - t.Run("concurrent remove", func(t *testing.T) { - var wg sync.WaitGroup - for i := 0; i < numTunnels; i++ { - tunnelID := fmt.Sprintf("concurrent-tunnel-%d", i) - server.AddTunnel(tunnelID, nil, nil) - } - - for i := 0; i < numTunnels; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - tunnelID := fmt.Sprintf("concurrent-tunnel-%d", i) - server.RemoveTunnel(tunnelID) - }(i) - } - wg.Wait() - - server.TunnelsMu.RLock() - count := len(server.Tunnels) - server.TunnelsMu.RUnlock() - if count != 0 { - t.Errorf("expected 0 tunnels, got %d", count) - } - }) -} - -func TestTunnelMessageHandling(t *testing.T) { - server := NewServer() - - t.Run("send message to nil connection tunnel", func(t *testing.T) { - tunnelID := "test-send-receive" - tunnel := server.AddTunnel(tunnelID, nil, nil) - defer server.RemoveTunnel(tunnelID) - - testMsg := &shared.Message{ - Type: "test", - RequestID: "test-123", - TunnelID: tunnelID, - } - err := tunnel.SendMessage(testMsg) - if err == nil { - t.Error("expected error when sending to a nil connection tunnel, but got nil") - } - }) -} - -func TestTunnelResponseChannels(t *testing.T) { - server := NewServer() - tunnel := server.AddTunnel("test-response-channels", nil, nil) - defer server.RemoveTunnel(tunnel.ID) - - requestID := "test-req-1" - ch := tunnel.registerResponseChannel(requestID) - if ch == nil { - t.Fatal("response channel should not be nil") - } - - tunnel.ResponseMu.RLock() - _, exists := tunnel.ResponseChannels[requestID] - tunnel.ResponseMu.RUnlock() - if !exists { - t.Fatal("response channel should be registered") - } - - tunnel.unregisterResponseChannel(requestID) - - tunnel.ResponseMu.RLock() - _, exists = tunnel.ResponseChannels[requestID] - tunnel.ResponseMu.RUnlock() - if exists { - t.Fatal("response channel should be unregistered") - } -} - -func BenchmarkTunnelCreation(b *testing.B) { - server := NewServer() - b.ResetTimer() - for i := 0; i < b.N; i++ { - tunnelID := fmt.Sprintf("bench-create-%d", i) - tunnel := server.AddTunnel(tunnelID, nil, nil) - server.RemoveTunnel(tunnel.ID) - } -} - -func BenchmarkTunnelLookup(b *testing.B) { - server := NewServer() - tunnel := server.AddTunnel("bench-lookup", nil, nil) - defer server.RemoveTunnel(tunnel.ID) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - server.GetTunnel(tunnel.ID) - } -} diff --git a/server/websocket.go b/server/websocket.go deleted file mode 100644 index 2d8c4cc..0000000 --- a/server/websocket.go +++ /dev/null @@ -1,137 +0,0 @@ -package server - -import ( - "net/http" - "strings" - "time" - - "github.com/karol-broda/funnel/shared" -) - -func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { - logger := shared.GetLogger("server.websocket") - - logger.Debug(). - Str("remote_addr", r.RemoteAddr). - Str("user_agent", r.UserAgent()). - Str("origin", r.Header.Get("Origin")). - Msg("websocket connection attempt") - - token := extractToken(r) - tokenRecord, valid := s.ValidateToken(token) - if !valid { - logger.Warn(). - Str("remote_addr", r.RemoteAddr). - Msg("websocket upgrade rejected - invalid or missing token") - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - tokenName := "" - if tokenRecord != nil { - tokenName = tokenRecord.Name - } - - tunnelID := r.URL.Query().Get("id") - if tunnelID == "" { - logger.Warn(). - Str("remote_addr", r.RemoteAddr). - Str("token_name", tokenName). - Msg("websocket upgrade rejected - missing tunnel id") - http.Error(w, "tunnel id required", http.StatusBadRequest) - return - } - - if err := shared.ValidateTunnelID(tunnelID); err != nil { - logger.Warn(). - Str("remote_addr", r.RemoteAddr). - Str("tunnel_id", tunnelID). - Str("token_name", tokenName). - Err(err). - Msg("websocket upgrade rejected - invalid tunnel id format") - http.Error(w, "invalid tunnel id format: "+err.Error(), http.StatusBadRequest) - return - } - - tunnelLogger := shared.GetTunnelLogger("server.websocket", tunnelID) - - tunnelLogger.Info(). - Str("remote_addr", r.RemoteAddr). - Str("user_agent", r.UserAgent()). - Str("token_name", tokenName). - Msg("websocket upgrade requested") - - if s.TunnelExists(tunnelID) { - tunnelLogger.Warn(). - Str("remote_addr", r.RemoteAddr). - Msg("websocket upgrade rejected - tunnel id already in use") - http.Error(w, "tunnel id already in use", http.StatusConflict) - return - } - - upgradeStart := time.Now() - conn, err := s.Upgrader.Upgrade(w, r, nil) - upgradeDuration := time.Since(upgradeStart) - - if err != nil { - tunnelLogger.Error().Err(err). - Dur("upgrade_duration", upgradeDuration). - Str("remote_addr", r.RemoteAddr). - Msg("websocket upgrade failed") - return - } - - tunnelLogger.Info(). - Dur("upgrade_duration", upgradeDuration). - Str("remote_addr", r.RemoteAddr). - Msg("websocket upgrade successful") - - tunnel := s.AddTunnel(tunnelID, conn, nil) - tunnelLogger.Info().Msg("tunnel connected via websocket") - - defer func() { - s.RemoveTunnel(tunnelID) - tunnelLogger.Info().Msg("tunnel disconnected and cleaned up") - }() - - s.setupWebSocketConnection(tunnel) - tunnel.Run() -} - -func (s *Server) setupWebSocketConnection(tunnel *Tunnel) { - logger := shared.GetTunnelLogger("server.websocket", tunnel.ID) - - readDeadline := 300 * time.Second - if err := tunnel.conn.SetReadDeadline(time.Now().Add(readDeadline)); err != nil { - logger.Error().Err(err).Msg("failed to set initial read deadline") - return - } - - logger.Debug(). - Dur("read_deadline", readDeadline). - Msg("websocket read deadline configured") - - tunnel.conn.SetPingHandler(func(appData string) error { - logger.Debug().Msg("ping received, read deadline extended") - if err := tunnel.conn.SetReadDeadline(time.Now().Add(readDeadline)); err != nil { - logger.Error().Err(err).Msg("failed to extend read deadline on ping") - } - return nil - }) - - logger.Debug().Msg("websocket connection setup completed") -} - -func extractToken(r *http.Request) string { - authHeader := r.Header.Get("Authorization") - if authHeader != "" { - return strings.TrimPrefix(authHeader, "Bearer ") - } - - token := r.URL.Query().Get("token") - if token != "" { - return token - } - - return "" -} diff --git a/server/websocket_test.go b/server/websocket_test.go deleted file mode 100644 index 2df71a9..0000000 --- a/server/websocket_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package server - -import ( - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/karol-broda/funnel/shared" -) - -func TestWebSocketTunnelIDValidation(t *testing.T) { - server := NewServer() - - tests := []struct { - name string - tunnelID string - expectedStatus int - expectedError string - }{ - { - name: "valid tunnel ID", - tunnelID: "abc123", - expectedStatus: http.StatusInternalServerError, // websocket upgrade fails in tests due to httptest.ResponseRecorder limitations - }, - { - name: "valid tunnel ID with hyphens", - tunnelID: "abc-123-def", - expectedStatus: http.StatusInternalServerError, // websocket upgrade fails in tests due to httptest.ResponseRecorder limitations - }, - { - name: "empty tunnel ID", - tunnelID: "", - expectedStatus: http.StatusBadRequest, - expectedError: "tunnel id required", - }, - { - name: "tunnel ID with uppercase", - tunnelID: "Abc123", - expectedStatus: http.StatusBadRequest, - expectedError: "invalid tunnel id format", - }, - { - name: "tunnel ID too short", - tunnelID: "ab", - expectedStatus: http.StatusBadRequest, - expectedError: "invalid tunnel id format", - }, - { - name: "tunnel ID too long", - tunnelID: strings.Repeat("a", 64), - expectedStatus: http.StatusBadRequest, - expectedError: "invalid tunnel id format", - }, - { - name: "tunnel ID starts with hyphen", - tunnelID: "-abc123", - expectedStatus: http.StatusBadRequest, - expectedError: "invalid tunnel id format", - }, - { - name: "tunnel ID ends with hyphen", - tunnelID: "abc123-", - expectedStatus: http.StatusBadRequest, - expectedError: "invalid tunnel id format", - }, - { - name: "tunnel ID with invalid characters", - tunnelID: "abc_123", - expectedStatus: http.StatusBadRequest, - expectedError: "invalid tunnel id format", - }, - { - name: "tunnel ID with spaces", - tunnelID: "abc 123", - expectedStatus: http.StatusBadRequest, - expectedError: "invalid tunnel id format", - }, - { - name: "tunnel ID with special characters", - tunnelID: "abc@123", - expectedStatus: http.StatusBadRequest, - expectedError: "invalid tunnel id format", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // create request with tunnel ID - req := httptest.NewRequest("GET", "/", nil) - req.Header.Set("Connection", "upgrade") - req.Header.Set("Upgrade", "websocket") - req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") - req.Header.Set("Sec-WebSocket-Version", "13") - - // add tunnel ID as query parameter - if tt.tunnelID != "" { - q := req.URL.Query() - q.Add("id", tt.tunnelID) - req.URL.RawQuery = q.Encode() - } - - // create response recorder - w := httptest.NewRecorder() - - // call the handler - server.HandleWebSocket(w, req) - - // check status code - if w.Code != tt.expectedStatus { - t.Errorf("HandleWebSocket() status = %v, want %v", w.Code, tt.expectedStatus) - } - - // check error message if expected - if tt.expectedError != "" { - body := w.Body.String() - if !strings.Contains(body, tt.expectedError) { - t.Errorf("HandleWebSocket() body = %v, want to contain %v", body, tt.expectedError) - } - } - }) - } -} - -func TestWebSocketTunnelIDConflict(t *testing.T) { - server := NewServer() - - // add a tunnel first - existingTunnelID := "existing123" - server.AddTunnel(existingTunnelID, nil, nil) - defer server.RemoveTunnel(existingTunnelID) - - // try to create another tunnel with the same ID - req := httptest.NewRequest("GET", "/", nil) - req.Header.Set("Connection", "upgrade") - req.Header.Set("Upgrade", "websocket") - req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") - req.Header.Set("Sec-WebSocket-Version", "13") - - q := req.URL.Query() - q.Add("id", existingTunnelID) - req.URL.RawQuery = q.Encode() - - w := httptest.NewRecorder() - server.HandleWebSocket(w, req) - - if w.Code != http.StatusConflict { - t.Errorf("HandleWebSocket() status = %v, want %v", w.Code, http.StatusConflict) - } - - body := w.Body.String() - if !strings.Contains(body, "tunnel id already in use") { - t.Errorf("HandleWebSocket() body = %v, want to contain 'tunnel id already in use'", body) - } -} - -func TestWebSocketTunnelIDGeneration(t *testing.T) { - // test that generated IDs are valid - for i := 0; i < 100; i++ { - id := shared.MustGenerateDomainSafeID() - - if err := shared.ValidateTunnelID(id); err != nil { - t.Errorf("Generated tunnel ID %s is invalid: %v", id, err) - } - - // ensure it's domain-safe (no uppercase) - for _, char := range id { - if char >= 'A' && char <= 'Z' { - t.Errorf("Generated tunnel ID %s contains uppercase character: %c", id, char) - } - } - } -} - -func TestWebSocketURLParsing(t *testing.T) { - tests := []struct { - name string - rawURL string - expected string - }{ - { - name: "simple tunnel ID", - rawURL: "/?id=abc123", - expected: "abc123", - }, - { - name: "tunnel ID with hyphens", - rawURL: "/?id=abc-123-def", - expected: "abc-123-def", - }, - { - name: "tunnel ID with other params", - rawURL: "/?id=abc123&other=value", - expected: "abc123", - }, - { - name: "URL encoded tunnel ID", - rawURL: "/?id=" + url.QueryEscape("abc-123"), - expected: "abc-123", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := httptest.NewRequest("GET", tt.rawURL, nil) - tunnelID := req.URL.Query().Get("id") - - if tunnelID != tt.expected { - t.Errorf("URL parsing tunnelID = %v, want %v", tunnelID, tt.expected) - } - }) - } -} diff --git a/shared/go.mod b/shared/go.mod deleted file mode 100644 index ef4196a..0000000 --- a/shared/go.mod +++ /dev/null @@ -1,11 +0,0 @@ -module github.com/karol-broda/funnel/shared - -go 1.24.4 - -require github.com/rs/zerolog v1.34.0 - -require ( - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.32.0 // indirect -) diff --git a/shared/go.sum b/shared/go.sum deleted file mode 100644 index 1d8cb30..0000000 --- a/shared/go.sum +++ /dev/null @@ -1,15 +0,0 @@ -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= diff --git a/shared/logging.go b/shared/logging.go deleted file mode 100644 index 417782b..0000000 --- a/shared/logging.go +++ /dev/null @@ -1,81 +0,0 @@ -package shared - -import ( - "os" - "time" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -type LogConfig struct { - Level string `json:"level"` - Format string `json:"format"` - TimeFormat string `json:"time_format"` -} - -func DefaultLogConfig() LogConfig { - return LogConfig{ - Level: "debug", - Format: "console", - TimeFormat: time.RFC3339, - } -} - -func ProductionLogConfig() LogConfig { - return LogConfig{ - Level: "info", - Format: "json", - TimeFormat: time.RFC3339, - } -} - -func InitializeLogging(config LogConfig) { - level := zerolog.InfoLevel - switch config.Level { - case "debug": - level = zerolog.DebugLevel - case "info": - level = zerolog.InfoLevel - case "warn": - level = zerolog.WarnLevel - case "error": - level = zerolog.ErrorLevel - case "trace": - level = zerolog.TraceLevel - } - - if config.Format == "console" { - log.Logger = log.Output(zerolog.ConsoleWriter{ - Out: os.Stderr, - TimeFormat: config.TimeFormat, - NoColor: false, - }).Level(level).With().Timestamp().Caller().Logger() - } else { - log.Logger = zerolog.New(os.Stderr). - Level(level). - With(). - Timestamp(). - Caller(). - Logger() - } -} - -func GetLogger(component string) zerolog.Logger { - return log.With().Str("component", component).Logger() -} - -func GetTunnelLogger(component, tunnelID string) zerolog.Logger { - return log.With(). - Str("component", component). - Str("tunnel_id", tunnelID). - Logger() -} - -func GetRequestLogger(component, tunnelID, requestID string) zerolog.Logger { - return log.With(). - Str("component", component). - Str("tunnel_id", tunnelID). - Str("request_id", requestID). - Logger() -} diff --git a/shared/message.go b/shared/message.go deleted file mode 100644 index 7819a69..0000000 --- a/shared/message.go +++ /dev/null @@ -1,12 +0,0 @@ -package shared - -type Message struct { - Type string `json:"type"` - TunnelID string `json:"tunnel_id,omitempty"` - RequestID string `json:"request_id,omitempty"` - Method string `json:"method,omitempty"` - Path string `json:"path,omitempty"` - Headers map[string][]string `json:"headers,omitempty"` - Body []byte `json:"body,omitempty"` - Status int `json:"status,omitempty"` -} diff --git a/shared/message_test.go b/shared/message_test.go deleted file mode 100644 index 3f99171..0000000 --- a/shared/message_test.go +++ /dev/null @@ -1,383 +0,0 @@ -package shared - -import ( - "encoding/json" - "reflect" - "testing" -) - -// messagesEqual compares two Message structs for equality, -// treating nil and empty maps/slices as equivalent -func messagesEqual(a, b Message) bool { - if a.Type != b.Type || - a.RequestID != b.RequestID || - a.Method != b.Method || - a.Path != b.Path || - a.Status != b.Status { - return false - } - - // compare headers - treat nil and empty maps as equal - if !headersEqual(a.Headers, b.Headers) { - return false - } - - // compare body - treat nil and empty slices as equal - return bodiesEqual(a.Body, b.Body) -} - -// headersEqual compares two header maps, treating nil and empty maps as equal -func headersEqual(a, b map[string][]string) bool { - if len(a) == 0 && len(b) == 0 { - return true - } - return reflect.DeepEqual(a, b) -} - -// bodiesEqual compares two byte slices, treating nil and empty slices as equal -func bodiesEqual(a, b []byte) bool { - if len(a) == 0 && len(b) == 0 { - return true - } - return reflect.DeepEqual(a, b) -} - -func TestMessageJSONSerialization(t *testing.T) { - tests := []struct { - name string - message Message - }{ - { - name: "complete message", - message: Message{ - Type: "request", - RequestID: "test-123", - Method: "GET", - Path: "/api/test", - Headers: map[string][]string{ - "Content-Type": {"application/json"}, - "Authorization": {"Bearer token123"}, - }, - Body: []byte("test body content"), - Status: 200, - }, - }, - { - name: "minimal message", - message: Message{ - Type: "ping", - }, - }, - { - name: "response message", - message: Message{ - Type: "response", - RequestID: "req-456", - Status: 404, - Headers: map[string][]string{ - "Content-Type": {"text/plain"}, - }, - Body: []byte("Not Found"), - }, - }, - { - name: "empty headers", - message: Message{ - Type: "request", - Method: "POST", - Path: "/submit", - Headers: map[string][]string{}, - Body: []byte("{}"), - }, - }, - { - name: "nil headers", - message: Message{ - Type: "request", - Method: "DELETE", - Path: "/resource/123", - }, - }, - { - name: "empty body", - message: Message{ - Type: "request", - Method: "HEAD", - Path: "/health", - Body: []byte{}, - }, - }, - { - name: "nil body", - message: Message{ - Type: "request", - Method: "OPTIONS", - Path: "/api", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name+" marshal", func(t *testing.T) { - data, err := json.Marshal(tt.message) - if err != nil { - t.Errorf("failed to marshal message: %v", err) - return - } - - if len(data) == 0 { - t.Error("marshaled data is empty") - } - }) - - t.Run(tt.name+" roundtrip", func(t *testing.T) { - // marshal - data, err := json.Marshal(tt.message) - if err != nil { - t.Errorf("failed to marshal message: %v", err) - return - } - - // unmarshal - var unmarshaled Message - err = json.Unmarshal(data, &unmarshaled) - if err != nil { - t.Errorf("failed to unmarshal message: %v", err) - return - } - - // compare - if !messagesEqual(tt.message, unmarshaled) { - t.Errorf("roundtrip failed:\noriginal: %+v\nunmarshaled: %+v", tt.message, unmarshaled) - } - }) - } -} - -func TestMessageFieldValidation(t *testing.T) { - tests := []struct { - name string - jsonData string - wantErr bool - }{ - { - name: "valid JSON", - jsonData: `{"type":"request","method":"GET","path":"/test"}`, - wantErr: false, - }, - { - name: "invalid JSON", - jsonData: `{"type":"request","method":"GET"`, - wantErr: true, - }, - { - name: "empty JSON object", - jsonData: `{}`, - wantErr: false, - }, - { - name: "null values", - jsonData: `{"type":null,"method":null}`, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var msg Message - err := json.Unmarshal([]byte(tt.jsonData), &msg) - - if tt.wantErr && err == nil { - t.Error("expected error but got none") - } - if !tt.wantErr && err != nil { - t.Errorf("unexpected error: %v", err) - } - }) - } -} - -func TestMessageHeaders(t *testing.T) { - tests := []struct { - name string - headers map[string][]string - }{ - { - name: "single value headers", - headers: map[string][]string{ - "Content-Type": {"application/json"}, - "Accept": {"application/json"}, - }, - }, - { - name: "multi-value headers", - headers: map[string][]string{ - "Accept": {"text/html", "application/xml"}, - "Cache-Control": {"no-cache", "no-store"}, - }, - }, - { - name: "empty value headers", - headers: map[string][]string{ - "X-Custom": {""}, - "X-Empty": {}, - }, - }, - { - name: "mixed case headers", - headers: map[string][]string{ - "Content-TYPE": {"text/plain"}, - "accept": {"*/*"}, - "X-Custom": {"value"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg := Message{ - Type: "request", - Headers: tt.headers, - } - - data, err := json.Marshal(msg) - if err != nil { - t.Errorf("failed to marshal: %v", err) - return - } - - var unmarshaled Message - err = json.Unmarshal(data, &unmarshaled) - if err != nil { - t.Errorf("failed to unmarshal: %v", err) - return - } - - if !messagesEqual(msg, unmarshaled) { - t.Errorf("headers mismatch:\noriginal: %+v\nunmarshaled: %+v", msg.Headers, unmarshaled.Headers) - } - }) - } -} - -func TestMessageBodyHandling(t *testing.T) { - tests := []struct { - name string - body []byte - }{ - { - name: "text body", - body: []byte("hello world"), - }, - { - name: "json body", - body: []byte(`{"key":"value","number":42}`), - }, - { - name: "binary body", - body: []byte{0x00, 0x01, 0x02, 0xFF, 0xFE}, - }, - { - name: "empty body", - body: []byte{}, - }, - { - name: "nil body", - body: nil, - }, - { - name: "large body", - body: make([]byte, 10000), // 10KB of zeros - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg := Message{ - Type: "request", - Body: tt.body, - } - - data, err := json.Marshal(msg) - if err != nil { - t.Errorf("failed to marshal: %v", err) - return - } - - var unmarshaled Message - err = json.Unmarshal(data, &unmarshaled) - if err != nil { - t.Errorf("failed to unmarshal: %v", err) - return - } - - if !messagesEqual(msg, unmarshaled) { - t.Errorf("body mismatch:\noriginal: %+v\nunmarshaled: %+v", msg.Body, unmarshaled.Body) - } - }) - } -} - -// benchmark tests -func BenchmarkMessageMarshal(b *testing.B) { - msg := Message{ - Type: "request", - RequestID: "bench-test-123", - Method: "POST", - Path: "/api/benchmark", - Headers: map[string][]string{ - "Content-Type": {"application/json"}, - "Authorization": {"Bearer token123"}, - "User-Agent": {"benchmark-client/1.0"}, - }, - Body: []byte(`{"test":"data","number":42,"array":[1,2,3]}`), - } - - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _, err := json.Marshal(msg) - if err != nil { - b.Fatal(err) - } - } -} - -func BenchmarkMessageUnmarshal(b *testing.B) { - data := []byte(`{"type":"request","request_id":"bench-test-123","method":"POST","path":"/api/benchmark","headers":{"Content-Type":["application/json"],"Authorization":["Bearer token123"]},"body":"eyJ0ZXN0IjoiZGF0YSJ9"}`) - - b.ReportAllocs() - for i := 0; i < b.N; i++ { - var msg Message - err := json.Unmarshal(data, &msg) - if err != nil { - b.Fatal(err) - } - } -} - -func BenchmarkMessageRoundtrip(b *testing.B) { - msg := Message{ - Type: "request", - RequestID: "bench-test-123", - Method: "GET", - Path: "/api/test", - Headers: map[string][]string{ - "Accept": {"application/json"}, - }, - Body: []byte("test data"), - } - - b.ReportAllocs() - for i := 0; i < b.N; i++ { - data, err := json.Marshal(msg) - if err != nil { - b.Fatal(err) - } - - var unmarshaled Message - err = json.Unmarshal(data, &unmarshaled) - if err != nil { - b.Fatal(err) - } - } -} diff --git a/shared/utils.go b/shared/utils.go deleted file mode 100644 index 7197e5a..0000000 --- a/shared/utils.go +++ /dev/null @@ -1,188 +0,0 @@ -package shared - -import ( - "crypto/rand" - "errors" - "math" - "regexp" - "strings" -) - -var defaultAlphabet = []rune("_-0123456789abcdefghijklmnopqrstuvwxyz") -var domainSafeAlphabet = []rune("0123456789abcdefghijklmnopqrstuvwxyz") - -const defaultSize = 8 - -var tunnelIDRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) - -func getMask(alphabetSize int) int { - for i := 1; i <= 8; i++ { - mask := (1 << uint(i)) - 1 - if mask >= alphabetSize-1 { - return mask - } - } - return 0 -} - -func ValidateTunnelID(id string) error { - logger := GetLogger("shared.utils") - - if len(id) == 0 { - logger.Error().Msg("tunnel ID cannot be empty") - return errors.New("tunnel ID cannot be empty") - } - - if len(id) < 3 { - logger.Error().Int("length", len(id)).Msg("tunnel ID too short") - return errors.New("tunnel ID must be at least 3 characters long") - } - - if len(id) > 63 { - logger.Error().Int("length", len(id)).Msg("tunnel ID too long") - return errors.New("tunnel ID must be no more than 63 characters long") - } - - lowerID := strings.ToLower(id) - if id != lowerID { - logger.Error().Str("id", id).Str("expected", lowerID).Msg("tunnel ID contains uppercase letters") - return errors.New("tunnel ID must be lowercase") - } - - if !tunnelIDRegex.MatchString(id) { - logger.Error().Str("id", id).Msg("tunnel ID contains invalid characters") - return errors.New("tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen") - } - - return nil -} - -func GenerateDomainSafeID(length ...int) (string, error) { - logger := GetLogger("shared.utils") - - var size int - switch { - case len(length) == 0: - size = defaultSize - case len(length) == 1: - size = length[0] - if size < 3 { - logger.Error().Int("requested_length", size).Msg("domain-safe ID length too short") - return "", errors.New("domain-safe ID must be at least 3 characters long") - } - if size > 63 { - logger.Error().Int("requested_length", size).Msg("domain-safe ID length too long") - return "", errors.New("domain-safe ID must be no more than 63 characters long") - } - default: - logger.Error().Int("param_count", len(length)).Msg("too many parameters for domain-safe ID generation") - return "", errors.New("unexpected parameter") - } - - return GenerateNanoIDWithAlphabet(string(domainSafeAlphabet), size) -} - -func MustGenerateDomainSafeID(length ...int) string { - logger := GetLogger("shared.utils") - - id, err := GenerateDomainSafeID(length...) - if err != nil { - logger.Error().Err(err).Msg("critical failure in domain-safe ID generation - panicking") - panic(err) - } - return id -} - -func GenerateNanoIDWithAlphabet(alphabet string, size int) (string, error) { - logger := GetLogger("shared.utils") - - chars := []rune(alphabet) - - if len(alphabet) == 0 || len(alphabet) > 255 { - logger.Error().Int("alphabet_length", len(alphabet)).Msg("invalid alphabet length for ID generation") - return "", errors.New("alphabet must not be empty and contain no more than 255 chars") - } - if size <= 0 { - logger.Error().Int("size", size).Msg("invalid size for ID generation") - return "", errors.New("size must be positive integer") - } - - mask := getMask(len(chars)) - ceilArg := 1.6 * float64(mask*size) / float64(len(alphabet)) - step := int(math.Ceil(ceilArg)) - - id := make([]rune, size) - bytes := make([]byte, step) - for j := 0; ; { - _, err := rand.Read(bytes) - if err != nil { - logger.Error().Err(err).Msg("failed to read random bytes for ID generation") - return "", err - } - for i := 0; i < step; i++ { - currByte := bytes[i] & byte(mask) - if currByte < byte(len(chars)) { - id[j] = chars[currByte] - j++ - if j == size { - generatedID := string(id[:size]) - return generatedID, nil - } - } - } - } -} - -func GenerateNanoID(length ...int) (string, error) { - logger := GetLogger("shared.utils") - - var size int - switch { - case len(length) == 0: - size = defaultSize - case len(length) == 1: - size = length[0] - if size < 0 { - logger.Error().Int("requested_length", size).Msg("negative ID length requested") - return "", errors.New("negative id length") - } - default: - logger.Error().Int("param_count", len(length)).Msg("too many parameters for ID generation") - return "", errors.New("unexpected parameter") - } - - bytes := make([]byte, size) - _, err := rand.Read(bytes) - if err != nil { - logger.Error().Err(err).Msg("failed to read random bytes for standard ID generation") - return "", err - } - - id := make([]rune, size) - alphabetLen := len(defaultAlphabet) - for i := 0; i < size; i++ { - id[i] = defaultAlphabet[int(bytes[i])%alphabetLen] - } - - generatedID := string(id[:size]) - return generatedID, nil -} - -func MustGenerateNanoID(length ...int) string { - logger := GetLogger("shared.utils") - - id, err := GenerateNanoID(length...) - if err != nil { - logger.Error().Err(err).Msg("critical failure in ID generation - panicking") - panic(err) - } - return id -} - -func GetBaseDomain(domain string) string { - parts := strings.Split(domain, ".") - if len(parts) > 2 { - return strings.Join(parts[len(parts)-2:], ".") - } - return domain -} diff --git a/shared/utils_test.go b/shared/utils_test.go deleted file mode 100644 index 3e7ffcb..0000000 --- a/shared/utils_test.go +++ /dev/null @@ -1,540 +0,0 @@ -package shared - -import ( - "regexp" - "strings" - "testing" -) - -func TestGenerateNanoID(t *testing.T) { - tests := []struct { - name string - length []int - expectedLen int - shouldError bool - }{ - { - name: "default length", - length: []int{}, - expectedLen: 8, - shouldError: false, - }, - { - name: "custom length 12", - length: []int{12}, - expectedLen: 12, - shouldError: false, - }, - { - name: "zero length", - length: []int{0}, - expectedLen: 0, - shouldError: false, - }, - { - name: "negative length", - length: []int{-1}, - expectedLen: 0, - shouldError: true, - }, - { - name: "too many parameters", - length: []int{8, 10}, - expectedLen: 0, - shouldError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - id, err := GenerateNanoID(tt.length...) - - if tt.shouldError { - if err == nil { - t.Errorf("expected error but got none") - } - return - } - - if err != nil { - t.Errorf("unexpected error: %v", err) - return - } - - if len(id) != tt.expectedLen { - t.Errorf("expected length %d, got %d", tt.expectedLen, len(id)) - } - - // verify characters are from the default alphabet - validChars := regexp.MustCompile(`^[_\-0-9a-zA-Z]*$`) - if !validChars.MatchString(id) { - t.Errorf("generated ID contains invalid characters: %s", id) - } - }) - } -} - -func TestGenerateNanoIDUniqueness(t *testing.T) { - generated := make(map[string]bool) - iterations := 1000 - - for i := 0; i < iterations; i++ { - id, err := GenerateNanoID() - if err != nil { - t.Fatalf("unexpected error generating ID: %v", err) - } - - if generated[id] { - t.Errorf("duplicate ID generated: %s", id) - } - generated[id] = true - } - - if len(generated) != iterations { - t.Errorf("expected %d unique IDs, got %d", iterations, len(generated)) - } -} - -func TestGenerateNanoIDWithAlphabet(t *testing.T) { - tests := []struct { - name string - alphabet string - size int - shouldError bool - errorMsg string - }{ - { - name: "valid alphabet and size", - alphabet: "abc123", - size: 8, - shouldError: false, - }, - { - name: "single character alphabet", - alphabet: "a", - size: 5, - shouldError: false, - }, - { - name: "empty alphabet", - alphabet: "", - size: 8, - shouldError: true, - errorMsg: "alphabet must not be empty", - }, - { - name: "too large alphabet", - alphabet: strings.Repeat("a", 256), - size: 8, - shouldError: true, - errorMsg: "alphabet must not be empty and contain no more than 255 chars", - }, - { - name: "zero size", - alphabet: "abc", - size: 0, - shouldError: true, - errorMsg: "size must be positive integer", - }, - { - name: "negative size", - alphabet: "abc", - size: -1, - shouldError: true, - errorMsg: "size must be positive integer", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - id, err := GenerateNanoIDWithAlphabet(tt.alphabet, tt.size) - - if tt.shouldError { - if err == nil { - t.Errorf("expected error but got none") - return - } - if !strings.Contains(err.Error(), tt.errorMsg) { - t.Errorf("expected error containing '%s', got '%s'", tt.errorMsg, err.Error()) - } - return - } - - if err != nil { - t.Errorf("unexpected error: %v", err) - return - } - - if len(id) != tt.size { - t.Errorf("expected length %d, got %d", tt.size, len(id)) - } - - // verify all characters are from the provided alphabet - for _, char := range id { - if !strings.ContainsRune(tt.alphabet, char) { - t.Errorf("generated ID contains character '%c' not in alphabet '%s'", char, tt.alphabet) - } - } - }) - } -} - -func TestGenerateNanoIDWithAlphabetUniqueness(t *testing.T) { - alphabet := "abcdef123456" - size := 8 - generated := make(map[string]bool) - iterations := 500 - - for i := 0; i < iterations; i++ { - id, err := GenerateNanoIDWithAlphabet(alphabet, size) - if err != nil { - t.Fatalf("unexpected error generating ID: %v", err) - } - - if generated[id] { - t.Errorf("duplicate ID generated: %s", id) - } - generated[id] = true - } -} - -func TestMustGenerateNanoID(t *testing.T) { - t.Run("successful generation", func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Errorf("unexpected panic: %v", r) - } - }() - - id := MustGenerateNanoID(10) - if len(id) != 10 { - t.Errorf("expected length 10, got %d", len(id)) - } - }) - - t.Run("panic on error", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Errorf("expected panic but got none") - } - }() - - // this should panic due to negative length - MustGenerateNanoID(-1) - }) -} - -func TestGetMask(t *testing.T) { - tests := []struct { - alphabetSize int - expected int - }{ - {1, 1}, - {2, 1}, - {3, 3}, - {4, 3}, - {8, 7}, - {16, 15}, - {32, 31}, - {64, 63}, - {128, 127}, - {256, 255}, - } - - for _, tt := range tests { - t.Run("", func(t *testing.T) { - result := getMask(tt.alphabetSize) - if result != tt.expected { - t.Errorf("getMask(%d) = %d, want %d", tt.alphabetSize, result, tt.expected) - } - }) - } -} - -// benchmark tests -func BenchmarkGenerateNanoID(b *testing.B) { - b.Run("default length", func(b *testing.B) { - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _, err := GenerateNanoID() - if err != nil { - b.Fatal(err) - } - } - }) - - b.Run("length 16", func(b *testing.B) { - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _, err := GenerateNanoID(16) - if err != nil { - b.Fatal(err) - } - } - }) -} - -func BenchmarkGenerateNanoIDWithAlphabet(b *testing.B) { - alphabet := "0123456789abcdef" - - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _, err := GenerateNanoIDWithAlphabet(alphabet, 8) - if err != nil { - b.Fatal(err) - } - } -} - -func BenchmarkMustGenerateNanoID(b *testing.B) { - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _ = MustGenerateNanoID() - } -} - -func TestValidateTunnelID(t *testing.T) { - tests := []struct { - name string - id string - wantErr bool - errMsg string - }{ - { - name: "valid lowercase alphanumeric", - id: "abc123", - wantErr: false, - }, - { - name: "valid with hyphens", - id: "abc-123-def", - wantErr: false, - }, - { - name: "valid minimum length", - id: "abc", - wantErr: false, - }, - { - name: "valid maximum length", - id: strings.Repeat("a", 63), - wantErr: false, - }, - { - name: "empty string", - id: "", - wantErr: true, - errMsg: "tunnel ID cannot be empty", - }, - { - name: "too short", - id: "ab", - wantErr: true, - errMsg: "tunnel ID must be at least 3 characters long", - }, - { - name: "too long", - id: strings.Repeat("a", 64), - wantErr: true, - errMsg: "tunnel ID must be no more than 63 characters long", - }, - { - name: "contains uppercase", - id: "Abc123", - wantErr: true, - errMsg: "tunnel ID must be lowercase", - }, - { - name: "starts with hyphen", - id: "-abc123", - wantErr: true, - errMsg: "tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen", - }, - { - name: "ends with hyphen", - id: "abc123-", - wantErr: true, - errMsg: "tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen", - }, - { - name: "contains invalid characters", - id: "abc_123", - wantErr: true, - errMsg: "tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen", - }, - { - name: "contains spaces", - id: "abc 123", - wantErr: true, - errMsg: "tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen", - }, - { - name: "contains special characters", - id: "abc@123", - wantErr: true, - errMsg: "tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateTunnelID(tt.id) - if tt.wantErr { - if err == nil { - t.Errorf("ValidateTunnelID() expected error but got none") - return - } - if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("ValidateTunnelID() error = %v, want error containing %v", err, tt.errMsg) - } - } else { - if err != nil { - t.Errorf("ValidateTunnelID() unexpected error = %v", err) - } - } - }) - } -} - -func TestGenerateDomainSafeID(t *testing.T) { - tests := []struct { - name string - length []int - wantErr bool - wantLength int - }{ - { - name: "default length", - length: []int{}, - wantErr: false, - wantLength: 8, - }, - { - name: "custom length 10", - length: []int{10}, - wantErr: false, - wantLength: 10, - }, - { - name: "minimum length 3", - length: []int{3}, - wantErr: false, - wantLength: 3, - }, - { - name: "maximum length 63", - length: []int{63}, - wantErr: false, - wantLength: 63, - }, - { - name: "too short", - length: []int{2}, - wantErr: true, - }, - { - name: "too long", - length: []int{64}, - wantErr: true, - }, - { - name: "too many parameters", - length: []int{8, 10}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - id, err := GenerateDomainSafeID(tt.length...) - if tt.wantErr { - if err == nil { - t.Errorf("GenerateDomainSafeID() expected error but got none") - } - return - } - - if err != nil { - t.Errorf("GenerateDomainSafeID() unexpected error = %v", err) - return - } - - if len(id) != tt.wantLength { - t.Errorf("GenerateDomainSafeID() length = %v, want %v", len(id), tt.wantLength) - } - - // validate the generated ID - if err := ValidateTunnelID(id); err != nil { - t.Errorf("GenerateDomainSafeID() generated invalid ID: %v, error: %v", id, err) - } - - // check that it only contains domain-safe characters - for _, char := range id { - if !((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9')) { - t.Errorf("GenerateDomainSafeID() generated ID contains invalid character: %c in %s", char, id) - } - } - }) - } -} - -func TestMustGenerateDomainSafeID(t *testing.T) { - t.Run("successful generation", func(t *testing.T) { - id := MustGenerateDomainSafeID() - if len(id) != 8 { - t.Errorf("MustGenerateDomainSafeID() length = %v, want 8", len(id)) - } - - if err := ValidateTunnelID(id); err != nil { - t.Errorf("MustGenerateDomainSafeID() generated invalid ID: %v, error: %v", id, err) - } - }) - - t.Run("custom length", func(t *testing.T) { - id := MustGenerateDomainSafeID(12) - if len(id) != 12 { - t.Errorf("MustGenerateDomainSafeID(12) length = %v, want 12", len(id)) - } - - if err := ValidateTunnelID(id); err != nil { - t.Errorf("MustGenerateDomainSafeID(12) generated invalid ID: %v, error: %v", id, err) - } - }) -} - -func TestDomainSafeIDUniqueness(t *testing.T) { - // generate multiple IDs and ensure they're unique - ids := make(map[string]bool) - numIDs := 1000 - - for i := 0; i < numIDs; i++ { - id := MustGenerateDomainSafeID() - if ids[id] { - t.Errorf("GenerateDomainSafeID() generated duplicate ID: %s", id) - } - ids[id] = true - } - - if len(ids) != numIDs { - t.Errorf("Expected %d unique IDs, got %d", numIDs, len(ids)) - } -} - -func TestBackwardCompatibilityNanoID(t *testing.T) { - // ensure the regular NanoID still works but now uses lowercase - id, err := GenerateNanoID() - if err != nil { - t.Errorf("GenerateNanoID() unexpected error = %v", err) - } - - if len(id) != 8 { - t.Errorf("GenerateNanoID() length = %v, want 8", len(id)) - } - - // check that it doesn't contain uppercase letters anymore - for _, char := range id { - if char >= 'A' && char <= 'Z' { - t.Errorf("GenerateNanoID() generated ID contains uppercase character: %c in %s", char, id) - } - } -} diff --git a/version/go.mod b/version/go.mod deleted file mode 100644 index 45556a4..0000000 --- a/version/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/karol-broda/funnel/version - -go 1.24.4 diff --git a/version/version.go b/version/version.go deleted file mode 100644 index 40e212d..0000000 --- a/version/version.go +++ /dev/null @@ -1,57 +0,0 @@ -package version - -import ( - "fmt" - "runtime" -) - -var ( - Version = "0.0.8" - BuildDate = "unknown" - GitCommit = "unknown" - GitTag = "unknown" -) - -type BuildInfo struct { - Version string - BuildDate string - GitCommit string - GitTag string - GoVersion string -} - -func GetBuildInfo() BuildInfo { - return BuildInfo{ - Version: Version, - BuildDate: BuildDate, - GitCommit: GitCommit, - GitTag: GitTag, - GoVersion: runtime.Version(), - } -} - -func GetVersion() string { - return Version -} - -func GetFullVersion() string { - info := GetBuildInfo() - if info.GitCommit != "unknown" && len(info.GitCommit) > 7 { - info.GitCommit = info.GitCommit[:7] - } - - if info.GitTag != "unknown" && info.GitTag != "" { - return fmt.Sprintf("%s (%s)", info.GitTag, info.GitCommit) - } - - return fmt.Sprintf("%s (%s)", info.Version, info.GitCommit) -} - -func PrintVersionInfo(appName string) { - info := GetBuildInfo() - fmt.Printf("%s version %s\n", appName, info.Version) - fmt.Printf("Build date: %s\n", info.BuildDate) - fmt.Printf("Git commit: %s\n", info.GitCommit) - fmt.Printf("Git tag: %s\n", info.GitTag) - fmt.Printf("Go version: %s\n", info.GoVersion) -} From fe17d96e20879c907075741c40a6c5527dc0b481 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sat, 2 May 2026 12:30:00 +0200 Subject: [PATCH 15/42] chore: track Cargo.lock for reproducible builds --- .gitignore | 1 - Cargo.lock | 4619 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 4619 insertions(+), 1 deletion(-) create mode 100644 Cargo.lock diff --git a/.gitignore b/.gitignore index f4b6ed6..240de45 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # Rust /target **/*.rs.bk -Cargo.lock # Environment .env diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..363ce43 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4619 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "any_spawner" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41058deaa38c9d9dd933d6d238d825227cffa668e2839b52879f6619c63eee3b" +dependencies = [ + "futures", + "thiserror 2.0.18", + "wasm-bindgen-futures", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn", +] + +[[package]] +name = "auto-future" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core 0.5.6", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-test" +version = "16.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e3a443d2608936a02a222da7b746eb412fede7225b3030b64fe9be99eab8dc" +dependencies = [ + "anyhow", + "assert-json-diff", + "auto-future", + "axum 0.7.9", + "bytes", + "bytesize", + "cookie", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "pretty_assertions", + "reserve-port", + "rust-multipart-rfc7578_2", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "tokio", + "tower", + "url", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytesize" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codee" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68cfe19cd7d23ffde002c24ffa5cda73931913ef394d5eaaa32037dc940c0c" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde_core", + "toml 1.1.2+spec-1.1.0", + "winnow 1.0.2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "either_of" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd" +dependencies = [ + "paste", + "pin-project-lite", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funnel-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum 0.8.9", + "clap", + "dirs", + "flate2", + "funnel-core", + "futures", + "http 1.4.0", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite 0.26.2", + "tokio-util", + "toml 0.8.23", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "funnel-core" +version = "0.1.0" +dependencies = [ + "base64", + "bytes", + "getrandom 0.3.4", + "hex", + "http 1.4.0", + "nanoid", + "pretty_assertions", + "regex", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "funnel-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum 0.8.9", + "axum-test", + "chrono", + "clap", + "dashmap", + "funnel-core", + "futures", + "ipnetwork", + "jsonwebtoken", + "pretty_assertions", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "funnel-web" +version = "0.1.0" +dependencies = [ + "funnel-core", + "leptos", + "serde", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http 1.4.0", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hydration_context" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35485b3dcbf7e044b8f28c73f04f13e7b509c2466fd10cb2a8a447e38f8a93a" +dependencies = [ + "futures", + "once_cell", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.0", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "leptos" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b8731cb00f3f0894058155410b95c8955b17273181d2bc72600ab84edd24f1" +dependencies = [ + "any_spawner", + "cfg-if", + "either_of", + "futures", + "hydration_context", + "leptos_config", + "leptos_dom", + "leptos_hot_reload", + "leptos_macro", + "leptos_server", + "oco_ref", + "or_poisoned", + "paste", + "reactive_graph", + "rustc-hash", + "send_wrapper", + "serde", + "serde_qs", + "server_fn", + "slotmap", + "tachys", + "thiserror 2.0.18", + "throw_error", + "typed-builder", + "typed-builder-macro", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_config" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bae3e0ead5a7a814c8340eef7cb8b6cba364125bd8174b15dc9fe1b3cab7e03" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.18", + "typed-builder", +] + +[[package]] +name = "leptos_dom" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89d4eb263bd5a9e7c49f780f17063f15aca56fd638c90b9dfd5f4739152e87d" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "tachys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e80219388501d99b246f43b6e7d08a28f327cdd34ba630a35654d917f3e1788e" +dependencies = [ + "anyhow", + "camino", + "indexmap", + "parking_lot", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn", + "walkdir", +] + +[[package]] +name = "leptos_macro" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e621f8f5342b9bdc93bb263b839cee7405027a74560425a2dabea9de7952b1fd" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.7.1", + "html-escape", + "itertools", + "leptos_hot_reload", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "server_fn_macro", + "syn", + "uuid", +] + +[[package]] +name = "leptos_server" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66985242812ec95e224fb48effe651ba02728beca92c461a9464c811a71aab11" +dependencies = [ + "any_spawner", + "base64", + "codee", + "futures", + "hydration_context", + "or_poisoned", + "reactive_graph", + "send_wrapper", + "serde", + "serde_json", + "server_fn", + "tachys", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linear-map" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.6", +] + +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "reactive_graph" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a0ccddbc11a648bd09761801dac9e3f246ef7641130987d6120fced22515e6" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "hydration_context", + "or_poisoned", + "pin-project-lite", + "rustc-hash", + "send_wrapper", + "serde", + "slotmap", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aadc7c19e3a360bf19cd595d2dc8b58ce67b9240b95a103fbc1317a8ff194237" +dependencies = [ + "guardian", + "itertools", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221095cb028dc51fbc2833743ea8b1a585da1a2af19b440b3528027495bf1f2d" +dependencies = [ + "convert_case 0.7.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "reserve-port" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94070964579245eb2f76e62a7668fe87bd9969ed6c41256f3bf614e3323dd3cc" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rstml" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", + "syn_derive", + "thiserror 2.0.18", +] + +[[package]] +name = "rust-multipart-rfc7578_2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" +dependencies = [ + "bytes", + "futures-core", + "futures-util", + "http 0.2.12", + "mime", + "mime_guess", + "rand 0.8.6", + "thiserror 1.0.69", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_qs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd34f36fe4c5ba9654417139a9b3a20d2e1de6012ee678ad14d240c22c78d8d6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "server_fn" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d05a9e3fd8d7404985418db38c6617cc793a1a27f398d4fbc9dfe8e41b804e6" +dependencies = [ + "bytes", + "const_format", + "dashmap", + "futures", + "gloo-net", + "http 1.4.0", + "js-sys", + "once_cell", + "pin-project-lite", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror 2.0.18", + "throw_error", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504b35e883267b3206317b46d02952ed7b8bf0e11b2e209e2eb453b609a5e052" +dependencies = [ + "const_format", + "convert_case 0.6.0", + "proc-macro2", + "quote", + "syn", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8b274f568c94226a8045668554aace8142a59b8bca5414ac5a79627c825568" +dependencies = [ + "server_fn_macro", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "ipnetwork", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "ipnetwork", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tachys" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66c3b70c32844a6f1e2943c72a33ebb777ad6acbeb20d1329d62e3a7806d6ec" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "dyn-clone", + "either_of", + "futures", + "html-escape", + "indexmap", + "itertools", + "js-sys", + "linear-map", + "next_tuple", + "oco_ref", + "once_cell", + "or_poisoned", + "parking_lot", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash", + "send_wrapper", + "slotmap", + "throw_error", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "throw_error" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ef8bf264c6ae02a065a4a16553283f0656bd6266fc1fcb09fd2e6b5e91427b" +dependencies = [ + "pin-project-lite", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.26.2", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.2", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + +[[package]] +name = "typed-builder" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[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.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" From bec50fe8d5101d0ebdc563d6cb9db673c9e6156b Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sat, 2 May 2026 17:02:42 +0200 Subject: [PATCH 16/42] feat(server): add automatic TLS with ACME certificate management --- Cargo.lock | 1379 +++++++++++++++-- crates/funnel-client/src/main.rs | 1 + crates/funnel-client/src/tunnel.rs | 13 +- crates/funnel-server/Cargo.toml | 11 + crates/funnel-server/src/api/keys.rs | 8 +- crates/funnel-server/src/app.rs | 12 +- crates/funnel-server/src/main.rs | 137 +- crates/funnel-server/src/proxy/router.rs | 4 +- crates/funnel-server/src/tls/config.rs | 27 + crates/funnel-server/src/tls/manager.rs | 369 +++++ crates/funnel-server/src/tls/mod.rs | 44 + .../src/tls/provider/cloudflare.rs | 117 ++ crates/funnel-server/src/tls/provider/exec.rs | 45 + crates/funnel-server/src/tls/provider/mod.rs | 88 ++ .../funnel-server/src/tls/provider/route53.rs | 112 ++ flake.lock | 21 + 16 files changed, 2242 insertions(+), 146 deletions(-) create mode 100644 crates/funnel-server/src/tls/config.rs create mode 100644 crates/funnel-server/src/tls/manager.rs create mode 100644 crates/funnel-server/src/tls/provider/cloudflare.rs create mode 100644 crates/funnel-server/src/tls/provider/exec.rs create mode 100644 crates/funnel-server/src/tls/provider/mod.rs create mode 100644 crates/funnel-server/src/tls/provider/route53.rs diff --git a/Cargo.lock b/Cargo.lock index 363ce43..285271d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -99,6 +93,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "argon2" version = "0.5.3" @@ -107,10 +110,49 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -200,6 +242,408 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-config" +version = "1.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.0", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-route53" +version = "1.111.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f556e78b01bc3b6fe405e2c6c4e538778b9da1cfea59c9f1123a84175305455e" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.100.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.9.0", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.7.9" @@ -211,9 +655,9 @@ dependencies = [ "bytes", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "itoa", "matchit 0.7.3", @@ -246,9 +690,9 @@ dependencies = [ "form_urlencoded", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "itoa", "matchit 0.8.4", @@ -280,7 +724,7 @@ dependencies = [ "bytes", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -300,7 +744,7 @@ dependencies = [ "bytes", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -310,6 +754,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "hyper-util", + "pin-project-lite", + "rustls 0.23.40", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + [[package]] name = "axum-test" version = "16.4.1" @@ -325,7 +791,7 @@ dependencies = [ "cookie", "http 1.4.0", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "mime", "pretty_assertions", @@ -346,6 +812,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -367,7 +843,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -379,6 +855,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -395,7 +880,17 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] [[package]] name = "bytesize" @@ -416,6 +911,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -485,6 +982,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "codee" version = "0.3.5" @@ -508,6 +1020,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -536,6 +1058,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const_format" version = "0.2.36" @@ -591,6 +1119,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -606,6 +1144,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -621,15 +1168,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -655,6 +1193,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -681,11 +1237,25 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -718,12 +1288,24 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", + "ctutils", +] + [[package]] name = "dirs" version = "6.0.0" @@ -768,6 +1350,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -842,20 +1430,16 @@ dependencies = [ ] [[package]] -name = "find-msvc-tools" -version = "0.1.9" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "flate2" -version = "1.1.9" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" @@ -880,6 +1464,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -889,19 +1479,33 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funnel-client" version = "0.1.0" dependencies = [ "anyhow", - "axum 0.8.9", "clap", "dirs", - "flate2", "funnel-core", "futures", "http 1.4.0", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -928,7 +1532,7 @@ dependencies = [ "regex", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", "uuid", ] @@ -939,16 +1543,24 @@ version = "0.1.0" dependencies = [ "anyhow", "argon2", + "aws-config", + "aws-sdk-route53", "axum 0.8.9", + "axum-server", "axum-test", "chrono", "clap", "dashmap", "funnel-core", "futures", + "instant-acme", "ipnetwork", "jsonwebtoken", + "lru", "pretty_assertions", + "reqwest 0.13.3", + "rustls 0.23.40", + "rustls-pemfile", "serde", "serde_json", "sqlx", @@ -960,6 +1572,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "x509-parser", ] [[package]] @@ -1160,6 +1773,44 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1174,7 +1825,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1210,7 +1872,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -1219,7 +1881,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", ] [[package]] @@ -1261,6 +1932,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1280,7 +1962,7 @@ dependencies = [ "bytes", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "pin-project-lite", ] @@ -1296,6 +1978,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + [[package]] name = "hydration_context" version = "0.2.1" @@ -1310,6 +2001,30 @@ dependencies = [ "throw_error", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1320,8 +2035,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2 0.4.13", "http 1.4.0", - "http-body", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1331,6 +2047,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -1338,11 +2069,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.0", - "hyper", + "hyper 1.9.0", "hyper-util", - "rustls", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-platform-verifier", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots 1.0.7", ] @@ -1358,13 +2091,13 @@ dependencies = [ "futures-channel", "futures-util", "http 1.4.0", - "http-body", - "hyper", + "http-body 1.0.1", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1515,6 +2248,32 @@ dependencies = [ "serde_core", ] +[[package]] +name = "instant-acme" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64", + "bytes", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "httpdate", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "rcgen", + "rustls 0.23.40", + "rustls-pki-types", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "interpolator" version = "0.5.0" @@ -1537,35 +2296,94 @@ dependencies = [ ] [[package]] -name = "iri-string" -version = "0.7.12" +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "memchr", - "serde", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] [[package]] -name = "itertools" -version = "0.14.0" +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ - "either", + "quote", + "syn", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "jobserver" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] [[package]] name = "js-sys" @@ -1808,6 +2626,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1865,7 +2692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -1891,14 +2718,10 @@ dependencies = [ ] [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "minimal-lexical" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" @@ -1926,6 +2749,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2007,6 +2840,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2019,6 +2861,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2031,6 +2879,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -2134,6 +2988,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkcs1" version = "0.7.5" @@ -2278,8 +3138,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", - "socket2", + "rustls 0.23.40", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2292,13 +3152,14 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", "rand 0.9.4", "ring", "rustc-hash", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -2316,7 +3177,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -2423,6 +3284,20 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "reactive_graph" version = "0.1.8" @@ -2524,6 +3399,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -2540,24 +3421,24 @@ dependencies = [ "bytes", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.9.0", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -2568,6 +3449,44 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.40", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reserve-port" version = "2.4.0" @@ -2587,7 +3506,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -2597,8 +3516,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -2648,20 +3567,73 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -2672,15 +3644,53 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2704,12 +3714,54 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted 0.9.0", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2881,8 +3933,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2892,8 +3944,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -2927,15 +3990,25 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] [[package]] -name = "simd-adler32" -version = "0.3.9" +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "simple_asn1" @@ -2973,6 +4046,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3040,10 +4123,10 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls", + "rustls 0.23.40", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "tokio", @@ -3082,7 +4165,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -3105,7 +4188,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -3115,7 +4198,7 @@ dependencies = [ "generic-array", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "itoa", "log", "md-5", @@ -3126,7 +4209,7 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -3155,7 +4238,7 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "home", "ipnetwork", "itoa", @@ -3166,7 +4249,7 @@ dependencies = [ "rand 0.8.6", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -3434,7 +4517,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3450,13 +4533,23 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.40", "tokio", ] @@ -3479,10 +4572,10 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tungstenite 0.26.2", "webpki-roots 0.26.11", ] @@ -3610,7 +4703,7 @@ dependencies = [ "bytes", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -3724,7 +4817,7 @@ dependencies = [ "httparse", "log", "rand 0.9.4", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "sha1", "thiserror 2.0.18", @@ -3818,6 +4911,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -3836,6 +4935,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -3890,6 +4995,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -4061,6 +5172,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -4497,6 +5617,30 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "xxhash-rust" version = "0.8.15" @@ -4509,6 +5653,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/crates/funnel-client/src/main.rs b/crates/funnel-client/src/main.rs index 4602c08..bb2953f 100644 --- a/crates/funnel-client/src/main.rs +++ b/crates/funnel-client/src/main.rs @@ -105,6 +105,7 @@ async fn run_http( let server_url = server_flag .or_else(|| inlet.map(|i| i.server.clone())) .filter(|s| !s.is_empty()) + .map(|s| if s.contains("://") { s } else { format!("http://{s}") }) .ok_or_else(|| { anyhow::anyhow!( "no server configured. use --server or run: funnel config set-server " diff --git a/crates/funnel-client/src/tunnel.rs b/crates/funnel-client/src/tunnel.rs index 2e24ceb..b754a6e 100644 --- a/crates/funnel-client/src/tunnel.rs +++ b/crates/funnel-client/src/tunnel.rs @@ -95,18 +95,17 @@ impl TunnelClient { > { let mut url = Url::parse(&self.server_url)?; - match url.scheme() { - "https" => url.set_scheme("wss").unwrap(), - _ => url.set_scheme("ws").unwrap(), - }; + let ws_scheme = if url.scheme() == "https" { "wss" } else { "ws" }; + url.set_scheme(ws_scheme) + .map_err(|_| anyhow::anyhow!("failed to set websocket scheme on url: {url}"))?; url.set_path("/ws"); url.query_pairs_mut() .append_pair("id", self.tunnel_id.as_ref()); - let mut request = tokio_tungstenite::tungstenite::http::Request::builder() - .uri(url.as_str()) - .body(())?; + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + + let mut request = url.as_str().into_client_request()?; if let Some(token) = &self.token { request.headers_mut().insert( diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index 066fdb3..f274d84 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -27,6 +27,17 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } uuid = { version = "1", features = ["v7", "serde"] } +# tls +aws-config = "1" +aws-sdk-route53 = "1" +axum-server = { version = "0.8", features = ["tls-rustls"] } +instant-acme = { version = "0.8", features = ["rcgen"] } +lru = "0.16" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "query"] } +rustls = "0.23" +rustls-pemfile = "2" +x509-parser = "0.18" + [dev-dependencies] axum-test = "16" pretty_assertions = "1" diff --git a/crates/funnel-server/src/api/keys.rs b/crates/funnel-server/src/api/keys.rs index c612af4..a0f43dd 100644 --- a/crates/funnel-server/src/api/keys.rs +++ b/crates/funnel-server/src/api/keys.rs @@ -26,7 +26,7 @@ pub struct CreateKeyResponse { pub async fn list(State(state): State>) -> Result>, AppError> { // TODO: get user_id from auth middleware // For now, return empty - let _pool = &state.db; + let _pool = state.require_db()?; Ok(Json(vec![])) } @@ -35,7 +35,8 @@ pub async fn create( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let (plaintext, key) = api_keys::create(&state.db, req.user_id, &req.name).await?; + let db = state.require_db()?; + let (plaintext, key) = api_keys::create(db, req.user_id, &req.name).await?; Ok(Json(CreateKeyResponse { key: plaintext, @@ -51,7 +52,8 @@ pub async fn revoke( // TODO: get user_id from auth middleware let user_id = Uuid::nil(); // placeholder - let revoked = api_keys::revoke(&state.db, id, user_id).await?; + let db = state.require_db()?; + let revoked = api_keys::revoke(db, id, user_id).await?; if !revoked { return Err(AppError::NotFound("api key not found".into())); } diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs index 95bfcaa..dc70298 100644 --- a/crates/funnel-server/src/app.rs +++ b/crates/funnel-server/src/app.rs @@ -13,19 +13,27 @@ use crate::ws; use crate::proxy; pub struct AppState { - pub db: PgPool, + pub db: Option, pub tunnels: TunnelManager, pub start_time: Instant, + pub is_tls: bool, } impl AppState { - pub fn new(db: PgPool) -> Self { + pub fn new(db: Option, is_tls: bool) -> Self { Self { db, tunnels: TunnelManager::new(), start_time: Instant::now(), + is_tls, } } + + pub fn require_db(&self) -> Result<&PgPool, crate::error::AppError> { + self.db + .as_ref() + .ok_or_else(|| crate::error::AppError::BadRequest("database not configured".into())) + } } pub fn build_router(state: Arc) -> Router { diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index c60dd8e..642162f 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -8,8 +8,13 @@ mod tls; mod tunnel; mod ws; +use std::net::SocketAddr; +use std::path::Path; use std::sync::Arc; +use axum::extract::Request; +use axum::response::Redirect; +use axum::Router; use clap::Parser; use sqlx::postgres::PgPoolOptions; use tracing_subscriber::EnvFilter; @@ -27,11 +32,35 @@ struct Cli { /// PostgreSQL connection URL #[arg(long, env = "DATABASE_URL")] - database_url: String, + database_url: Option, /// Maximum database connections #[arg(long, default_value_t = 10)] db_max_connections: u32, + + /// Enable TLS/HTTPS + #[arg(long)] + enable_tls: bool, + + /// TLS port + #[arg(long, default_value_t = 8443)] + tls_port: u16, + + /// Certificate storage directory + #[arg(long, default_value = "./certs")] + cert_dir: String, + + /// Let's Encrypt email for ACME registration + #[arg(long, env = "LETSENCRYPT_EMAIL")] + letsencrypt_email: Option, + + /// Path to DNS providers config JSON + #[arg(long, env = "DNS_PROVIDERS_CONFIG")] + dns_providers_config: Option, + + /// Use Let's Encrypt staging environment + #[arg(long)] + acme_staging: bool, } #[tokio::main] @@ -47,32 +76,102 @@ async fn main() -> anyhow::Result<()> { tracing::info!(host = %cli.host, port = cli.port, "starting funnel server"); - let pool = PgPoolOptions::new() - .max_connections(cli.db_max_connections) - .connect(&cli.database_url) - .await?; + let pool = if let Some(ref database_url) = cli.database_url { + let pool = PgPoolOptions::new() + .max_connections(cli.db_max_connections) + .connect(database_url) + .await?; + + tracing::info!("connected to database"); + + sqlx::migrate!("../../migrations") + .run(&pool) + .await?; + + tracing::info!("database migrations applied"); + Some(pool) + } else { + tracing::info!("no database configured, running in memory-only mode"); + None + }; + + if cli.enable_tls { + run_with_tls(cli, pool).await + } else { + run_plain(cli, pool).await + } +} + +async fn run_plain(cli: Cli, pool: Option) -> anyhow::Result<()> { + let state = Arc::new(app::AppState::new(pool, false)); + let router = app::build_router(state); + + let addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?; + let listener = tokio::net::TcpListener::bind(addr).await?; - tracing::info!("connected to database"); + tracing::info!(addr = %addr, "server listening"); - sqlx::migrate!("../../migrations") - .run(&pool) - .await?; + axum::serve(listener, router.into_make_service_with_connect_info::()).await?; - tracing::info!("database migrations applied"); + Ok(()) +} - let state = Arc::new(app::AppState::new(pool)); +async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<()> { + let email = cli + .letsencrypt_email + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--letsencrypt-email required when TLS is enabled"))?; + let config_path = cli + .dns_providers_config + .as_deref() + .ok_or_else(|| anyhow::anyhow!("--dns-providers-config required when TLS is enabled"))?; + + let rustls_config = + tls::setup(Path::new(&cli.cert_dir), Path::new(config_path), email, cli.acme_staging) + .await?; + + let state = Arc::new(app::AppState::new(pool, true)); let router = app::build_router(state); - let addr = format!("{}:{}", cli.host, cli.port); - let listener = tokio::net::TcpListener::bind(&addr).await?; + let tls_addr: SocketAddr = format!("{}:{}", cli.host, cli.tls_port).parse()?; + let http_addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?; - tracing::info!(addr = %addr, "server listening"); + tracing::info!(tls_addr = %tls_addr, http_addr = %http_addr, "server listening with TLS"); - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .await?; + let https_server = axum_server::bind_rustls(tls_addr, rustls_config) + .serve(router.into_make_service_with_connect_info::()); + + let redirect_router = https_redirect_router(cli.tls_port); + let http_listener = tokio::net::TcpListener::bind(http_addr).await?; + let http_server = axum::serve(http_listener, redirect_router.into_make_service()); + + tokio::select! { + res = https_server => { res?; } + res = http_server => { res?; } + }; Ok(()) } + +fn https_redirect_router(tls_port: u16) -> Router { + Router::new().fallback(move |request: Request| async move { + let host = request + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost"); + let host_without_port = host.split(':').next().unwrap_or(host); + let path = request + .uri() + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + + let location = if tls_port == 443 { + format!("https://{host_without_port}{path}") + } else { + format!("https://{host_without_port}:{tls_port}{path}") + }; + Redirect::permanent(&location) + }) +} diff --git a/crates/funnel-server/src/proxy/router.rs b/crates/funnel-server/src/proxy/router.rs index e970358..3fce6fc 100644 --- a/crates/funnel-server/src/proxy/router.rs +++ b/crates/funnel-server/src/proxy/router.rs @@ -48,12 +48,12 @@ pub async fn handle_tunnel_request( .map(|ci| ci.0); let headers = match remote_addr { - Some(addr) => prepare_forwarding_headers(request.headers(), host, addr, false), + Some(addr) => prepare_forwarding_headers(request.headers(), host, addr, state.is_tls), None => prepare_forwarding_headers( request.headers(), host, std::net::SocketAddr::from(([0, 0, 0, 0], 0)), - false, + state.is_tls, ), }; diff --git a/crates/funnel-server/src/tls/config.rs b/crates/funnel-server/src/tls/config.rs new file mode 100644 index 0000000..0e0ac6d --- /dev/null +++ b/crates/funnel-server/src/tls/config.rs @@ -0,0 +1,27 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct ProviderConfig { + pub providers: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct DnsProviderEntry { + #[allow(dead_code)] + pub name: String, + #[serde(rename = "type")] + pub provider_type: String, + pub domains: Vec, + pub config: HashMap, +} + +impl ProviderConfig { + pub async fn load(path: &Path) -> anyhow::Result { + let contents = tokio::fs::read_to_string(path).await?; + let config: ProviderConfig = serde_json::from_str(&contents)?; + Ok(config) + } +} diff --git a/crates/funnel-server/src/tls/manager.rs b/crates/funnel-server/src/tls/manager.rs new file mode 100644 index 0000000..89be9c7 --- /dev/null +++ b/crates/funnel-server/src/tls/manager.rs @@ -0,0 +1,369 @@ +use std::io::BufReader; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +use anyhow::{Context, Result}; +use instant_acme::{ + Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, NewAccount, + NewOrder, RetryPolicy, +}; +use lru::LruCache; +use rustls::server::ResolvesServerCert; +use rustls::sign::CertifiedKey; +use x509_parser::prelude::*; + +use super::provider::ProviderMux; + +const LRU_CACHE_SIZE: usize = 512; +const RENEWAL_CHECK_INTERVAL: Duration = Duration::from_secs(3600); +const RENEWAL_WINDOW: Duration = Duration::from_secs(30 * 24 * 3600); +const ACME_POLL_TIMEOUT: Duration = Duration::from_secs(300); +const DNS_PROPAGATION_DELAY: Duration = Duration::from_secs(10); + +struct CachedCert { + certified_key: Arc, + not_after: SystemTime, +} + +pub struct CertificateManager { + cache: Mutex>, + obtain_lock: tokio::sync::Mutex<()>, + cert_dir: PathBuf, + provider_mux: Arc, + account: Account, +} + +impl std::fmt::Debug for CertificateManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CertificateManager") + .field("cert_dir", &self.cert_dir) + .finish_non_exhaustive() + } +} + +impl CertificateManager { + pub async fn new( + cert_dir: PathBuf, + provider_mux: ProviderMux, + email: &str, + staging: bool, + ) -> Result { + tokio::fs::create_dir_all(&cert_dir) + .await + .context("failed to create certificate directory")?; + + let account = load_or_create_account(&cert_dir, email, staging).await?; + let cache = Mutex::new(LruCache::new( + std::num::NonZeroUsize::new(LRU_CACHE_SIZE).unwrap(), + )); + + Ok(Self { + cache, + obtain_lock: tokio::sync::Mutex::new(()), + cert_dir, + provider_mux: Arc::new(provider_mux), + account, + }) + } + + pub async fn preload_certificates(&self) -> Result<()> { + for domain in self.provider_mux.domains() { + match self.load_from_disk(&domain).await { + Ok(Some((certified_key, not_after))) => { + tracing::info!(domain = %domain, "loaded certificate from disk"); + let mut cache = self.cache.lock().unwrap(); + cache.put( + domain, + CachedCert { + certified_key: Arc::new(certified_key), + not_after, + }, + ); + } + Ok(None) => { + tracing::info!(domain = %domain, "no cached certificate, obtaining from ACME"); + if let Err(e) = self.obtain_certificate(&domain).await { + tracing::error!(domain = %domain, error = %e, "failed to obtain certificate"); + } + } + Err(e) => { + tracing::warn!(domain = %domain, error = %e, "failed to load cert from disk"); + if let Err(e) = self.obtain_certificate(&domain).await { + tracing::error!(domain = %domain, error = %e, "failed to obtain certificate"); + } + } + } + } + Ok(()) + } + + pub async fn obtain_certificate(&self, domain: &str) -> Result<()> { + let _guard = self.obtain_lock.lock().await; + + // double check cache after acquiring lock + { + let cache = self.cache.lock().unwrap(); + if let Some(cached) = cache.peek(domain) { + if cached.not_after > SystemTime::now() + RENEWAL_WINDOW { + return Ok(()); + } + } + } + + let provider = self + .provider_mux + .find(domain) + .context("no DNS provider configured for domain")?; + + tracing::info!(domain = %domain, "ordering certificate from ACME"); + + let identifiers = vec![Identifier::Dns(domain.to_string())]; + let mut order = self + .account + .new_order(&NewOrder::new(&identifiers)) + .await + .context("failed to create ACME order")?; + + // process authorizations: present DNS challenges and mark them ready + struct PendingCleanup { + record_name: String, + dns_value: String, + } + let mut cleanups = Vec::new(); + + { + let mut authz_stream = order.authorizations(); + while let Some(result) = authz_stream.next().await { + let mut authz = result.context("failed to get authorization")?; + + if matches!(authz.status, AuthorizationStatus::Valid) { + continue; + } + + let challenge_domain = match authz.identifier().identifier { + Identifier::Dns(d) => d.clone(), + _ => continue, + }; + + let mut challenge = authz + .challenge(ChallengeType::Dns01) + .context("no DNS-01 challenge found")?; + + let dns_value = challenge.key_authorization().dns_value(); + + let record_name = format!("_acme-challenge.{challenge_domain}"); + provider + .present(&record_name, &dns_value) + .await + .context("failed to present DNS challenge")?; + + tokio::time::sleep(DNS_PROPAGATION_DELAY).await; + + challenge + .set_ready() + .await + .context("failed to set challenge ready")?; + + cleanups.push(PendingCleanup { + record_name, + dns_value, + }); + } + } + + let retries = RetryPolicy::new().timeout(ACME_POLL_TIMEOUT); + + order + .poll_ready(&retries) + .await + .context("failed waiting for order to become ready")?; + + // finalize: generates ECDSA key + CSR internally, returns private key PEM + let private_key_pem = order + .finalize() + .await + .context("failed to finalize ACME order")?; + + let cert_pem = order + .poll_certificate(&retries) + .await + .context("failed to get certificate")?; + + // save to disk + let cert_path = self.cert_dir.join(format!("{domain}.crt")); + let key_path = self.cert_dir.join(format!("{domain}.key")); + tokio::fs::write(&cert_path, &cert_pem).await?; + tokio::fs::write(&key_path, &private_key_pem).await?; + + // parse and cache + let certified_key = load_certified_key(&cert_pem, &private_key_pem)?; + let not_after = parse_cert_expiry(&cert_pem)?; + + { + let mut cache = self.cache.lock().unwrap(); + cache.put( + domain.to_string(), + CachedCert { + certified_key: Arc::new(certified_key), + not_after, + }, + ); + } + + tracing::info!(domain = %domain, "certificate obtained and cached"); + + // clean up DNS records (best effort, after everything succeeded) + for cleanup in &cleanups { + if let Err(e) = provider.cleanup(&cleanup.record_name, &cleanup.dns_value).await { + tracing::warn!( + record = %cleanup.record_name, + error = %e, + "failed to clean up DNS record" + ); + } + } + + Ok(()) + } + + async fn load_from_disk(&self, domain: &str) -> Result> { + let cert_path = self.cert_dir.join(format!("{domain}.crt")); + let key_path = self.cert_dir.join(format!("{domain}.key")); + + if !cert_path.exists() || !key_path.exists() { + return Ok(None); + } + + let cert_pem = tokio::fs::read_to_string(&cert_path).await?; + let key_pem = tokio::fs::read_to_string(&key_path).await?; + + let certified_key = load_certified_key(&cert_pem, &key_pem)?; + let not_after = parse_cert_expiry(&cert_pem)?; + + Ok(Some((certified_key, not_after))) + } + + pub fn spawn_renewal_task(self: &Arc) { + let manager = Arc::clone(self); + tokio::spawn(async move { + let mut interval = tokio::time::interval(RENEWAL_CHECK_INTERVAL); + loop { + interval.tick().await; + manager.check_renewals().await; + } + }); + } + + async fn check_renewals(&self) { + let domains_to_renew: Vec = { + let cache = self.cache.lock().unwrap(); + cache + .iter() + .filter(|(_, cert)| { + cert.not_after + .duration_since(SystemTime::now()) + .unwrap_or(Duration::ZERO) + < RENEWAL_WINDOW + }) + .map(|(domain, _)| domain.clone()) + .collect() + }; + + for domain in domains_to_renew { + tracing::info!(domain = %domain, "renewing certificate"); + if let Err(e) = self.obtain_certificate(&domain).await { + tracing::error!(domain = %domain, error = %e, "failed to renew certificate"); + } + } + } +} + +impl ResolvesServerCert for CertificateManager { + fn resolve( + &self, + client_hello: rustls::server::ClientHello<'_>, + ) -> Option> { + let sni = client_hello.server_name()?; + let cache = self.cache.lock().unwrap(); + let cached = cache.peek(sni)?; + + if cached.not_after > SystemTime::now() { + Some(Arc::clone(&cached.certified_key)) + } else { + None + } + } +} + +fn load_certified_key(cert_pem: &str, key_pem: &str) -> Result { + let certs = rustls_pemfile::certs(&mut BufReader::new(cert_pem.as_bytes())) + .collect::, _>>() + .context("failed to parse certificate PEM")?; + + let key = rustls_pemfile::private_key(&mut BufReader::new(key_pem.as_bytes())) + .context("failed to parse private key PEM")? + .context("no private key found")?; + + let signing_key = rustls::crypto::ring::sign::any_supported_type(&key) + .map_err(|e| anyhow::anyhow!("unsupported private key type: {e}"))?; + + Ok(CertifiedKey::new(certs, signing_key)) +} + +fn parse_cert_expiry(cert_pem: &str) -> Result { + let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_pem.as_bytes())) + .collect::, _>>()?; + let cert = certs.first().context("no certificate found")?; + let (_, parsed) = X509Certificate::from_der(cert.as_ref()) + .map_err(|e| anyhow::anyhow!("failed to parse x509 certificate: {e}"))?; + + let not_after = parsed.validity().not_after; + Ok(not_after.to_datetime().into()) +} + +async fn load_or_create_account( + cert_dir: &Path, + email: &str, + staging: bool, +) -> Result { + let account_json_path = cert_dir.join("account.json"); + + if account_json_path.exists() { + let json = tokio::fs::read_to_string(&account_json_path).await?; + let credentials: AccountCredentials = serde_json::from_str(&json)?; + let account = Account::builder() + .context("failed to create account builder")? + .from_credentials(credentials) + .await + .context("failed to load ACME account from credentials")?; + tracing::info!("loaded existing ACME account"); + return Ok(account); + } + + let directory_url = if staging { + instant_acme::LetsEncrypt::Staging.url() + } else { + instant_acme::LetsEncrypt::Production.url() + }; + + let (account, credentials) = Account::builder() + .context("failed to create account builder")? + .create( + &NewAccount { + contact: &[&format!("mailto:{email}")], + terms_of_service_agreed: true, + only_return_existing: false, + }, + directory_url.to_string(), + None, + ) + .await + .context("failed to create ACME account")?; + + let json = serde_json::to_string_pretty(&credentials)?; + tokio::fs::write(&account_json_path, &json).await?; + + tracing::info!("created new ACME account"); + Ok(account) +} diff --git a/crates/funnel-server/src/tls/mod.rs b/crates/funnel-server/src/tls/mod.rs index e69de29..0aad9a8 100644 --- a/crates/funnel-server/src/tls/mod.rs +++ b/crates/funnel-server/src/tls/mod.rs @@ -0,0 +1,44 @@ +pub mod config; +pub mod manager; +pub mod provider; + +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Context, Result}; + +pub async fn setup( + cert_dir: &Path, + providers_config_path: &Path, + email: &str, + acme_staging: bool, +) -> Result { + rustls::crypto::ring::default_provider() + .install_default() + .expect("failed to install default crypto provider"); + + let provider_config = config::ProviderConfig::load(providers_config_path).await?; + let provider_mux = provider::ProviderMux::from_config(&provider_config).await?; + + let cert_manager = Arc::new( + manager::CertificateManager::new( + cert_dir.to_path_buf(), + provider_mux, + email, + acme_staging, + ) + .await + .context("failed to create certificate manager")?, + ); + + cert_manager.preload_certificates().await?; + cert_manager.spawn_renewal_task(); + + let server_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(cert_manager as Arc); + + Ok(axum_server::tls_rustls::RustlsConfig::from_config( + Arc::new(server_config), + )) +} diff --git a/crates/funnel-server/src/tls/provider/cloudflare.rs b/crates/funnel-server/src/tls/provider/cloudflare.rs new file mode 100644 index 0000000..36812ed --- /dev/null +++ b/crates/funnel-server/src/tls/provider/cloudflare.rs @@ -0,0 +1,117 @@ +use anyhow::Result; + +use super::{BoxFuture, DnsChallenger}; + +const API_BASE: &str = "https://api.cloudflare.com/client/v4"; +const TXT_TTL: u32 = 120; + +pub struct CloudflareProvider { + client: reqwest::Client, + api_token: String, +} + +impl CloudflareProvider { + pub fn new(api_token: String) -> Self { + Self { + client: reqwest::Client::new(), + api_token, + } + } + + async fn find_zone_id(&self, domain: &str) -> Result { + let parts: Vec<&str> = domain.split('.').collect(); + for i in 0..parts.len().saturating_sub(1) { + let zone_name = parts[i..].join("."); + let resp: serde_json::Value = self + .client + .get(format!("{API_BASE}/zones")) + .bearer_auth(&self.api_token) + .query(&[("name", &zone_name)]) + .send() + .await? + .json() + .await?; + + let zone_id = resp["result"] + .as_array() + .and_then(|zones| zones.first()) + .and_then(|zone| zone["id"].as_str()); + + if let Some(id) = zone_id { + return Ok(id.to_string()); + } + } + anyhow::bail!("no cloudflare zone found for domain: {domain}") + } + + async fn create_txt_record(&self, zone_id: &str, name: &str, value: &str) -> Result<()> { + let resp = self + .client + .post(format!("{API_BASE}/zones/{zone_id}/dns_records")) + .bearer_auth(&self.api_token) + .json(&serde_json::json!({ + "type": "TXT", + "name": name, + "content": value, + "ttl": TXT_TTL + })) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await?; + anyhow::bail!("failed to create TXT record: {body}"); + } + Ok(()) + } + + async fn delete_txt_records(&self, zone_id: &str, name: &str, value: &str) -> Result<()> { + let resp: serde_json::Value = self + .client + .get(format!("{API_BASE}/zones/{zone_id}/dns_records")) + .bearer_auth(&self.api_token) + .query(&[("type", "TXT"), ("name", name)]) + .send() + .await? + .json() + .await?; + + let matching_records = resp["result"] + .as_array() + .into_iter() + .flatten() + .filter(|r| r["content"].as_str() == Some(value)) + .filter_map(|r| r["id"].as_str()); + + for record_id in matching_records { + self.client + .delete(format!("{API_BASE}/zones/{zone_id}/dns_records/{record_id}")) + .bearer_auth(&self.api_token) + .send() + .await?; + } + Ok(()) + } +} + +impl DnsChallenger for CloudflareProvider { + fn present(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { + let zone_id = self.find_zone_id(&record_name).await?; + self.create_txt_record(&zone_id, &record_name, &value) + .await + }) + } + + fn cleanup(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { + let zone_id = self.find_zone_id(&record_name).await?; + self.delete_txt_records(&zone_id, &record_name, &value) + .await + }) + } +} diff --git a/crates/funnel-server/src/tls/provider/exec.rs b/crates/funnel-server/src/tls/provider/exec.rs new file mode 100644 index 0000000..89c0854 --- /dev/null +++ b/crates/funnel-server/src/tls/provider/exec.rs @@ -0,0 +1,45 @@ +use anyhow::{Context, Result}; + +use super::{BoxFuture, DnsChallenger}; + +pub struct ExecProvider { + command: String, +} + +impl ExecProvider { + pub fn new(command: String) -> Self { + Self { command } + } + + async fn run(&self, action: &str, record_name: &str, value: &str) -> Result<()> { + let output = tokio::process::Command::new(&self.command) + .args([action, record_name, value]) + .output() + .await + .with_context(|| format!("failed to execute command: {}", self.command))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "command exited with {}: {}", + output.status, + stderr.trim() + ); + } + Ok(()) + } +} + +impl DnsChallenger for ExecProvider { + fn present(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { self.run("present", &record_name, &value).await }) + } + + fn cleanup(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { self.run("cleanup", &record_name, &value).await }) + } +} diff --git a/crates/funnel-server/src/tls/provider/mod.rs b/crates/funnel-server/src/tls/provider/mod.rs new file mode 100644 index 0000000..6c72893 --- /dev/null +++ b/crates/funnel-server/src/tls/provider/mod.rs @@ -0,0 +1,88 @@ +mod cloudflare; +mod exec; +mod route53; + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use anyhow::{Context, Result}; + +use self::cloudflare::CloudflareProvider; +use self::exec::ExecProvider; +use self::route53::Route53Provider; +use super::config::ProviderConfig; + +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +pub trait DnsChallenger: Send + Sync { + fn present(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>>; + fn cleanup(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>>; +} + +/// maps domains to their DNS challenge providers, walking the domain hierarchy for lookups +pub struct ProviderMux { + providers: HashMap>, +} + +impl ProviderMux { + pub fn new() -> Self { + Self { + providers: HashMap::new(), + } + } + + pub async fn from_config(config: &ProviderConfig) -> Result { + let mut mux = Self::new(); + + for entry in &config.providers { + let provider: Arc = match entry.provider_type.as_str() { + "cloudflare" => { + let api_token = entry + .config + .get("api_token") + .context("cloudflare provider requires 'api_token' in config")?; + Arc::new(CloudflareProvider::new(api_token.clone())) + } + "route53" => { + let region = entry.config.get("region").cloned(); + Arc::new(Route53Provider::new(region).await) + } + "exec" => { + let command = entry + .config + .get("command") + .context("exec provider requires 'command' in config")?; + Arc::new(ExecProvider::new(command.clone())) + } + other => anyhow::bail!("unsupported DNS provider type: {other}"), + }; + + for domain in &entry.domains { + mux.add(domain.clone(), Arc::clone(&provider)); + } + } + + Ok(mux) + } + + pub fn add(&mut self, domain: String, provider: Arc) { + self.providers.insert(domain, provider); + } + + pub fn find(&self, domain: &str) -> Option> { + let parts: Vec<&str> = domain.split('.').collect(); + for i in 0..parts.len() { + let candidate = parts[i..].join("."); + if let Some(provider) = self.providers.get(&candidate) { + return Some(Arc::clone(provider)); + } + } + None + } + + pub fn domains(&self) -> Vec { + self.providers.keys().cloned().collect() + } +} diff --git a/crates/funnel-server/src/tls/provider/route53.rs b/crates/funnel-server/src/tls/provider/route53.rs new file mode 100644 index 0000000..0122bf6 --- /dev/null +++ b/crates/funnel-server/src/tls/provider/route53.rs @@ -0,0 +1,112 @@ +use anyhow::{Context, Result}; +use aws_sdk_route53::types::{ + Change, ChangeAction, ChangeBatch, ResourceRecord, ResourceRecordSet, RrType, +}; + +use super::{BoxFuture, DnsChallenger}; + +const TXT_TTL: i64 = 60; + +pub struct Route53Provider { + client: aws_sdk_route53::Client, +} + +impl Route53Provider { + pub async fn new(region: Option) -> Self { + let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest()); + if let Some(region) = region { + loader = loader.region(aws_config::Region::new(region)); + } + let config = loader.load().await; + Self { + client: aws_sdk_route53::Client::new(&config), + } + } + + async fn find_hosted_zone(&self, domain: &str) -> Result { + let parts: Vec<&str> = domain.split('.').collect(); + for i in 0..parts.len().saturating_sub(1) { + let zone_name = parts[i..].join("."); + let resp = self + .client + .list_hosted_zones_by_name() + .dns_name(&zone_name) + .max_items(1) + .send() + .await + .context("failed to list hosted zones")?; + + if let Some(zone) = resp.hosted_zones().first() { + let normalized_name = zone.name().trim_end_matches('.'); + if normalized_name == zone_name { + let zone_id = zone.id().trim_start_matches("/hostedzone/"); + return Ok(zone_id.to_string()); + } + } + } + anyhow::bail!("no route53 hosted zone found for domain: {domain}") + } + + async fn change_record( + &self, + action: ChangeAction, + record_name: &str, + value: &str, + ) -> Result<()> { + let zone_id = self.find_hosted_zone(record_name).await?; + + let record = ResourceRecord::builder() + .value(format!("\"{value}\"")) + .build() + .context("failed to build resource record")?; + + let record_set = ResourceRecordSet::builder() + .name(record_name) + .r#type(RrType::Txt) + .ttl(TXT_TTL) + .resource_records(record) + .build() + .context("failed to build resource record set")?; + + let change = Change::builder() + .action(action) + .resource_record_set(record_set) + .build() + .context("failed to build change")?; + + let batch = ChangeBatch::builder() + .changes(change) + .build() + .context("failed to build change batch")?; + + self.client + .change_resource_record_sets() + .hosted_zone_id(&zone_id) + .change_batch(batch) + .send() + .await + .context("failed to change route53 record")?; + + Ok(()) + } +} + +impl DnsChallenger for Route53Provider { + fn present(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { + self.change_record(ChangeAction::Upsert, &record_name, &value) + .await + }) + } + + fn cleanup(&self, record_name: &str, value: &str) -> BoxFuture<'_, Result<()>> { + let record_name = record_name.to_string(); + let value = value.to_string(); + Box::pin(async move { + self.change_record(ChangeAction::Delete, &record_name, &value) + .await + }) + } +} diff --git a/flake.lock b/flake.lock index 881f35a..53d7077 100644 --- a/flake.lock +++ b/flake.lock @@ -19,9 +19,30 @@ "root": { "inputs": { "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay", "systems": "systems" } }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1777691680, + "narHash": "sha256-sdCAzrPAaKu+yo7L2pWddy5PN6U9bO++WEWc1zcr7aQ=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "4757db4358c77c1cbe878fa5990e6ea88d82f6b5", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, From d9d9481d3c07279cd83469d725f6ec31884eb4c7 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 3 May 2026 20:55:39 +0200 Subject: [PATCH 17/42] feat(protocol): replace websocket tunneling with quic --- Cargo.lock | 420 ++++++++++++++---- Cargo.toml | 1 + crates/funnel-client/Cargo.toml | 11 +- crates/funnel-client/src/forwarder.rs | 56 +-- crates/funnel-client/src/main.rs | 29 +- crates/funnel-client/src/runner.rs | 21 +- crates/funnel-client/src/tunnel.rs | 416 ++++++++--------- crates/funnel-core/Cargo.toml | 4 +- crates/funnel-core/src/error.rs | 2 +- crates/funnel-core/src/protocol/frame.rs | 110 +++++ crates/funnel-core/src/protocol/handshake.rs | 47 ++ crates/funnel-core/src/protocol/message.rs | 179 -------- crates/funnel-core/src/protocol/mod.rs | 11 +- crates/funnel-core/src/protocol/request.rs | 51 +++ crates/funnel-core/src/tunnel/id.rs | 43 +- crates/funnel-e2e/Cargo.toml | 9 + crates/funnel-e2e/src/lib.rs | 0 crates/funnel-e2e/tests/e2e/harness.rs | 207 +++++++++ crates/funnel-e2e/tests/e2e/main.rs | 136 ++++++ crates/funnel-server/Cargo.toml | 10 +- crates/funnel-server/src/app.rs | 19 +- crates/funnel-server/src/error.rs | 25 +- crates/funnel-server/src/main.rs | 112 ++++- crates/funnel-server/src/metrics.rs | 21 + crates/funnel-server/src/proxy/router.rs | 71 +-- crates/funnel-server/src/quic/handler.rs | 76 ++++ crates/funnel-server/src/quic/listener.rs | 30 ++ crates/funnel-server/src/quic/mod.rs | 2 + crates/funnel-server/src/tls/manager.rs | 22 +- crates/funnel-server/src/tls/mod.rs | 4 - crates/funnel-server/src/tunnel/connection.rs | 300 ++++++------- crates/funnel-server/src/tunnel/manager.rs | 11 - crates/funnel-server/src/tunnel/stats.rs | 1 - crates/funnel-server/src/ws/handler.rs | 68 --- crates/funnel-server/src/ws/mod.rs | 1 - scripts/e2e.sh | 4 + 36 files changed, 1619 insertions(+), 911 deletions(-) create mode 100644 crates/funnel-core/src/protocol/frame.rs create mode 100644 crates/funnel-core/src/protocol/handshake.rs delete mode 100644 crates/funnel-core/src/protocol/message.rs create mode 100644 crates/funnel-core/src/protocol/request.rs create mode 100644 crates/funnel-e2e/Cargo.toml create mode 100644 crates/funnel-e2e/src/lib.rs create mode 100644 crates/funnel-e2e/tests/e2e/harness.rs create mode 100644 crates/funnel-e2e/tests/e2e/main.rs create mode 100644 crates/funnel-server/src/metrics.rs create mode 100644 crates/funnel-server/src/quic/handler.rs create mode 100644 crates/funnel-server/src/quic/listener.rs create mode 100644 crates/funnel-server/src/quic/mod.rs delete mode 100644 crates/funnel-server/src/ws/handler.rs delete mode 100644 crates/funnel-server/src/ws/mod.rs create mode 100755 scripts/e2e.sh diff --git a/Cargo.lock b/Cargo.lock index 285271d..57b6302 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -685,7 +685,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core 0.5.6", - "base64", "bytes", "form_urlencoded", "futures-util", @@ -704,10 +703,8 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -916,6 +913,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -1168,6 +1171,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1296,9 +1308,9 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", @@ -1429,6 +1441,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "rand 0.9.4", + "siphasher", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1503,19 +1527,18 @@ dependencies = [ "clap", "dirs", "funnel-core", - "futures", "http 1.4.0", + "quinn", "reqwest 0.12.28", + "rustls 0.23.40", "serde", - "serde_json", "tokio", - "tokio-tungstenite 0.26.2", "tokio-util", "toml 0.8.23", "tracing", "tracing-subscriber", "url", - "uuid", + "webpki-roots 1.0.7", ] [[package]] @@ -1530,11 +1553,21 @@ dependencies = [ "nanoid", "pretty_assertions", "regex", + "rmp-serde", "serde", "serde_json", "sha2 0.10.9", "thiserror 2.0.18", - "uuid", + "tokio", +] + +[[package]] +name = "funnel-e2e" +version = "0.1.0" +dependencies = [ + "axum 0.8.9", + "reqwest 0.12.28", + "tokio", ] [[package]] @@ -1552,12 +1585,16 @@ dependencies = [ "clap", "dashmap", "funnel-core", - "futures", + "http-body-util", "instant-acme", "ipnetwork", "jsonwebtoken", "lru", + "metrics", + "metrics-exporter-prometheus", "pretty_assertions", + "quinn", + "rcgen 0.13.2", "reqwest 0.13.3", "rustls 0.23.40", "rustls-pemfile", @@ -1890,7 +1927,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -2073,7 +2110,7 @@ dependencies = [ "hyper-util", "rustls 0.23.40", "rustls-native-certs", - "rustls-platform-verifier", + "rustls-platform-verifier 0.7.0", "tokio", "tokio-rustls 0.26.4", "tower-service", @@ -2265,7 +2302,7 @@ dependencies = [ "hyper 1.9.0", "hyper-rustls 0.27.9", "hyper-util", - "rcgen", + "rcgen 0.14.7", "rustls 0.23.40", "rustls-pki-types", "serde", @@ -2326,6 +2363,22 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -2335,7 +2388,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.18", @@ -2356,6 +2409,15 @@ dependencies = [ "syn", ] +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + [[package]] name = "jni-sys" version = "0.4.1" @@ -2701,6 +2763,53 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "metrics" +version = "0.24.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff56c2e7dce6bd462e3b8919986a617027481b1dcc703175b58cf9dd98a2f071" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7399781913e5393588a8d8c6a2867bf85fb38eaf2502fdce465aad2dc6f034" +dependencies = [ + "base64", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "indexmap", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.15.5", + "metrics", + "quanta", + "rand 0.9.4", + "rand_xoshiro", + "sketches-ddsketch", +] + [[package]] name = "mime" version = "0.3.17" @@ -3027,6 +3136,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -3126,6 +3241,21 @@ dependencies = [ "yansi", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quinn" version = "0.11.9" @@ -3154,6 +3284,7 @@ checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "aws-lc-rs", "bytes", + "fastbloom", "getrandom 0.3.4", "lru-slab", "rand 0.9.4", @@ -3161,6 +3292,7 @@ dependencies = [ "rustc-hash", "rustls 0.23.40", "rustls-pki-types", + "rustls-platform-verifier 0.6.2", "slab", "thiserror 2.0.18", "tinyvec", @@ -3284,6 +3416,46 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "rcgen" version = "0.14.7" @@ -3420,6 +3592,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -3439,12 +3612,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls 0.26.4", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.7", ] @@ -3471,7 +3646,7 @@ dependencies = [ "quinn", "rustls 0.23.40", "rustls-pki-types", - "rustls-platform-verifier", + "rustls-platform-verifier 0.7.0", "serde", "serde_json", "serde_urlencoded", @@ -3510,6 +3685,25 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rsa" version = "0.9.10" @@ -3644,6 +3838,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.21.1", + "log", + "once_cell", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.13", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + [[package]] name = "rustls-platform-verifier" version = "0.7.0" @@ -3652,7 +3867,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls 0.23.40", @@ -3956,7 +4171,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -4022,6 +4237,18 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + [[package]] name = "slab" version = "0.4.12" @@ -4564,34 +4791,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" -dependencies = [ - "futures-util", - "log", - "rustls 0.23.40", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.4", - "tungstenite 0.26.2", - "webpki-roots 0.26.11", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.29.0", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -4805,41 +5004,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" -dependencies = [ - "bytes", - "data-encoding", - "http 1.4.0", - "httparse", - "log", - "rand 0.9.4", - "rustls 0.23.40", - "rustls-pki-types", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - -[[package]] -name = "tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" -dependencies = [ - "bytes", - "data-encoding", - "http 1.4.0", - "httparse", - "log", - "rand 0.9.4", - "sha1", - "thiserror 2.0.18", -] - [[package]] name = "typed-builder" version = "0.20.1" @@ -4941,12 +5105,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8-width" version = "0.1.8" @@ -5209,6 +5367,22 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -5218,6 +5392,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -5277,6 +5457,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -5313,6 +5502,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -5361,6 +5565,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5379,6 +5589,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5397,6 +5613,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5427,6 +5649,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5445,6 +5673,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5463,6 +5697,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5481,6 +5721,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index 7a6cf66..6a93871 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/funnel-server", "crates/funnel-client", "crates/funnel-web", + "crates/funnel-e2e", ] [workspace.package] diff --git a/crates/funnel-client/Cargo.toml b/crates/funnel-client/Cargo.toml index a87bb2c..8c5b1dd 100644 --- a/crates/funnel-client/Cargo.toml +++ b/crates/funnel-client/Cargo.toml @@ -9,16 +9,15 @@ funnel-core = { path = "../funnel-core" } anyhow = "1" clap = { version = "4", features = ["derive"] } dirs = "6" -futures = "0.3" http = "1" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +quinn = "0.11" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } serde = { version = "1", features = ["derive"] } -serde_json = "1" tokio = { version = "1", features = ["full"] } -tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } -tokio-util = "0.7" +tokio-util = { version = "0.7", features = ["io"] } toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2" -uuid = { version = "1", features = ["v7"] } +webpki-roots = "1" diff --git a/crates/funnel-client/src/forwarder.rs b/crates/funnel-client/src/forwarder.rs index d9f9f87..552008e 100644 --- a/crates/funnel-client/src/forwarder.rs +++ b/crates/funnel-client/src/forwarder.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use funnel_core::protocol::{RequestPayload, ResponsePayload}; +use funnel_core::protocol::{RequestMeta, ResponseMeta}; const HOP_BY_HOP_HEADERS: &[&str] = &[ "connection", @@ -31,23 +31,31 @@ impl Forwarder { Self { client, local_addr } } - pub async fn forward(&self, req: RequestPayload) -> ResponsePayload { - match self.try_forward(&req).await { + pub async fn forward( + &self, + meta: RequestMeta, + body: reqwest::Body, + ) -> (ResponseMeta, Vec) { + match self.try_forward(&meta, body).await { Ok(resp) => resp, Err(e) => { - tracing::error!(error = %e, method = %req.method, path = %req.path, "forwarding failed"); + tracing::error!(error = %e, method = %meta.method, path = %meta.path, "forwarding failed"); error_response(502, &format!("local service error: {e}")) } } } - async fn try_forward(&self, req: &RequestPayload) -> anyhow::Result { - let url = format!("http://{}{}", self.local_addr, req.path); + async fn try_forward( + &self, + meta: &RequestMeta, + body: reqwest::Body, + ) -> anyhow::Result<(ResponseMeta, Vec)> { + let url = format!("http://{}{}", self.local_addr, meta.path); - let method: http::Method = req.method.parse()?; + let method: http::Method = meta.method.parse()?; let mut builder = self.client.request(method, &url); - for (name, values) in &req.headers { + for (name, values) in &meta.headers { if is_hop_by_hop(name) || name.eq_ignore_ascii_case("host") { continue; } @@ -57,7 +65,7 @@ impl Forwarder { } builder = builder.header("host", &self.local_addr); - builder = builder.body(req.body.clone()); + builder = builder.body(body); let resp = builder.send().await?; @@ -65,17 +73,14 @@ impl Forwarder { let headers = collect_response_headers(resp.headers()); let body = resp.bytes().await?.to_vec(); - Ok(ResponsePayload { - status, - headers, - body, - }) + Ok((ResponseMeta { status, headers }, body)) } } fn is_hop_by_hop(name: &str) -> bool { - let lower = name.to_ascii_lowercase(); - HOP_BY_HOP_HEADERS.iter().any(|h| *h == lower) + HOP_BY_HOP_HEADERS + .iter() + .any(|h| h.eq_ignore_ascii_case(name)) } fn collect_response_headers(headers: &reqwest::header::HeaderMap) -> HashMap> { @@ -90,14 +95,13 @@ fn collect_response_headers(headers: &reqwest::header::HeaderMap) -> HashMap ResponsePayload { +fn error_response(status: u16, msg: &str) -> (ResponseMeta, Vec) { let mut headers = HashMap::new(); headers.insert("content-type".to_string(), vec!["text/plain".to_string()]); - ResponsePayload { - status, - headers, - body: msg.as_bytes().to_vec(), - } + ( + ResponseMeta { status, headers }, + msg.as_bytes().to_vec(), + ) } #[cfg(test)] @@ -115,9 +119,9 @@ mod tests { #[test] fn error_response_has_correct_status() { - let resp = error_response(502, "test error"); - assert_eq!(resp.status, 502); - assert_eq!(resp.body, b"test error"); - assert!(resp.headers.contains_key("content-type")); + let (meta, body) = error_response(502, "test error"); + assert_eq!(meta.status, 502); + assert_eq!(body, b"test error"); + assert!(meta.headers.contains_key("content-type")); } } diff --git a/crates/funnel-client/src/main.rs b/crates/funnel-client/src/main.rs index bb2953f..11ced8b 100644 --- a/crates/funnel-client/src/main.rs +++ b/crates/funnel-client/src/main.rs @@ -38,6 +38,14 @@ enum Command { /// authentication token (overrides config) #[arg(short, long)] token: Option, + + /// quic port on the server + #[arg(long, default_value_t = 4433)] + quic_port: u16, + + /// skip tls certificate verification (for development with self signed certs) + #[arg(long)] + insecure: bool, }, /// manage client configuration Config { @@ -68,6 +76,10 @@ enum ConfigAction { #[tokio::main] async fn main() -> anyhow::Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .expect("failed to install default crypto provider"); + tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), @@ -83,7 +95,9 @@ async fn main() -> anyhow::Result<()> { id, inlet, token, - } => run_http(address, server, id, inlet, token).await?, + quic_port, + insecure, + } => run_http(address, server, id, inlet, token, quic_port, insecure).await?, Command::Config { action } => run_config(action)?, } @@ -96,6 +110,8 @@ async fn run_http( id_flag: Option, inlet_name: String, token_flag: Option, + quic_port: u16, + insecure: bool, ) -> anyhow::Result<()> { let local_addr = normalize_address(&address); @@ -128,6 +144,15 @@ async fn run_http( ); } + let client = tunnel::TunnelClient::new( + tunnel_id, + server_url, + local_addr, + token, + quic_port, + insecure, + )?; + let shutdown = CancellationToken::new(); let shutdown_signal = shutdown.clone(); @@ -137,7 +162,7 @@ async fn run_http( shutdown_signal.cancel(); }); - runner::run(tunnel_id, server_url, local_addr, token, shutdown).await; + runner::run(&client, shutdown).await; Ok(()) } diff --git a/crates/funnel-client/src/runner.rs b/crates/funnel-client/src/runner.rs index a6ab15a..305849f 100644 --- a/crates/funnel-client/src/runner.rs +++ b/crates/funnel-client/src/runner.rs @@ -10,13 +10,7 @@ use crate::tunnel::TunnelClient; const INITIAL_BACKOFF: Duration = Duration::from_secs(1); const MAX_BACKOFF: Duration = Duration::from_secs(30); -pub async fn run( - tunnel_id: TunnelId, - server_url: String, - local_addr: String, - token: Option, - shutdown: CancellationToken, -) { +pub async fn run(client: &TunnelClient, shutdown: CancellationToken) { let mut attempt: u32 = 0; loop { @@ -24,13 +18,6 @@ pub async fn run( break; } - let client = TunnelClient::new( - tunnel_id.clone(), - server_url.clone(), - local_addr.clone(), - token.clone(), - ); - tracing::info!(attempt = attempt + 1, "connecting to server"); let run_cancel = shutdown.child_token(); @@ -95,21 +82,21 @@ mod tests { #[test] fn public_url_http() { - let id = TunnelId::new("my-tunnel").unwrap(); + let id = TunnelId::new("my-tunnel").expect("valid tunnel id"); let url = build_public_url("http://tunnel.example.com", &id); assert_eq!(url.as_deref(), Some("http://my-tunnel.tunnel.example.com")); } #[test] fn public_url_https() { - let id = TunnelId::new("my-tunnel").unwrap(); + let id = TunnelId::new("my-tunnel").expect("valid tunnel id"); let url = build_public_url("https://tunnel.example.com", &id); assert_eq!(url.as_deref(), Some("https://my-tunnel.tunnel.example.com")); } #[test] fn public_url_with_port() { - let id = TunnelId::new("abc").unwrap(); + let id = TunnelId::new("abc").expect("valid tunnel id"); let url = build_public_url("http://localhost:8080", &id); assert_eq!(url.as_deref(), Some("http://abc.localhost:8080")); } diff --git a/crates/funnel-client/src/tunnel.rs b/crates/funnel-client/src/tunnel.rs index b754a6e..ce1c397 100644 --- a/crates/funnel-client/src/tunnel.rs +++ b/crates/funnel-client/src/tunnel.rs @@ -1,27 +1,26 @@ +use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; -use futures::{SinkExt, StreamExt}; -use tokio::sync::mpsc; -use tokio_tungstenite::tungstenite::Message; +use tokio_util::io::ReaderStream; use tokio_util::sync::CancellationToken; use url::Url; -use funnel_core::protocol::TunnelMessage; +use funnel_core::protocol::{self, Handshake, HandshakeResponse, RequestMeta, QUIC_ALPN}; use funnel_core::tunnel::TunnelId; use crate::forwarder::Forwarder; -const CHANNEL_BUFFER: usize = 128; -const PING_INTERVAL: Duration = Duration::from_secs(30); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); const MAX_CONCURRENT_REQUESTS: usize = 128; pub struct TunnelClient { pub tunnel_id: TunnelId, - server_url: String, local_addr: String, token: Option, + quic_addr: SocketAddr, + host: String, + endpoint: quinn::Endpoint, } impl TunnelClient { @@ -30,274 +29,211 @@ impl TunnelClient { server_url: String, local_addr: String, token: Option, - ) -> Self { - Self { + quic_port: u16, + insecure: bool, + ) -> anyhow::Result { + let url = Url::parse(&server_url)?; + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("no host in server url"))? + .to_string(); + + let quic_addr = resolve_quic_addr(&host, quic_port)?; + let client_config = build_client_config(insecure)?; + + // bind address must match the remote address family + let bind_addr: SocketAddr = if quic_addr.is_ipv6() { + SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, 0)) + } else { + SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, 0)) + }; + + let mut endpoint = quinn::Endpoint::client(bind_addr)?; + endpoint.set_default_client_config(client_config); + + Ok(Self { tunnel_id, - server_url, local_addr, token, - } + quic_addr, + host, + endpoint, + }) } - /// connect to the server and run the tunnel until it disconnects. + /// connect to the server via quic and run the tunnel until it disconnects. pub async fn run(&self, cancel: CancellationToken) -> anyhow::Result<()> { - let ws = self.connect().await?; - let (ws_sink, ws_stream) = ws.split(); - - let (outgoing_tx, outgoing_rx) = mpsc::channel::(CHANNEL_BUFFER); + let conn = self.connect().await?; let forwarder = Arc::new(Forwarder::new(self.local_addr.clone())); let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_REQUESTS)); - let read_cancel = cancel.clone(); - let read_tx = outgoing_tx.clone(); - let read_forwarder = Arc::clone(&forwarder); - let read_semaphore = Arc::clone(&semaphore); - let read_tunnel_id = self.tunnel_id.clone(); - let read_handle = tokio::spawn(async move { - read_loop( - ws_stream, - read_tx, - read_forwarder, - read_semaphore, - read_tunnel_id, - read_cancel, - ) - .await; - }); - - let write_cancel = cancel.clone(); - let write_handle = tokio::spawn(async move { - write_loop(ws_sink, outgoing_rx, write_cancel).await; - }); - - let ping_cancel = cancel.clone(); - let ping_tx = outgoing_tx.clone(); - let ping_handle = tokio::spawn(async move { - heartbeat_loop(ping_tx, ping_cancel).await; - }); - - tokio::select! { - _ = read_handle => {}, - _ = write_handle => {}, - _ = ping_handle => {}, - _ = cancel.cancelled() => {}, + loop { + tokio::select! { + bi = conn.accept_bi() => { + let (send, recv) = match bi { + Ok(pair) => pair, + Err(e) => { + tracing::debug!(error = %e, "connection closed"); + break; + } + }; + + let fwd = Arc::clone(&forwarder); + let sem = Arc::clone(&semaphore); + let cancel = cancel.clone(); + + tokio::spawn(async move { + let _permit = tokio::select! { + permit = sem.acquire() => match permit { + Ok(p) => p, + Err(_) => return, + }, + _ = cancel.cancelled() => return, + }; + + if let Err(e) = handle_stream(send, recv, &fwd).await { + tracing::debug!(error = %e, "stream handler error"); + } + }); + } + _ = cancel.cancelled() => break, + } } + conn.close(quinn::VarInt::from_u32(0), b"client shutdown"); Ok(()) } - async fn connect( - &self, - ) -> anyhow::Result< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - > { - let mut url = Url::parse(&self.server_url)?; - - let ws_scheme = if url.scheme() == "https" { "wss" } else { "ws" }; - url.set_scheme(ws_scheme) - .map_err(|_| anyhow::anyhow!("failed to set websocket scheme on url: {url}"))?; - - url.set_path("/ws"); - url.query_pairs_mut() - .append_pair("id", self.tunnel_id.as_ref()); - - use tokio_tungstenite::tungstenite::client::IntoClientRequest; - - let mut request = url.as_str().into_client_request()?; - - if let Some(token) = &self.token { - request.headers_mut().insert( - "Authorization", - format!("Bearer {token}").parse()?, - ); - } + async fn connect(&self) -> anyhow::Result { + tracing::debug!(addr = %self.quic_addr, host = %self.host, "connecting via quic"); - tracing::debug!(url = %url, "connecting to server"); + let connecting = self.endpoint.connect(self.quic_addr, &self.host)?; - let (ws, _response) = - tokio::time::timeout(HANDSHAKE_TIMEOUT, tokio_tungstenite::connect_async(request)) - .await - .map_err(|_| anyhow::anyhow!("connection timed out"))??; + let conn = tokio::time::timeout(HANDSHAKE_TIMEOUT, connecting) + .await + .map_err(|_| anyhow::anyhow!("connection timed out")) + .and_then(|r| r.map_err(Into::into))?; - tracing::info!(tunnel_id = %self.tunnel_id, "websocket connected"); - Ok(ws) - } -} + let (mut send, mut recv) = conn.open_bi().await?; -async fn read_loop( - mut stream: futures::stream::SplitStream< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - >, - outgoing_tx: mpsc::Sender, - forwarder: Arc, - semaphore: Arc, - tunnel_id: TunnelId, - cancel: CancellationToken, -) { - loop { - let msg = tokio::select! { - msg = stream.next() => msg, - _ = cancel.cancelled() => break, + let handshake = Handshake { + tunnel_id: self.tunnel_id.clone(), + token: self.token.clone(), }; - let Some(result) = msg else { break }; + protocol::write_meta(&mut send, &handshake).await?; - let frame = match result { - Ok(f) => f, - Err(e) => { - tracing::debug!(error = %e, "websocket read error"); - break; - } - }; - - match frame { - Message::Text(text) => { - handle_text_message( - &text, - &outgoing_tx, - &forwarder, - &semaphore, - &tunnel_id, - &cancel, - ); - } - Message::Close(_) => break, - Message::Ping(_) => { - // tungstenite handles pong automatically - } - _ => {} - } - } + let resp: HandshakeResponse = protocol::read_meta(&mut recv).await?; + resp.into_result()?; - tracing::debug!("read loop exiting"); - cancel.cancel(); -} + tracing::info!(tunnel_id = %self.tunnel_id, "quic tunnel connected"); -fn handle_text_message( - text: &str, - outgoing_tx: &mpsc::Sender, - forwarder: &Arc, - semaphore: &Arc, - tunnel_id: &TunnelId, - cancel: &CancellationToken, -) { - let msg: TunnelMessage = match serde_json::from_str(text) { - Ok(m) => m, - Err(e) => { - tracing::warn!(error = %e, "malformed message from server"); - return; - } - }; + // keep the control stream alive in the background; dropping send + // would finish the stream, which the server interprets as disconnect + tokio::spawn(async move { + let _send = send; + let mut buf = [0u8; 1]; + let _ = recv.read(&mut buf).await; + }); - match msg { - TunnelMessage::Request { - request_id, - payload, - .. - } => { - tracing::debug!( - request_id = %request_id, - method = %payload.method, - path = %payload.path, - "received request" - ); - - let tx = outgoing_tx.clone(); - let fwd = Arc::clone(forwarder); - let sem = Arc::clone(semaphore); - let cancel = cancel.clone(); - let tid = tunnel_id.clone(); - - tokio::spawn(async move { - let _permit = tokio::select! { - permit = sem.acquire() => match permit { - Ok(p) => p, - Err(_) => return, - }, - _ = cancel.cancelled() => return, - }; - - let response = fwd.forward(payload).await; - - let msg = TunnelMessage::Response { - request_id, - payload: response, - }; - - if tx.send(msg).await.is_err() { - tracing::debug!(tunnel_id = %tid, "outgoing channel closed"); - } - }); - } - TunnelMessage::RequestCancel { request_id } => { - tracing::debug!(request_id = %request_id, "request cancelled by server"); - // cancellation of individual in flight requests would require - // tracking cancel tokens per request. for now we just log it; - // the forwarded request will complete and its response will be - // silently dropped by the server since the pending entry is gone. - } - TunnelMessage::Pong => { - tracing::debug!("pong received"); - } - other => { - tracing::debug!(msg_type = ?other, "unhandled message type"); - } + Ok(conn) } } -async fn write_loop( - mut sink: futures::stream::SplitSink< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - Message, - >, - mut rx: mpsc::Receiver, - cancel: CancellationToken, -) { - loop { - let msg = tokio::select! { - msg = rx.recv() => msg, - _ = cancel.cancelled() => break, - }; +async fn handle_stream( + mut send: quinn::SendStream, + mut recv: quinn::RecvStream, + forwarder: &Forwarder, +) -> anyhow::Result<()> { + let meta: RequestMeta = protocol::read_meta(&mut recv).await?; - let Some(msg) = msg else { break }; + tracing::debug!( + method = %meta.method, + path = %meta.path, + "received request" + ); - let json = match serde_json::to_string(&msg) { - Ok(j) => j, - Err(e) => { - tracing::error!(error = %e, "failed to serialize message"); - continue; - } - }; + // stream body from quic directly to local service (no buffer cap) + let body_stream = ReaderStream::new(recv); + let body = reqwest::Body::wrap_stream(body_stream); - if sink.send(Message::text(json)).await.is_err() { - break; - } - } + let (resp_meta, resp_body) = forwarder.forward(meta, body).await; + + protocol::write_meta(&mut send, &resp_meta).await?; + send.write_all(&resp_body).await?; + send.finish()?; - tracing::debug!("write loop exiting"); - cancel.cancel(); + Ok(()) } -async fn heartbeat_loop(tx: mpsc::Sender, cancel: CancellationToken) { - let mut interval = tokio::time::interval(PING_INTERVAL); - interval.tick().await; // skip first immediate tick +fn resolve_quic_addr(host: &str, port: u16) -> anyhow::Result { + let addr_str = format!("{host}:{port}"); + addr_str.parse().or_else(|_| { + use std::net::ToSocketAddrs; + addr_str + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow::anyhow!("failed to resolve host: {host}")) + }) +} - loop { - tokio::select! { - _ = interval.tick() => {}, - _ = cancel.cancelled() => break, - } +fn build_client_config(insecure: bool) -> anyhow::Result { + let mut crypto = if insecure { + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(SkipVerification)) + .with_no_client_auth() + } else { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth() + }; - if tx.send(TunnelMessage::Ping).await.is_err() { - break; - } + crypto.alpn_protocols = vec![QUIC_ALPN.to_vec()]; + + let quic_config = quinn::crypto::rustls::QuicClientConfig::try_from(crypto)?; + Ok(quinn::ClientConfig::new(Arc::new(quic_config))) +} + +/// certificate verifier that accepts any certificate (for dev/insecure mode) +#[derive(Debug)] +struct SkipVerification; + +impl rustls::client::danger::ServerCertVerifier for SkipVerification { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) } - tracing::debug!("heartbeat loop exiting"); + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } } diff --git a/crates/funnel-core/Cargo.toml b/crates/funnel-core/Cargo.toml index 18a0fcc..5d17dd6 100644 --- a/crates/funnel-core/Cargo.toml +++ b/crates/funnel-core/Cargo.toml @@ -11,11 +11,13 @@ hex = "0.4" http = "1" nanoid = "0.4" regex = "1" +rmp-serde = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" thiserror = "2" -uuid = { version = "1", features = ["v7", "serde"] } +tokio = { version = "1", features = ["io-util"] } [dev-dependencies] pretty_assertions = "1" +tokio = { version = "1", features = ["io-util", "rt", "macros"] } diff --git a/crates/funnel-core/src/error.rs b/crates/funnel-core/src/error.rs index 3876b98..b6b979c 100644 --- a/crates/funnel-core/src/error.rs +++ b/crates/funnel-core/src/error.rs @@ -1,7 +1,7 @@ use thiserror::Error; #[derive(Debug, Error)] -pub enum ValidationError { +pub enum TunnelIdError { #[error("tunnel ID cannot be empty")] Empty, diff --git a/crates/funnel-core/src/protocol/frame.rs b/crates/funnel-core/src/protocol/frame.rs new file mode 100644 index 0000000..092099c --- /dev/null +++ b/crates/funnel-core/src/protocol/frame.rs @@ -0,0 +1,110 @@ +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +const MAX_META_SIZE: u32 = 1_048_576; // 1mb + +#[derive(Debug, thiserror::Error)] +pub enum FrameError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("frame too large: {size} bytes (max {max})")] + TooLarge { size: u32, max: u32 }, + + #[error("encode error: {0}")] + Encode(#[from] rmp_serde::encode::Error), + + #[error("decode error: {0}")] + Decode(#[from] rmp_serde::decode::Error), +} + +/// write a length prefixed frame: [4 bytes big endian u32 length][data] +pub async fn write_frame( + writer: &mut W, + data: &[u8], +) -> Result<(), FrameError> { + let len = data.len() as u32; + writer.write_all(&len.to_be_bytes()).await?; + writer.write_all(data).await?; + Ok(()) +} + +/// read a length prefixed frame, rejecting frames larger than max_size +pub async fn read_frame( + reader: &mut R, + max_size: u32, +) -> Result, FrameError> { + let mut len_buf = [0u8; 4]; + reader.read_exact(&mut len_buf).await?; + let len = u32::from_be_bytes(len_buf); + + if len > max_size { + return Err(FrameError::TooLarge { + size: len, + max: max_size, + }); + } + + let mut buf = vec![0u8; len as usize]; + reader.read_exact(&mut buf).await?; + Ok(buf) +} + +/// serialize T as msgpack and write as a length prefixed frame +pub async fn write_meta( + writer: &mut W, + meta: &T, +) -> Result<(), FrameError> { + let data = rmp_serde::to_vec_named(meta)?; + write_frame(writer, &data).await +} + +/// read a length prefixed frame and deserialize as msgpack +pub async fn read_meta( + reader: &mut R, +) -> Result { + let data = read_frame(reader, MAX_META_SIZE).await?; + let value = rmp_serde::from_slice(&data)?; + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn frame_roundtrip() { + let data = b"hello world"; + let mut buf = Vec::new(); + write_frame(&mut buf, data).await.unwrap(); + + let mut cursor = std::io::Cursor::new(buf); + let result = read_frame(&mut cursor, 1024).await.unwrap(); + assert_eq!(result, data); + } + + #[tokio::test] + async fn frame_too_large() { + let data = vec![0u8; 100]; + let mut buf = Vec::new(); + write_frame(&mut buf, &data).await.unwrap(); + + let mut cursor = std::io::Cursor::new(buf); + let result = read_frame(&mut cursor, 50).await; + assert!(matches!(result, Err(FrameError::TooLarge { size: 100, max: 50 }))); + } + + #[tokio::test] + async fn meta_roundtrip() { + use std::collections::HashMap; + + let original: HashMap = + [("key".into(), "value".into())].into_iter().collect(); + + let mut buf = Vec::new(); + write_meta(&mut buf, &original).await.unwrap(); + + let mut cursor = std::io::Cursor::new(buf); + let decoded: HashMap = read_meta(&mut cursor).await.unwrap(); + assert_eq!(decoded, original); + } +} diff --git a/crates/funnel-core/src/protocol/handshake.rs b/crates/funnel-core/src/protocol/handshake.rs new file mode 100644 index 0000000..f9be233 --- /dev/null +++ b/crates/funnel-core/src/protocol/handshake.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +use crate::tunnel::TunnelId; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Handshake { + pub tunnel_id: TunnelId, + pub token: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HandshakeResponse { + success: bool, + error: Option, +} + +impl HandshakeResponse { + pub fn ok() -> Self { + Self { + success: true, + error: None, + } + } + + pub fn rejected(reason: impl Into) -> Self { + Self { + success: false, + error: Some(reason.into()), + } + } + + pub fn into_result(self) -> Result<(), HandshakeRejected> { + if self.success { + Ok(()) + } else { + Err(HandshakeRejected { + reason: self.error.unwrap_or_else(|| "unknown error".to_string()), + }) + } + } +} + +#[derive(Debug, thiserror::Error)] +#[error("handshake rejected: {reason}")] +pub struct HandshakeRejected { + pub reason: String, +} diff --git a/crates/funnel-core/src/protocol/message.rs b/crates/funnel-core/src/protocol/message.rs deleted file mode 100644 index a022cb7..0000000 --- a/crates/funnel-core/src/protocol/message.rs +++ /dev/null @@ -1,179 +0,0 @@ -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::tunnel::TunnelId; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RequestPayload { - pub method: String, - pub path: String, - pub headers: HashMap>, - #[serde(with = "base64_bytes")] - pub body: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResponsePayload { - pub status: u16, - pub headers: HashMap>, - #[serde(with = "base64_bytes")] - pub body: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum TunnelMessage { - Request { - tunnel_id: TunnelId, - request_id: Uuid, - payload: RequestPayload, - }, - Response { - request_id: Uuid, - payload: ResponsePayload, - }, - Ping, - Pong, - RequestCancel { - request_id: Uuid, - }, -} - -mod base64_bytes { - use base64::prelude::*; - use serde::{Deserialize, Deserializer, Serializer}; - - pub fn serialize(bytes: &Vec, s: S) -> Result { - s.serialize_str(&BASE64_STANDARD.encode(bytes)) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { - let s = String::deserialize(d)?; - BASE64_STANDARD.decode(&s).map_err(serde::de::Error::custom) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn serialize_request() { - let msg = TunnelMessage::Request { - tunnel_id: TunnelId::new("my-tunnel").unwrap(), - request_id: Uuid::nil(), - payload: RequestPayload { - method: "GET".into(), - path: "/hello".into(), - headers: HashMap::new(), - body: vec![], - }, - }; - - let json = serde_json::to_value(&msg).unwrap(); - assert_eq!(json["type"], "request"); - assert_eq!(json["tunnel_id"], "my-tunnel"); - assert_eq!(json["payload"]["method"], "GET"); - } - - #[test] - fn serialize_response() { - let msg = TunnelMessage::Response { - request_id: Uuid::nil(), - payload: ResponsePayload { - status: 200, - headers: HashMap::new(), - body: b"hello world".to_vec(), - }, - }; - - let json = serde_json::to_value(&msg).unwrap(); - assert_eq!(json["type"], "response"); - assert_eq!(json["payload"]["status"], 200); - } - - #[test] - fn serialize_ping_pong() { - let ping_json = serde_json::to_value(&TunnelMessage::Ping).unwrap(); - assert_eq!(ping_json["type"], "ping"); - - let pong_json = serde_json::to_value(&TunnelMessage::Pong).unwrap(); - assert_eq!(pong_json["type"], "pong"); - } - - #[test] - fn serialize_request_cancel() { - let id = Uuid::nil(); - let msg = TunnelMessage::RequestCancel { request_id: id }; - let json = serde_json::to_value(&msg).unwrap(); - assert_eq!(json["type"], "request_cancel"); - assert_eq!(json["request_id"], id.to_string()); - } - - #[test] - fn roundtrip_request() { - let msg = TunnelMessage::Request { - tunnel_id: TunnelId::new("test-abc").unwrap(), - request_id: Uuid::now_v7(), - payload: RequestPayload { - method: "POST".into(), - path: "/api/data".into(), - headers: { - let mut h = HashMap::new(); - h.insert("Content-Type".into(), vec!["application/json".into()]); - h - }, - body: b"{\"key\": \"value\"}".to_vec(), - }, - }; - - let json = serde_json::to_string(&msg).unwrap(); - let parsed: TunnelMessage = serde_json::from_str(&json).unwrap(); - - match parsed { - TunnelMessage::Request { - tunnel_id, - payload, - .. - } => { - assert_eq!(tunnel_id.as_ref(), "test-abc"); - assert_eq!(payload.method, "POST"); - assert_eq!(payload.body, b"{\"key\": \"value\"}"); - } - _ => panic!("expected Request variant"), - } - } - - #[test] - fn roundtrip_response_with_body() { - let body = vec![0u8, 1, 2, 255, 128]; - let msg = TunnelMessage::Response { - request_id: Uuid::now_v7(), - payload: ResponsePayload { - status: 201, - headers: HashMap::new(), - body: body.clone(), - }, - }; - - let json = serde_json::to_string(&msg).unwrap(); - let parsed: TunnelMessage = serde_json::from_str(&json).unwrap(); - - match parsed { - TunnelMessage::Response { payload, .. } => { - assert_eq!(payload.status, 201); - assert_eq!(payload.body, body); - } - _ => panic!("expected Response variant"), - } - } - - #[test] - fn deserialize_unknown_type_fails() { - let json = r#"{"type": "unknown_msg"}"#; - let result: Result = serde_json::from_str(json); - assert!(result.is_err()); - } -} diff --git a/crates/funnel-core/src/protocol/mod.rs b/crates/funnel-core/src/protocol/mod.rs index 8805705..8542261 100644 --- a/crates/funnel-core/src/protocol/mod.rs +++ b/crates/funnel-core/src/protocol/mod.rs @@ -1,3 +1,10 @@ -mod message; +pub mod frame; +pub mod handshake; +pub mod request; -pub use message::{RequestPayload, ResponsePayload, TunnelMessage}; +pub use frame::{read_frame, read_meta, write_frame, write_meta, FrameError}; +pub use handshake::{Handshake, HandshakeRejected, HandshakeResponse}; +pub use request::{RequestMeta, ResponseMeta}; + +/// application level protocol name used for QUIC ALPN negotiation +pub const QUIC_ALPN: &[u8] = b"funnel/1"; diff --git a/crates/funnel-core/src/protocol/request.rs b/crates/funnel-core/src/protocol/request.rs new file mode 100644 index 0000000..b437149 --- /dev/null +++ b/crates/funnel-core/src/protocol/request.rs @@ -0,0 +1,51 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestMeta { + pub method: String, + pub path: String, + pub headers: HashMap>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseMeta { + pub status: u16, + pub headers: HashMap>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_meta_roundtrip() { + let meta = RequestMeta { + method: "POST".into(), + path: "/api/data".into(), + headers: { + let mut h = HashMap::new(); + h.insert("content-type".into(), vec!["application/json".into()]); + h + }, + }; + + let encoded = rmp_serde::to_vec_named(&meta).unwrap(); + let decoded: RequestMeta = rmp_serde::from_slice(&encoded).unwrap(); + assert_eq!(decoded.method, "POST"); + assert_eq!(decoded.path, "/api/data"); + } + + #[test] + fn response_meta_roundtrip() { + let meta = ResponseMeta { + status: 200, + headers: HashMap::new(), + }; + + let encoded = rmp_serde::to_vec_named(&meta).unwrap(); + let decoded: ResponseMeta = rmp_serde::from_slice(&encoded).unwrap(); + assert_eq!(decoded.status, 200); + } +} diff --git a/crates/funnel-core/src/tunnel/id.rs b/crates/funnel-core/src/tunnel/id.rs index 821b9be..bc4fd10 100644 --- a/crates/funnel-core/src/tunnel/id.rs +++ b/crates/funnel-core/src/tunnel/id.rs @@ -1,9 +1,13 @@ use std::fmt; +use std::sync::LazyLock; use regex::Regex; use serde::{Deserialize, Serialize}; -use crate::error::ValidationError; +use crate::error::TunnelIdError; + +static TUNNEL_ID_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$").expect("valid regex")); const DOMAIN_SAFE_ALPHABET: &[char] = &[ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', @@ -20,7 +24,7 @@ const MAX_LENGTH: usize = 63; pub struct TunnelId(String); impl TunnelId { - pub fn new(raw: impl Into) -> Result { + pub fn new(raw: impl Into) -> Result { let s = raw.into(); Self::validate(&s)?; Ok(Self(s)) @@ -31,23 +35,22 @@ impl TunnelId { Self(id) } - fn validate(s: &str) -> Result<(), ValidationError> { + fn validate(s: &str) -> Result<(), TunnelIdError> { if s.is_empty() { - return Err(ValidationError::Empty); + return Err(TunnelIdError::Empty); } if s.len() < MIN_LENGTH { - return Err(ValidationError::TooShort(s.len())); + return Err(TunnelIdError::TooShort(s.len())); } if s.len() > MAX_LENGTH { - return Err(ValidationError::TooLong(s.len())); + return Err(TunnelIdError::TooLong(s.len())); } if s != s.to_lowercase() { - return Err(ValidationError::NotLowercase); + return Err(TunnelIdError::NotLowercase); } - let re = Regex::new(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$").expect("valid regex"); - if !re.is_match(s) { - return Err(ValidationError::InvalidFormat); + if !TUNNEL_ID_REGEX.is_match(s) { + return Err(TunnelIdError::InvalidFormat); } Ok(()) @@ -73,7 +76,7 @@ impl From for String { } impl TryFrom for TunnelId { - type Error = ValidationError; + type Error = TunnelIdError; fn try_from(s: String) -> Result { Self::new(s) @@ -95,13 +98,13 @@ mod tests { #[test] fn empty_id() { - assert!(matches!(TunnelId::new(""), Err(ValidationError::Empty))); + assert!(matches!(TunnelId::new(""), Err(TunnelIdError::Empty))); } #[test] fn too_short() { - assert!(matches!(TunnelId::new("ab"), Err(ValidationError::TooShort(2)))); - assert!(matches!(TunnelId::new("a"), Err(ValidationError::TooShort(1)))); + assert!(matches!(TunnelId::new("ab"), Err(TunnelIdError::TooShort(2)))); + assert!(matches!(TunnelId::new("a"), Err(TunnelIdError::TooShort(1)))); } #[test] @@ -109,7 +112,7 @@ mod tests { let long = "a".repeat(MAX_LENGTH + 1); assert!(matches!( TunnelId::new(long), - Err(ValidationError::TooLong(64)) + Err(TunnelIdError::TooLong(64)) )); } @@ -117,16 +120,16 @@ mod tests { fn uppercase_rejected() { assert!(matches!( TunnelId::new("MyTunnel"), - Err(ValidationError::NotLowercase) + Err(TunnelIdError::NotLowercase) )); } #[test] fn invalid_format() { - assert!(matches!(TunnelId::new("-abc"), Err(ValidationError::InvalidFormat))); - assert!(matches!(TunnelId::new("abc-"), Err(ValidationError::InvalidFormat))); - assert!(matches!(TunnelId::new("ab_c"), Err(ValidationError::InvalidFormat))); - assert!(matches!(TunnelId::new("ab c"), Err(ValidationError::InvalidFormat))); + assert!(matches!(TunnelId::new("-abc"), Err(TunnelIdError::InvalidFormat))); + assert!(matches!(TunnelId::new("abc-"), Err(TunnelIdError::InvalidFormat))); + assert!(matches!(TunnelId::new("ab_c"), Err(TunnelIdError::InvalidFormat))); + assert!(matches!(TunnelId::new("ab c"), Err(TunnelIdError::InvalidFormat))); } #[test] diff --git a/crates/funnel-e2e/Cargo.toml b/crates/funnel-e2e/Cargo.toml new file mode 100644 index 0000000..128f369 --- /dev/null +++ b/crates/funnel-e2e/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "funnel-e2e" +version = "0.1.0" +edition = "2024" + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +axum = "0.8" diff --git a/crates/funnel-e2e/src/lib.rs b/crates/funnel-e2e/src/lib.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/funnel-e2e/tests/e2e/harness.rs b/crates/funnel-e2e/tests/e2e/harness.rs new file mode 100644 index 0000000..de9ea9d --- /dev/null +++ b/crates/funnel-e2e/tests/e2e/harness.rs @@ -0,0 +1,207 @@ +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use axum::extract::DefaultBodyLimit; +use axum::routing::{get, post}; +use axum::Router; +use tokio::task::JoinHandle; + +const TUNNEL_ID: &str = "e2etest"; +const READY_TIMEOUT: Duration = Duration::from_secs(30); +const POLL_INTERVAL: Duration = Duration::from_millis(100); +const MOCK_BODY_LIMIT: usize = 4 * 1024 * 1024; + +pub struct TestEnv { + server_process: Child, + client_process: Child, + local_server_handle: JoinHandle<()>, + pub http_port: u16, + pub host_header: String, + pub client: reqwest::Client, +} + +impl TestEnv { + pub async fn start() -> Self { + let (local_handle, local_port) = start_mock_server(); + let http_port = free_port(); + let quic_port = free_port(); + let host_header = format!("{TUNNEL_ID}.localhost:{http_port}"); + + let server_process = start_server_process(http_port, quic_port); + wait_for_tcp(http_port).await; + + let client_process = start_client_process(local_port, http_port, quic_port, TUNNEL_ID); + + let client = reqwest::Client::new(); + wait_for_tunnel(&client, http_port, &host_header).await; + + Self { + server_process, + client_process, + local_server_handle: local_handle, + http_port, + host_header, + client, + } + } + + pub fn tunnel_request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { + let url = format!("http://127.0.0.1:{}{}", self.http_port, path); + self.client + .request(method, &url) + .header("host", &self.host_header) + } + + pub fn server_url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{}", self.http_port, path) + } +} + +impl Drop for TestEnv { + fn drop(&mut self) { + let _ = self.server_process.kill(); + let _ = self.client_process.kill(); + self.local_server_handle.abort(); + let _ = self.server_process.wait(); + let _ = self.client_process.wait(); + } +} + +fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn binary_path(name: &str) -> PathBuf { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let path = manifest_dir + .parent() + .unwrap() + .parent() + .unwrap() + .join("target") + .join("debug") + .join(name); + assert!( + path.exists(), + "binary not found at {}: run `cargo build -p funnel-server -p funnel-client` first", + path.display() + ); + path +} + +fn start_mock_server() -> (JoinHandle<()>, u16) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + listener.set_nonblocking(true).unwrap(); + + let handle = tokio::spawn(async move { + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + let app = mock_app(); + axum::serve(listener, app).await.unwrap(); + }); + + (handle, port) +} + +fn mock_app() -> Router { + Router::new() + .route("/hello", get(|| async { "hello from local service" })) + .route( + "/echo", + post(|body: String| async move { format!("echo: {body}") }), + ) + .route( + "/headers", + get(|headers: axum::http::HeaderMap| async move { + let map: std::collections::HashMap = headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + ( + [("x-custom-header", "test-value")], + axum::Json(map), + ) + }), + ) + .route( + "/large", + post(|body: axum::body::Bytes| async move { body }), + ) + .layer(DefaultBodyLimit::max(MOCK_BODY_LIMIT)) +} + +fn start_server_process(http_port: u16, quic_port: u16) -> Child { + Command::new(binary_path("funnel-server")) + .args([ + "--port", + &http_port.to_string(), + "--quic-port", + &quic_port.to_string(), + "--host", + "127.0.0.1", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to start funnel-server") +} + +fn start_client_process( + local_port: u16, + server_http_port: u16, + quic_port: u16, + tunnel_id: &str, +) -> Child { + Command::new(binary_path("funnel-client")) + .args([ + "http", + &format!("127.0.0.1:{local_port}"), + "--server", + &format!("http://127.0.0.1:{server_http_port}"), + "--id", + tunnel_id, + "--quic-port", + &quic_port.to_string(), + "--insecure", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to start funnel-client") +} + +async fn wait_for_tcp(port: u16) { + let deadline = tokio::time::Instant::now() + READY_TIMEOUT; + loop { + if tokio::time::Instant::now() > deadline { + panic!("tcp port {port} did not become ready"); + } + if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")) + .await + .is_ok() + { + return; + } + tokio::time::sleep(POLL_INTERVAL).await; + } +} + +async fn wait_for_tunnel(client: &reqwest::Client, http_port: u16, host_header: &str) { + let url = format!("http://127.0.0.1:{http_port}/hello"); + let deadline = tokio::time::Instant::now() + READY_TIMEOUT; + loop { + if tokio::time::Instant::now() > deadline { + panic!("tunnel did not become ready"); + } + match client.get(&url).header("host", host_header).send().await { + Ok(resp) if resp.status().is_success() => return, + _ => tokio::time::sleep(POLL_INTERVAL).await, + } + } +} diff --git a/crates/funnel-e2e/tests/e2e/main.rs b/crates/funnel-e2e/tests/e2e/main.rs new file mode 100644 index 0000000..ce6dd07 --- /dev/null +++ b/crates/funnel-e2e/tests/e2e/main.rs @@ -0,0 +1,136 @@ +mod harness; + +use harness::TestEnv; +use reqwest::Method; + +#[tokio::test(flavor = "multi_thread")] +async fn e2e() { + let env = TestEnv::start().await; + + basic_get(&env).await; + post_echo(&env).await; + large_body(&env).await; + concurrent_requests(&env).await; + response_headers(&env).await; + metrics_endpoint(&env).await; + unknown_tunnel_404(&env).await; +} + +async fn basic_get(env: &TestEnv) { + let resp = env + .tunnel_request(Method::GET, "/hello") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200, "basic_get: expected 200"); + assert_eq!( + resp.text().await.unwrap(), + "hello from local service", + "basic_get: unexpected body" + ); +} + +async fn post_echo(env: &TestEnv) { + let resp = env + .tunnel_request(Method::POST, "/echo") + .body("test body") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200, "post_echo: expected 200"); + assert_eq!( + resp.text().await.unwrap(), + "echo: test body", + "post_echo: unexpected body" + ); +} + +async fn large_body(env: &TestEnv) { + let size = 2_500_000; + let payload = vec![b'x'; size]; + let resp = env + .tunnel_request(Method::POST, "/large") + .body(payload) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200, "large_body: expected 200"); + let body = resp.bytes().await.unwrap(); + assert_eq!(body.len(), size, "large_body: response size mismatch"); +} + +async fn concurrent_requests(env: &TestEnv) { + let mut set = tokio::task::JoinSet::new(); + + for _ in 0..10 { + let client = env.client.clone(); + let url = format!("http://127.0.0.1:{}/hello", env.http_port); + let host = env.host_header.clone(); + + set.spawn(async move { + client + .get(&url) + .header("host", &host) + .send() + .await + .unwrap() + }); + } + + let mut ok_count = 0; + while let Some(result) = set.join_next().await { + let resp = result.unwrap(); + assert_eq!(resp.status(), 200, "concurrent_requests: expected 200"); + ok_count += 1; + } + assert_eq!(ok_count, 10, "concurrent_requests: expected 10 responses"); +} + +async fn response_headers(env: &TestEnv) { + let resp = env + .tunnel_request(Method::GET, "/headers") + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200, "response_headers: expected 200"); + + let custom = resp.headers().get("x-custom-header"); + assert_eq!( + custom.and_then(|v| v.to_str().ok()), + Some("test-value"), + "response_headers: missing or wrong x-custom-header" + ); +} + +async fn metrics_endpoint(env: &TestEnv) { + let resp = env + .client + .get(&env.server_url("/api/metrics")) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 200, "metrics_endpoint: expected 200"); + let body = resp.text().await.unwrap(); + assert!( + body.contains("funnel_requests_total"), + "metrics_endpoint: expected funnel_requests_total in prometheus output" + ); +} + +async fn unknown_tunnel_404(env: &TestEnv) { + let url = format!("http://127.0.0.1:{}/hello", env.http_port); + let resp = env + .client + .get(&url) + .header("host", format!("nonexistent.localhost:{}", env.http_port)) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), 404, "unknown_tunnel_404: expected 404"); +} diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index f274d84..dc19524 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -8,19 +8,23 @@ funnel-core = { path = "../funnel-core" } anyhow = "1" argon2 = "0.5" -axum = { version = "0.8", features = ["ws"] } +axum = "0.8" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive", "env"] } dashmap = "6" -futures = "0.3" +http-body-util = "0.1" jsonwebtoken = "9" +metrics = "0.24" +metrics-exporter-prometheus = "0.16" +quinn = "0.11" +rcgen = "0.13" serde = { version = "1", features = ["derive"] } serde_json = "1" ipnetwork = "0.20" sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "migrate", "ipnetwork"] } thiserror = "2" tokio = { version = "1", features = ["full"] } -tokio-util = "0.7" +tokio-util = { version = "0.7", features = ["io"] } tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace"] } tracing = "0.1" diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs index dc70298..f343b6e 100644 --- a/crates/funnel-server/src/app.rs +++ b/crates/funnel-server/src/app.rs @@ -2,15 +2,17 @@ use std::sync::Arc; use axum::Router; use axum::routing::get; +use metrics_exporter_prometheus::PrometheusHandle; use sqlx::PgPool; use tokio::time::Instant; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use crate::api; -use crate::tunnel::manager::TunnelManager; -use crate::ws; +use crate::error::AppError; +use crate::metrics; use crate::proxy; +use crate::tunnel::manager::TunnelManager; pub struct AppState { pub db: Option, @@ -29,14 +31,14 @@ impl AppState { } } - pub fn require_db(&self) -> Result<&PgPool, crate::error::AppError> { + pub fn require_db(&self) -> Result<&PgPool, AppError> { self.db .as_ref() - .ok_or_else(|| crate::error::AppError::BadRequest("database not configured".into())) + .ok_or_else(|| AppError::BadRequest("database not configured".into())) } } -pub fn build_router(state: Arc) -> Router { +pub fn build_router(state: Arc, metrics_handle: PrometheusHandle) -> Router { let api_routes = Router::new() .route("/health", get(api::health::handler)) .route("/tunnels", get(api::tunnels::list)) @@ -45,11 +47,14 @@ pub fn build_router(state: Arc) -> Router { get(api::tunnels::get_tunnel).delete(api::tunnels::delete), ) .route("/keys", get(api::keys::list).post(api::keys::create)) - .route("/keys/{id}", axum::routing::delete(api::keys::revoke)); + .route("/keys/{id}", axum::routing::delete(api::keys::revoke)) + .route( + "/metrics", + get(metrics::handler).with_state(metrics_handle), + ); Router::new() .nest("/api", api_routes) - .route("/ws", get(ws::handler::upgrade)) .fallback(proxy::router::handle_tunnel_request) .with_state(state) .layer(TraceLayer::new_for_http()) diff --git a/crates/funnel-server/src/error.rs b/crates/funnel-server/src/error.rs index 88a056f..8b37bb4 100644 --- a/crates/funnel-server/src/error.rs +++ b/crates/funnel-server/src/error.rs @@ -1,7 +1,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::Json; -use funnel_core::error::ValidationError; +use funnel_core::error::TunnelIdError; use serde::Serialize; #[derive(Debug, Serialize)] @@ -10,31 +10,18 @@ struct ApiErrorBody { } #[derive(Debug, thiserror::Error)] -#[allow(dead_code)] pub enum AppError { #[error("tunnel not found: {0}")] TunnelNotFound(String), - #[error("tunnel ID already in use: {0}")] - TunnelConflict(String), - - #[error("unauthorized: {0}")] - Unauthorized(String), - - #[error("forbidden: {0}")] - Forbidden(String), - #[error("not found: {0}")] NotFound(String), - #[error("conflict: {0}")] - Conflict(String), - #[error("bad request: {0}")] BadRequest(String), - #[error("validation error: {0}")] - Validation(#[from] ValidationError), + #[error("invalid tunnel id: {0}")] + InvalidTunnelId(#[from] TunnelIdError), #[error("database error")] Database(#[from] sqlx::Error), @@ -47,13 +34,9 @@ impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { Self::TunnelNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), - Self::TunnelConflict(_) => (StatusCode::CONFLICT, self.to_string()), - Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, self.to_string()), - Self::Forbidden(_) => (StatusCode::FORBIDDEN, self.to_string()), Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), - Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()), Self::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), - Self::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()), + Self::InvalidTunnelId(_) => (StatusCode::BAD_REQUEST, self.to_string()), Self::Database(e) => { tracing::error!(error = %e, "database error"); ( diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index 642162f..2713f80 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -3,14 +3,16 @@ mod app; mod auth; mod db; mod error; +mod metrics; mod proxy; +mod quic; mod tls; mod tunnel; -mod ws; use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; +use std::time::Duration; use axum::extract::Request; use axum::response::Redirect; @@ -19,6 +21,11 @@ use clap::Parser; use sqlx::postgres::PgPoolOptions; use tracing_subscriber::EnvFilter; +use funnel_core::protocol::QUIC_ALPN; + +const QUIC_KEEP_ALIVE: Duration = Duration::from_secs(15); +const QUIC_IDLE_TIMEOUT: Duration = Duration::from_secs(60); + #[derive(Parser)] #[command(name = "funnel-server", about = "Funnel tunnel server")] struct Cli { @@ -30,6 +37,10 @@ struct Cli { #[arg(long, default_value = "0.0.0.0")] host: String, + /// QUIC port for tunnel connections + #[arg(long, default_value_t = 4433)] + quic_port: u16, + /// PostgreSQL connection URL #[arg(long, env = "DATABASE_URL")] database_url: Option, @@ -65,6 +76,10 @@ struct Cli { #[tokio::main] async fn main() -> anyhow::Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .expect("failed to install default crypto provider"); + tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), @@ -74,7 +89,7 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - tracing::info!(host = %cli.host, port = cli.port, "starting funnel server"); + tracing::info!(host = %cli.host, port = cli.port, quic_port = cli.quic_port, "starting funnel server"); let pool = if let Some(ref database_url) = cli.database_url { let pool = PgPoolOptions::new() @@ -91,7 +106,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("database migrations applied"); Some(pool) } else { - tracing::info!("no database configured, running in memory-only mode"); + tracing::info!("no database configured, running in memory only mode"); None }; @@ -103,15 +118,24 @@ async fn main() -> anyhow::Result<()> { } async fn run_plain(cli: Cli, pool: Option) -> anyhow::Result<()> { + let metrics_handle = metrics::setup(); let state = Arc::new(app::AppState::new(pool, false)); - let router = app::build_router(state); + let router = app::build_router(Arc::clone(&state), metrics_handle); + + let quic_handle = spawn_quic_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; let addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?; let listener = tokio::net::TcpListener::bind(addr).await?; - tracing::info!(addr = %addr, "server listening"); + tracing::info!(addr = %addr, "http server listening"); - axum::serve(listener, router.into_make_service_with_connect_info::()).await?; + let http_server = + axum::serve(listener, router.into_make_service_with_connect_info::()); + + tokio::select! { + res = http_server => { res?; } + _ = quic_handle => {} + }; Ok(()) } @@ -130,8 +154,11 @@ async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<() tls::setup(Path::new(&cli.cert_dir), Path::new(config_path), email, cli.acme_staging) .await?; + let metrics_handle = metrics::setup(); let state = Arc::new(app::AppState::new(pool, true)); - let router = app::build_router(state); + let router = app::build_router(Arc::clone(&state), metrics_handle); + + let quic_handle = spawn_quic_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; let tls_addr: SocketAddr = format!("{}:{}", cli.host, cli.tls_port).parse()?; let http_addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?; @@ -148,11 +175,82 @@ async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<() tokio::select! { res = https_server => { res?; } res = http_server => { res?; } + _ = quic_handle => {} }; Ok(()) } +fn spawn_quic_listener( + host: &str, + port: u16, + state: Arc, +) -> anyhow::Result> { + let server_config = build_self_signed_quic_config()?; + let endpoint = build_quic_endpoint(server_config, host, port)?; + + Ok(tokio::spawn(async move { + if let Err(e) = quic::listener::run(endpoint, state).await { + tracing::error!(error = %e, "quic listener failed"); + } + })) +} + +fn build_self_signed_quic_config() -> anyhow::Result { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])?; + let cert_der = cert.cert.der().clone(); + let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()); + + let mut server_crypto = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![cert_der], + rustls::pki_types::PrivateKeyDer::Pkcs8(key_der), + )?; + + server_crypto.alpn_protocols = vec![QUIC_ALPN.to_vec()]; + + let mut server_config = quinn::ServerConfig::with_crypto(Arc::new( + quinn::crypto::rustls::QuicServerConfig::try_from(server_crypto)?, + )); + + let mut transport = quinn::TransportConfig::default(); + transport.keep_alive_interval(Some(QUIC_KEEP_ALIVE)); + transport.max_idle_timeout(Some( + quinn::IdleTimeout::try_from(QUIC_IDLE_TIMEOUT)?, + )); + + server_config.transport_config(Arc::new(transport)); + + Ok(server_config) +} + +/// build a quic endpoint, using a dual stack ipv6 socket when the host is a +/// wildcard address so that both ipv4 and ipv6 clients can connect. +fn build_quic_endpoint( + config: quinn::ServerConfig, + host: &str, + port: u16, +) -> anyhow::Result { + let addr: SocketAddr = format!("{host}:{port}").parse()?; + + // when binding to 0.0.0.0, also bind the ipv6 wildcard so dual stack works + if addr == SocketAddr::from(([0, 0, 0, 0], port)) { + let v6_addr: SocketAddr = format!("[::]:{port}").parse()?; + let socket = std::net::UdpSocket::bind(v6_addr)?; + let runtime = quinn::default_runtime() + .ok_or_else(|| anyhow::anyhow!("no async runtime available for quic endpoint"))?; + Ok(quinn::Endpoint::new( + quinn::EndpointConfig::default(), + Some(config), + socket, + runtime, + )?) + } else { + Ok(quinn::Endpoint::server(config, addr)?) + } +} + fn https_redirect_router(tls_port: u16) -> Router { Router::new().fallback(move |request: Request| async move { let host = request diff --git a/crates/funnel-server/src/metrics.rs b/crates/funnel-server/src/metrics.rs new file mode 100644 index 0000000..3ea125a --- /dev/null +++ b/crates/funnel-server/src/metrics.rs @@ -0,0 +1,21 @@ +use axum::http::StatusCode; +use axum::response::IntoResponse; +use metrics_exporter_prometheus::PrometheusHandle; + +pub fn setup() -> PrometheusHandle { + let builder = metrics_exporter_prometheus::PrometheusBuilder::new(); + builder + .install_recorder() + .expect("failed to install prometheus recorder") +} + +pub async fn handler( + axum::extract::State(handle): axum::extract::State, +) -> impl IntoResponse { + let body = handle.render(); + ( + StatusCode::OK, + [("content-type", "text/plain; version=0.0.4; charset=utf-8")], + body, + ) +} diff --git a/crates/funnel-server/src/proxy/router.rs b/crates/funnel-server/src/proxy/router.rs index 3fce6fc..ea1f867 100644 --- a/crates/funnel-server/src/proxy/router.rs +++ b/crates/funnel-server/src/proxy/router.rs @@ -3,15 +3,15 @@ use std::sync::Arc; use axum::body::Body; use axum::extract::State; use axum::http::{Request, Response, StatusCode}; +use tokio_util::io::ReaderStream; -use funnel_core::protocol::RequestPayload; +use funnel_core::protocol::{RequestMeta, ResponseMeta}; use funnel_core::tunnel::TunnelId; use crate::app::AppState; +use crate::tunnel::connection::{CountedRecvStream, SendError}; use super::headers::prepare_forwarding_headers; -const MAX_BODY_SIZE: usize = 10 * 1024 * 1024; // 10 mb - /// axum fallback handler that routes requests based on subdomain. /// requests to `{tunnel_id}.{base_domain}` are forwarded through the matching tunnel. pub async fn handle_tunnel_request( @@ -42,40 +42,35 @@ pub async fn handle_tunnel_request( let method = request.method().to_string(); let path = request.uri().to_string(); + let fallback_addr = std::net::SocketAddr::from(([0, 0, 0, 0], 0)); let remote_addr = request .extensions() .get::>() - .map(|ci| ci.0); - - let headers = match remote_addr { - Some(addr) => prepare_forwarding_headers(request.headers(), host, addr, state.is_tls), - None => prepare_forwarding_headers( - request.headers(), - host, - std::net::SocketAddr::from(([0, 0, 0, 0], 0)), - state.is_tls, - ), - }; + .map(|ci| ci.0) + .unwrap_or(fallback_addr); - let body_bytes = match axum::body::to_bytes(request.into_body(), MAX_BODY_SIZE).await { - Ok(b) => b.to_vec(), - Err(_) => return error_response(StatusCode::BAD_REQUEST, "request body too large"), - }; + let headers = prepare_forwarding_headers(request.headers(), host, remote_addr, state.is_tls); - let payload = RequestPayload { + let meta = RequestMeta { method, path, headers, - body: body_bytes, }; - match tunnel.send_request(payload).await { - Ok(response) => build_response(response), - Err(crate::tunnel::connection::SendError::Timeout) => { + let body = request.into_body(); + + match tunnel.send_request(meta, body).await { + Ok((resp_meta, recv_stream)) => build_response(resp_meta, recv_stream), + Err(SendError::Timeout) => { error_response(StatusCode::GATEWAY_TIMEOUT, "request timed out") } - Err(crate::tunnel::connection::SendError::TunnelClosed) => { - error_response(StatusCode::BAD_GATEWAY, "tunnel connection lost") + Err(SendError::ReadBody(e)) => { + tracing::debug!(error = %e, "failed to read request body"); + error_response(StatusCode::BAD_REQUEST, "failed to read request body") + } + Err(e) => { + tracing::debug!(error = %e, "tunnel request failed"); + error_response(StatusCode::BAD_GATEWAY, "tunnel error") } } } @@ -91,18 +86,18 @@ fn extract_subdomain(host: &str) -> Option<&str> { let subdomain = &host[..dot]; let rest = &host[dot + 1..]; - if rest.contains('.') { - Some(subdomain) - } else { + if rest.is_empty() { None + } else { + Some(subdomain) } } -fn build_response(payload: funnel_core::protocol::ResponsePayload) -> Response { - let mut builder = Response::builder().status(payload.status); +fn build_response(meta: ResponseMeta, recv: CountedRecvStream) -> Response { + let mut builder = Response::builder().status(meta.status); if let Some(headers) = builder.headers_mut() { - for (name, values) in &payload.headers { + for (name, values) in &meta.headers { for value in values { if let (Ok(name), Ok(value)) = ( axum::http::HeaderName::try_from(name.as_str()), @@ -114,8 +109,11 @@ fn build_response(payload: funnel_core::protocol::ResponsePayload) -> Response Response { .status(status) .header("content-type", "text/plain") .body(Body::from(msg.to_string())) - .unwrap() + .expect("hardcoded error response must be valid") } #[cfg(test)] @@ -146,9 +144,14 @@ mod tests { assert_eq!(extract_subdomain("my-tunnel.example.com:8080"), Some("my-tunnel")); } + #[test] + fn extract_subdomain_single_label() { + assert_eq!(extract_subdomain("abc.localhost"), Some("abc")); + assert_eq!(extract_subdomain("abc.localhost:8080"), Some("abc")); + } + #[test] fn extract_subdomain_no_subdomain() { - assert_eq!(extract_subdomain("example.com"), None); assert_eq!(extract_subdomain("localhost"), None); assert_eq!(extract_subdomain("localhost:8080"), None); } diff --git a/crates/funnel-server/src/quic/handler.rs b/crates/funnel-server/src/quic/handler.rs new file mode 100644 index 0000000..7a8572c --- /dev/null +++ b/crates/funnel-server/src/quic/handler.rs @@ -0,0 +1,76 @@ +use std::sync::Arc; + +use funnel_core::protocol::{self, Handshake, HandshakeResponse}; +use crate::app::AppState; +use crate::tunnel::connection::ActiveTunnel; + +#[derive(Debug, thiserror::Error)] +pub enum ConnectionError { + #[error("connection error: {0}")] + Connection(#[from] quinn::ConnectionError), + + #[error("frame error: {0}")] + Frame(#[from] protocol::FrameError), + + #[error("tunnel id conflict: {0}")] + Conflict(String), +} + +pub async fn handle_connection( + conn: quinn::Connection, + state: Arc, +) -> Result<(), ConnectionError> { + let (mut send, mut recv) = conn + .accept_bi() + .await + .map_err(ConnectionError::Connection)?; + + let handshake: Handshake = protocol::read_meta(&mut recv).await?; + let tunnel_id = handshake.tunnel_id; + + let tunnel = Arc::new(ActiveTunnel::new(tunnel_id.clone(), conn.clone())); + + if state + .tunnels + .insert(tunnel_id.clone(), Arc::clone(&tunnel)) + .is_err() + { + let resp = HandshakeResponse::rejected(format!("tunnel id already in use: {tunnel_id}")); + let _ = protocol::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Conflict(tunnel_id.to_string())); + } + + protocol::write_meta(&mut send, &HandshakeResponse::ok()).await?; + + tracing::info!(tunnel_id = %tunnel_id, "tunnel connected via quic"); + + metrics::gauge!("funnel_tunnels_active").increment(1.0); + metrics::counter!("funnel_tunnels_total").increment(1); + + // wait until the connection closes (control stream EOF or transport error) + let mut buf = [0u8; 1]; + tokio::select! { + _ = recv.read(&mut buf) => {}, + _ = conn.closed() => {}, + } + + let stats = tunnel.stats(); + state.tunnels.remove(&tunnel_id); + + let session_secs = tunnel.connected_at().elapsed().as_secs_f64(); + + metrics::gauge!("funnel_tunnels_active").decrement(1.0); + metrics::histogram!("funnel_tunnel_session_duration_seconds").record(session_secs); + + tracing::info!( + tunnel_id = %tunnel_id, + bytes_in = stats.bytes_in, + bytes_out = stats.bytes_out, + requests = stats.requests, + session_secs = session_secs, + remaining_tunnels = state.tunnels.count(), + "tunnel disconnected" + ); + + Ok(()) +} diff --git a/crates/funnel-server/src/quic/listener.rs b/crates/funnel-server/src/quic/listener.rs new file mode 100644 index 0000000..7caf56e --- /dev/null +++ b/crates/funnel-server/src/quic/listener.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use crate::app::AppState; + +use super::handler; + +pub async fn run(endpoint: quinn::Endpoint, state: Arc) -> anyhow::Result<()> { + tracing::info!( + addr = %endpoint.local_addr()?, + "quic endpoint listening" + ); + + while let Some(incoming) = endpoint.accept().await { + let state = Arc::clone(&state); + tokio::spawn(async move { + match incoming.await { + Ok(conn) => { + if let Err(e) = handler::handle_connection(conn, state).await { + tracing::debug!(error = %e, "connection ended"); + } + } + Err(e) => { + tracing::debug!(error = %e, "failed to accept connection"); + } + } + }); + } + + Ok(()) +} diff --git a/crates/funnel-server/src/quic/mod.rs b/crates/funnel-server/src/quic/mod.rs new file mode 100644 index 0000000..dae6833 --- /dev/null +++ b/crates/funnel-server/src/quic/mod.rs @@ -0,0 +1,2 @@ +pub mod handler; +pub mod listener; diff --git a/crates/funnel-server/src/tls/manager.rs b/crates/funnel-server/src/tls/manager.rs index 89be9c7..345fb54 100644 --- a/crates/funnel-server/src/tls/manager.rs +++ b/crates/funnel-server/src/tls/manager.rs @@ -15,7 +15,7 @@ use x509_parser::prelude::*; use super::provider::ProviderMux; -const LRU_CACHE_SIZE: usize = 512; +const LRU_CACHE_SIZE: std::num::NonZeroUsize = std::num::NonZeroUsize::new(512).unwrap(); const RENEWAL_CHECK_INTERVAL: Duration = Duration::from_secs(3600); const RENEWAL_WINDOW: Duration = Duration::from_secs(30 * 24 * 3600); const ACME_POLL_TIMEOUT: Duration = Duration::from_secs(300); @@ -54,9 +54,7 @@ impl CertificateManager { .context("failed to create certificate directory")?; let account = load_or_create_account(&cert_dir, email, staging).await?; - let cache = Mutex::new(LruCache::new( - std::num::NonZeroUsize::new(LRU_CACHE_SIZE).unwrap(), - )); + let cache = Mutex::new(LruCache::new(LRU_CACHE_SIZE)); Ok(Self { cache, @@ -72,7 +70,7 @@ impl CertificateManager { match self.load_from_disk(&domain).await { Ok(Some((certified_key, not_after))) => { tracing::info!(domain = %domain, "loaded certificate from disk"); - let mut cache = self.cache.lock().unwrap(); + let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); cache.put( domain, CachedCert { @@ -103,7 +101,7 @@ impl CertificateManager { // double check cache after acquiring lock { - let cache = self.cache.lock().unwrap(); + let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); if let Some(cached) = cache.peek(domain) { if cached.not_after > SystemTime::now() + RENEWAL_WINDOW { return Ok(()); @@ -201,7 +199,7 @@ impl CertificateManager { let not_after = parse_cert_expiry(&cert_pem)?; { - let mut cache = self.cache.lock().unwrap(); + let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); cache.put( domain.to_string(), CachedCert { @@ -257,7 +255,7 @@ impl CertificateManager { async fn check_renewals(&self) { let domains_to_renew: Vec = { - let cache = self.cache.lock().unwrap(); + let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); cache .iter() .filter(|(_, cert)| { @@ -285,7 +283,7 @@ impl ResolvesServerCert for CertificateManager { client_hello: rustls::server::ClientHello<'_>, ) -> Option> { let sni = client_hello.server_name()?; - let cache = self.cache.lock().unwrap(); + let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); let cached = cache.peek(sni)?; if cached.not_after > SystemTime::now() { @@ -306,7 +304,8 @@ fn load_certified_key(cert_pem: &str, key_pem: &str) -> Result { .context("no private key found")?; let signing_key = rustls::crypto::ring::sign::any_supported_type(&key) - .map_err(|e| anyhow::anyhow!("unsupported private key type: {e}"))?; + .map_err(|e| anyhow::anyhow!(e)) + .context("unsupported private key type")?; Ok(CertifiedKey::new(certs, signing_key)) } @@ -316,7 +315,8 @@ fn parse_cert_expiry(cert_pem: &str) -> Result { .collect::, _>>()?; let cert = certs.first().context("no certificate found")?; let (_, parsed) = X509Certificate::from_der(cert.as_ref()) - .map_err(|e| anyhow::anyhow!("failed to parse x509 certificate: {e}"))?; + .map_err(|e| anyhow::anyhow!(e.to_string())) + .context("failed to parse x509 certificate")?; let not_after = parsed.validity().not_after; Ok(not_after.to_datetime().into()) diff --git a/crates/funnel-server/src/tls/mod.rs b/crates/funnel-server/src/tls/mod.rs index 0aad9a8..154bd5f 100644 --- a/crates/funnel-server/src/tls/mod.rs +++ b/crates/funnel-server/src/tls/mod.rs @@ -13,10 +13,6 @@ pub async fn setup( email: &str, acme_staging: bool, ) -> Result { - rustls::crypto::ring::default_provider() - .install_default() - .expect("failed to install default crypto provider"); - let provider_config = config::ProviderConfig::load(providers_config_path).await?; let provider_mux = provider::ProviderMux::from_config(&provider_config).await?; diff --git a/crates/funnel-server/src/tunnel/connection.rs b/crates/funnel-server/src/tunnel/connection.rs index c9f4f15..e8fe513 100644 --- a/crates/funnel-server/src/tunnel/connection.rs +++ b/crates/funnel-server/src/tunnel/connection.rs @@ -1,31 +1,35 @@ +use std::pin::Pin; use std::sync::Arc; +use std::task::{Context, Poll}; use std::time::Duration; -use axum::extract::ws::{Message, WebSocket}; -use dashmap::DashMap; -use futures::{SinkExt, StreamExt}; -use tokio::sync::{mpsc, oneshot}; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; +use http_body_util::BodyExt; +use tokio::io::{AsyncRead, ReadBuf}; -use funnel_core::protocol::{ResponsePayload, TunnelMessage}; +use funnel_core::protocol::{self, FrameError, RequestMeta, ResponseMeta}; use funnel_core::tunnel::TunnelId; use super::stats::TunnelStats; -const CHANNEL_BUFFER: usize = 128; const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); pub struct ActiveTunnel { id: TunnelId, - outgoing_tx: mpsc::Sender, - pending_requests: DashMap>, - stats: TunnelStats, + conn: quinn::Connection, + stats: Arc, connected_at: tokio::time::Instant, - cancel: CancellationToken, } impl ActiveTunnel { + pub fn new(id: TunnelId, conn: quinn::Connection) -> Self { + Self { + id, + conn, + stats: Arc::new(TunnelStats::new()), + connected_at: tokio::time::Instant::now(), + } + } + pub fn id(&self) -> &TunnelId { &self.id } @@ -38,191 +42,158 @@ impl ActiveTunnel { self.connected_at } - #[allow(dead_code)] - pub fn is_alive(&self) -> bool { - !self.cancel.is_cancelled() - } - - /// wait until the tunnel connection has been closed. - pub async fn cancelled(&self) { - self.cancel.cancelled().await; - } - - /// send a request through the tunnel and wait for the client's response. + /// open a quic bidirectional stream, send the request, and return the + /// response metadata along with a counted recv stream for body streaming. pub async fn send_request( &self, - payload: funnel_core::protocol::RequestPayload, - ) -> Result { - let request_id = Uuid::now_v7(); - let (tx, rx) = oneshot::channel(); - - self.pending_requests.insert(request_id, tx); + meta: RequestMeta, + body: axum::body::Body, + ) -> Result<(ResponseMeta, CountedRecvStream), SendError> { self.stats.inc_requests(); - let msg = TunnelMessage::Request { - tunnel_id: self.id.clone(), - request_id, - payload, - }; + let start = std::time::Instant::now(); + + let result = tokio::time::timeout(REQUEST_TIMEOUT, async { + let (mut send, mut recv) = self + .conn + .open_bi() + .await + .map_err(SendError::OpenStream)?; + + protocol::write_meta(&mut send, &meta) + .await + .map_err(SendError::SendMeta)?; + + let mut body = body; + let mut bytes_sent: u64 = 0; + while let Some(frame) = body.frame().await { + let frame = frame.map_err(SendError::ReadBody)?; + if let Ok(data) = frame.into_data() { + bytes_sent += data.len() as u64; + send.write_all(&data) + .await + .map_err(SendError::WriteBody)?; + } + } - if self.outgoing_tx.send(msg).await.is_err() { - self.pending_requests.remove(&request_id); - return Err(SendError::TunnelClosed); - } + send.finish().map_err(SendError::FinishStream)?; - let result = tokio::time::timeout(REQUEST_TIMEOUT, rx).await; + self.stats.add_bytes_out(bytes_sent); + metrics::counter!("funnel_bytes_out_total").increment(bytes_sent); + metrics::histogram!("funnel_request_body_bytes").record(bytes_sent as f64); - // clean up regardless of outcome - self.pending_requests.remove(&request_id); + let resp_meta: ResponseMeta = protocol::read_meta(&mut recv) + .await + .map_err(SendError::ReadResponse)?; + + let counted_recv = CountedRecvStream::new(recv, Arc::clone(&self.stats)); + Ok::<_, SendError>((resp_meta, counted_recv)) + }) + .await; + + let duration = start.elapsed().as_secs_f64(); + metrics::histogram!("funnel_request_duration_seconds").record(duration); match result { - Ok(Ok(response)) => Ok(response), - Ok(Err(_)) => Err(SendError::TunnelClosed), + Ok(Ok(response)) => { + metrics::counter!("funnel_requests_total", "outcome" => "success").increment(1); + Ok(response) + } + Ok(Err(e)) => { + metrics::counter!("funnel_requests_total", "outcome" => e.outcome_label()).increment(1); + Err(e) + } Err(_) => { - // send cancel so the client can stop working on this request - let _ = self.outgoing_tx.send(TunnelMessage::RequestCancel { request_id }).await; + metrics::counter!("funnel_requests_total", "outcome" => "timeout").increment(1); Err(SendError::Timeout) } } } - /// shut down the tunnel connection. pub fn close(&self) { - self.cancel.cancel(); + self.conn + .close(quinn::VarInt::from_u32(0), b"tunnel closed"); } } #[derive(Debug, thiserror::Error)] pub enum SendError { - #[error("tunnel connection closed")] - TunnelClosed, + #[error("failed to open tunnel stream")] + OpenStream(#[source] quinn::ConnectionError), + + #[error("failed to send request metadata")] + SendMeta(#[source] FrameError), + + #[error("failed to read request body")] + ReadBody(#[source] axum::Error), + + #[error("failed to write body to tunnel")] + WriteBody(#[source] quinn::WriteError), + + #[error("failed to finish send stream")] + FinishStream(#[source] quinn::ClosedStream), + + #[error("failed to read response metadata")] + ReadResponse(#[source] FrameError), + #[error("request timed out")] Timeout, } -/// spawn the read/write tasks for a websocket connection and return the active tunnel. -/// the returned tunnel is live immediately; the background tasks run until the -/// connection drops or `close()` is called. -pub fn spawn(id: TunnelId, ws: WebSocket) -> Arc { - let (ws_sink, ws_stream) = ws.split(); - let (outgoing_tx, outgoing_rx) = mpsc::channel(CHANNEL_BUFFER); - let cancel = CancellationToken::new(); - - let tunnel = Arc::new(ActiveTunnel { - id: id.clone(), - outgoing_tx, - pending_requests: DashMap::new(), - stats: TunnelStats::new(), - connected_at: tokio::time::Instant::now(), - cancel: cancel.clone(), - }); - - let read_tunnel = Arc::clone(&tunnel); - let read_cancel = cancel.clone(); - tokio::spawn(async move { - read_loop(ws_stream, &read_tunnel, read_cancel).await; - }); - - let write_cancel = cancel.clone(); - tokio::spawn(async move { - write_loop(ws_sink, outgoing_rx, write_cancel).await; - }); - - tunnel -} - -async fn read_loop( - mut stream: futures::stream::SplitStream, - tunnel: &ActiveTunnel, - cancel: CancellationToken, -) { - loop { - let msg = tokio::select! { - msg = stream.next() => msg, - _ = cancel.cancelled() => break, - }; - - let Some(result) = msg else { break }; - - let frame = match result { - Ok(frame) => frame, - Err(e) => { - tracing::debug!(tunnel_id = %tunnel.id, error = %e, "websocket read error"); - break; - } - }; - - match frame { - Message::Text(text) => { - tunnel.stats.add_bytes_in(text.len() as u64); - handle_incoming_text(tunnel, &text); - } - Message::Close(_) => break, - _ => {} +impl SendError { + pub fn outcome_label(&self) -> &'static str { + match self { + Self::OpenStream(_) => "stream_open_failed", + Self::SendMeta(_) => "send_meta_failed", + Self::ReadBody(_) => "read_body_failed", + Self::WriteBody(_) => "write_body_failed", + Self::FinishStream(_) => "finish_stream_failed", + Self::ReadResponse(_) => "read_response_failed", + Self::Timeout => "timeout", } } - tracing::debug!(tunnel_id = %tunnel.id, "read loop exiting"); - cancel.cancel(); } -fn handle_incoming_text(tunnel: &ActiveTunnel, text: &str) { - let msg: TunnelMessage = match serde_json::from_str(text) { - Ok(m) => m, - Err(e) => { - tracing::warn!(tunnel_id = %tunnel.id, error = %e, "malformed message from client"); - return; - } - }; - - match msg { - TunnelMessage::Response { - request_id, - payload, - } => { - if let Some((_, tx)) = tunnel.pending_requests.remove(&request_id) { - let _ = tx.send(payload); - } - } - TunnelMessage::Pong => {} - other => { - tracing::debug!( - tunnel_id = %tunnel.id, - msg_type = ?other, - "unexpected message type from client" - ); +/// wraps a quinn::RecvStream and tracks bytes read for metrics and per tunnel stats. +/// on drop, records funnel_bytes_in_total and funnel_response_body_bytes. +pub struct CountedRecvStream { + inner: quinn::RecvStream, + bytes_read: u64, + stats: Arc, +} + +impl CountedRecvStream { + fn new(inner: quinn::RecvStream, stats: Arc) -> Self { + Self { + inner, + bytes_read: 0, + stats, } } } -async fn write_loop( - mut sink: futures::stream::SplitSink, - mut rx: mpsc::Receiver, - cancel: CancellationToken, -) { - loop { - let msg = tokio::select! { - msg = rx.recv() => msg, - _ = cancel.cancelled() => break, - }; - - let Some(msg) = msg else { break }; - - let json = match serde_json::to_string(&msg) { - Ok(j) => j, - Err(e) => { - tracing::error!(error = %e, "failed to serialize tunnel message"); - continue; - } - }; - - if sink.send(Message::text(json)).await.is_err() { - break; - } +impl AsyncRead for CountedRecvStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + let before = buf.filled().len(); + let result = Pin::new(&mut this.inner).poll_read(cx, buf); + let after = buf.filled().len(); + this.bytes_read += (after - before) as u64; + result } +} - tracing::debug!("write loop exiting"); - cancel.cancel(); +impl Drop for CountedRecvStream { + fn drop(&mut self) { + self.stats.add_bytes_in(self.bytes_read); + metrics::counter!("funnel_bytes_in_total").increment(self.bytes_read); + metrics::histogram!("funnel_response_body_bytes").record(self.bytes_read as f64); + } } #[cfg(test)] @@ -231,7 +202,12 @@ mod tests { #[test] fn send_error_display() { - assert_eq!(SendError::TunnelClosed.to_string(), "tunnel connection closed"); assert_eq!(SendError::Timeout.to_string(), "request timed out"); } + + #[test] + fn send_error_outcome_labels() { + assert_eq!(SendError::Timeout.outcome_label(), "timeout"); + } + } diff --git a/crates/funnel-server/src/tunnel/manager.rs b/crates/funnel-server/src/tunnel/manager.rs index 5de137e..803c4c7 100644 --- a/crates/funnel-server/src/tunnel/manager.rs +++ b/crates/funnel-server/src/tunnel/manager.rs @@ -45,10 +45,6 @@ impl TunnelManager { self.tunnels.get(id).map(|r| Arc::clone(r.value())) } - pub fn exists(&self, id: &TunnelId) -> bool { - self.tunnels.contains_key(id) - } - pub fn list(&self) -> Vec { self.tunnels .iter() @@ -79,13 +75,6 @@ mod tests { assert!(mgr.list().is_empty()); } - #[test] - fn exists_returns_false_for_missing() { - let mgr = TunnelManager::new(); - let id = TunnelId::new("test-abc").unwrap(); - assert!(!mgr.exists(&id)); - } - #[test] fn get_returns_none_for_missing() { let mgr = TunnelManager::new(); diff --git a/crates/funnel-server/src/tunnel/stats.rs b/crates/funnel-server/src/tunnel/stats.rs index b9a26a7..d90738f 100644 --- a/crates/funnel-server/src/tunnel/stats.rs +++ b/crates/funnel-server/src/tunnel/stats.rs @@ -28,7 +28,6 @@ impl TunnelStats { self.bytes_in.fetch_add(n, Ordering::Relaxed); } - #[allow(dead_code)] pub fn add_bytes_out(&self, n: u64) { self.bytes_out.fetch_add(n, Ordering::Relaxed); } diff --git a/crates/funnel-server/src/ws/handler.rs b/crates/funnel-server/src/ws/handler.rs deleted file mode 100644 index 13f2f54..0000000 --- a/crates/funnel-server/src/ws/handler.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::sync::Arc; - -use axum::extract::ws::WebSocket; -use axum::extract::{Query, State, WebSocketUpgrade}; -use axum::response::Response; -use serde::Deserialize; - -use funnel_core::tunnel::TunnelId; - -use crate::app::AppState; -use crate::error::AppError; -use crate::tunnel::connection; - -#[derive(Deserialize)] -pub struct ConnectParams { - id: Option, -} - -pub async fn upgrade( - State(state): State>, - Query(params): Query, - ws: WebSocketUpgrade, -) -> Result { - let tunnel_id = match params.id { - Some(raw) => TunnelId::new(raw)?, - None => TunnelId::generate(), - }; - - if state.tunnels.exists(&tunnel_id) { - return Err(AppError::TunnelConflict(tunnel_id.to_string())); - } - - tracing::info!(tunnel_id = %tunnel_id, "websocket upgrade accepted"); - - Ok(ws.on_upgrade(move |socket| handle_socket(state, tunnel_id, socket))) -} - -async fn handle_socket(state: Arc, id: TunnelId, socket: WebSocket) { - let tunnel = connection::spawn(id.clone(), socket); - - if let Err(_returned) = state.tunnels.insert(id.clone(), Arc::clone(&tunnel)) { - tracing::warn!(tunnel_id = %id, "tunnel id became occupied during upgrade"); - tunnel.close(); - return; - } - - tracing::info!( - tunnel_id = %id, - total_tunnels = state.tunnels.count(), - "tunnel connected" - ); - - // wait until the tunnel connection drops - tunnel.cancelled().await; - - let stats = tunnel.stats(); - state.tunnels.remove(&id); - - tracing::info!( - tunnel_id = %id, - bytes_in = stats.bytes_in, - bytes_out = stats.bytes_out, - requests = stats.requests, - uptime_secs = tunnel.connected_at().elapsed().as_secs_f64(), - remaining_tunnels = state.tunnels.count(), - "tunnel disconnected" - ); -} diff --git a/crates/funnel-server/src/ws/mod.rs b/crates/funnel-server/src/ws/mod.rs deleted file mode 100644 index 062ae9d..0000000 --- a/crates/funnel-server/src/ws/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod handler; diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..40e293e --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cargo build -p funnel-server -p funnel-client +cargo test -p funnel-e2e -- --nocapture "$@" From 0a6d27c27f29b6082808146758753494a2b5c5d7 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 3 May 2026 22:48:17 +0200 Subject: [PATCH 18/42] refactor: use workspace deps, remove unused crates, and clean up core types --- Cargo.lock | 131 +++--------------- Cargo.toml | 42 ++++++ crates/funnel-client/Cargo.toml | 26 ++-- crates/funnel-client/src/forwarder.rs | 2 +- crates/funnel-client/src/main.rs | 2 +- crates/funnel-client/src/runner.rs | 2 +- crates/funnel-client/src/tunnel.rs | 15 +- crates/funnel-core/Cargo.toml | 15 +- crates/funnel-core/src/auth/mod.rs | 4 +- crates/funnel-core/src/error.rs | 19 --- crates/funnel-core/src/lib.rs | 1 - crates/funnel-core/src/protocol/handshake.rs | 30 ++-- crates/funnel-core/src/protocol/mod.rs | 4 - crates/funnel-core/src/tunnel/id.rs | 30 +++- crates/funnel-core/src/tunnel/mod.rs | 4 +- crates/funnel-e2e/Cargo.toml | 8 +- crates/funnel-server/Cargo.toml | 47 +++---- crates/funnel-server/src/api/tunnels.rs | 2 +- crates/funnel-server/src/db/api_keys.rs | 2 +- crates/funnel-server/src/error.rs | 2 +- crates/funnel-server/src/proxy/router.rs | 4 +- crates/funnel-server/src/quic/handler.rs | 11 +- crates/funnel-server/src/tls/config.rs | 10 +- crates/funnel-server/src/tls/provider/mod.rs | 11 +- crates/funnel-server/src/tunnel/connection.rs | 9 +- crates/funnel-server/src/tunnel/manager.rs | 2 +- crates/funnel-web/Cargo.toml | 6 +- 27 files changed, 192 insertions(+), 249 deletions(-) delete mode 100644 crates/funnel-core/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index 57b6302..af833aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,18 +102,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures 0.2.17", - "password-hash", -] - [[package]] name = "asn1-rs" version = "0.7.1" @@ -834,15 +822,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest 0.10.7", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -1529,7 +1508,7 @@ dependencies = [ "funnel-core", "http 1.4.0", "quinn", - "reqwest 0.12.28", + "reqwest", "rustls 0.23.40", "serde", "tokio", @@ -1546,13 +1525,10 @@ name = "funnel-core" version = "0.1.0" dependencies = [ "base64", - "bytes", "getrandom 0.3.4", "hex", - "http 1.4.0", "nanoid", "pretty_assertions", - "regex", "rmp-serde", "serde", "serde_json", @@ -1566,7 +1542,7 @@ name = "funnel-e2e" version = "0.1.0" dependencies = [ "axum 0.8.9", - "reqwest 0.12.28", + "reqwest", "tokio", ] @@ -1575,7 +1551,6 @@ name = "funnel-server" version = "0.1.0" dependencies = [ "anyhow", - "argon2", "aws-config", "aws-sdk-route53", "axum 0.8.9", @@ -1588,14 +1563,13 @@ dependencies = [ "http-body-util", "instant-acme", "ipnetwork", - "jsonwebtoken", "lru", "metrics", "metrics-exporter-prometheus", "pretty_assertions", "quinn", "rcgen 0.13.2", - "reqwest 0.13.3", + "reqwest", "rustls 0.23.40", "rustls-pemfile", "serde", @@ -1604,7 +1578,6 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", - "tower", "tower-http", "tracing", "tracing-subscriber", @@ -2114,7 +2087,6 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.7", ] [[package]] @@ -2459,21 +2431,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "konst" version = "0.2.20" @@ -3023,17 +2980,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "paste" version = "1.0.15" @@ -3583,47 +3529,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls 0.23.40", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls 0.26.4", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots 1.0.7", -] - [[package]] name = "reqwest" version = "0.13.3" @@ -3633,6 +3538,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -3653,12 +3559,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls 0.26.4", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", ] @@ -4112,7 +4020,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "xxhash-rust", ] @@ -4225,18 +4133,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] - [[package]] name = "siphasher" version = "1.0.2" @@ -5298,6 +5194,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index 6a93871..1b60801 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,45 @@ members = [ edition = "2024" license = "MIT" repository = "https://github.com/karol-broda/funnel" + +[workspace.dependencies] +# internal +funnel-core = { path = "crates/funnel-core" } + +# serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rmp-serde = "1" + +# async +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["io"] } + +# error handling +thiserror = "2" +anyhow = "1" + +# networking +quinn = "0.11" +rustls = { version = "0.23", default-features = false } +reqwest = { version = "0.13", default-features = false } + +# http +axum = "0.8" +http = "1" +tower-http = { version = "0.6", features = ["cors", "trace"] } + +# observability +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# cli +clap = { version = "4", features = ["derive", "env"] } + +# database +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "migrate", "ipnetwork"] } +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v7", "serde"] } + +# misc +pretty_assertions = "1" diff --git a/crates/funnel-client/Cargo.toml b/crates/funnel-client/Cargo.toml index 8c5b1dd..b361f9d 100644 --- a/crates/funnel-client/Cargo.toml +++ b/crates/funnel-client/Cargo.toml @@ -1,23 +1,23 @@ [package] name = "funnel-client" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] -funnel-core = { path = "../funnel-core" } +funnel-core.workspace = true -anyhow = "1" -clap = { version = "4", features = ["derive"] } +anyhow.workspace = true +clap = { workspace = true, features = ["derive"] } dirs = "6" -http = "1" -quinn = "0.11" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } -rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } -serde = { version = "1", features = ["derive"] } -tokio = { version = "1", features = ["full"] } -tokio-util = { version = "0.7", features = ["io"] } +http.workspace = true +quinn.workspace = true +reqwest = { workspace = true, features = ["rustls", "stream"] } +rustls = { workspace = true, default-features = false, features = ["ring", "std"] } +serde.workspace = true +tokio.workspace = true +tokio-util.workspace = true toml = "0.8" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing.workspace = true +tracing-subscriber.workspace = true url = "2" webpki-roots = "1" diff --git a/crates/funnel-client/src/forwarder.rs b/crates/funnel-client/src/forwarder.rs index 552008e..0b00414 100644 --- a/crates/funnel-client/src/forwarder.rs +++ b/crates/funnel-client/src/forwarder.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use funnel_core::protocol::{RequestMeta, ResponseMeta}; +use funnel_core::protocol::request::{RequestMeta, ResponseMeta}; const HOP_BY_HOP_HEADERS: &[&str] = &[ "connection", diff --git a/crates/funnel-client/src/main.rs b/crates/funnel-client/src/main.rs index 11ced8b..5ce49da 100644 --- a/crates/funnel-client/src/main.rs +++ b/crates/funnel-client/src/main.rs @@ -7,7 +7,7 @@ use clap::{Parser, Subcommand}; use tokio_util::sync::CancellationToken; use tracing_subscriber::EnvFilter; -use funnel_core::tunnel::TunnelId; +use funnel_core::tunnel::id::TunnelId; #[derive(Parser)] #[command(name = "funnel", about = "tunnel client for exposing local services")] diff --git a/crates/funnel-client/src/runner.rs b/crates/funnel-client/src/runner.rs index 305849f..7da50a1 100644 --- a/crates/funnel-client/src/runner.rs +++ b/crates/funnel-client/src/runner.rs @@ -3,7 +3,7 @@ use std::time::Duration; use tokio_util::sync::CancellationToken; use url::Url; -use funnel_core::tunnel::TunnelId; +use funnel_core::tunnel::id::TunnelId; use crate::tunnel::TunnelClient; diff --git a/crates/funnel-client/src/tunnel.rs b/crates/funnel-client/src/tunnel.rs index ce1c397..7e4cc8e 100644 --- a/crates/funnel-client/src/tunnel.rs +++ b/crates/funnel-client/src/tunnel.rs @@ -6,8 +6,11 @@ use tokio_util::io::ReaderStream; use tokio_util::sync::CancellationToken; use url::Url; -use funnel_core::protocol::{self, Handshake, HandshakeResponse, RequestMeta, QUIC_ALPN}; -use funnel_core::tunnel::TunnelId; +use funnel_core::protocol::frame; +use funnel_core::protocol::handshake::{Handshake, HandshakeResponse}; +use funnel_core::protocol::request::RequestMeta; +use funnel_core::protocol::QUIC_ALPN; +use funnel_core::tunnel::id::TunnelId; use crate::forwarder::Forwarder; @@ -121,9 +124,9 @@ impl TunnelClient { token: self.token.clone(), }; - protocol::write_meta(&mut send, &handshake).await?; + frame::write_meta(&mut send, &handshake).await?; - let resp: HandshakeResponse = protocol::read_meta(&mut recv).await?; + let resp: HandshakeResponse = frame::read_meta(&mut recv).await?; resp.into_result()?; tracing::info!(tunnel_id = %self.tunnel_id, "quic tunnel connected"); @@ -145,7 +148,7 @@ async fn handle_stream( mut recv: quinn::RecvStream, forwarder: &Forwarder, ) -> anyhow::Result<()> { - let meta: RequestMeta = protocol::read_meta(&mut recv).await?; + let meta: RequestMeta = frame::read_meta(&mut recv).await?; tracing::debug!( method = %meta.method, @@ -159,7 +162,7 @@ async fn handle_stream( let (resp_meta, resp_body) = forwarder.forward(meta, body).await; - protocol::write_meta(&mut send, &resp_meta).await?; + frame::write_meta(&mut send, &resp_meta).await?; send.write_all(&resp_body).await?; send.finish()?; diff --git a/crates/funnel-core/Cargo.toml b/crates/funnel-core/Cargo.toml index 5d17dd6..6a3ff11 100644 --- a/crates/funnel-core/Cargo.toml +++ b/crates/funnel-core/Cargo.toml @@ -1,23 +1,20 @@ [package] name = "funnel-core" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] base64 = "0.22" -bytes = "1" getrandom = "0.3" hex = "0.4" -http = "1" nanoid = "0.4" -regex = "1" -rmp-serde = "1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" +rmp-serde.workspace = true +serde.workspace = true sha2 = "0.10" -thiserror = "2" +thiserror.workspace = true tokio = { version = "1", features = ["io-util"] } [dev-dependencies] -pretty_assertions = "1" +pretty_assertions.workspace = true +serde_json.workspace = true tokio = { version = "1", features = ["io-util", "rt", "macros"] } diff --git a/crates/funnel-core/src/auth/mod.rs b/crates/funnel-core/src/auth/mod.rs index 8798cec..79c66ba 100644 --- a/crates/funnel-core/src/auth/mod.rs +++ b/crates/funnel-core/src/auth/mod.rs @@ -1,3 +1 @@ -mod token; - -pub use token::{ApiKeyPrefix, hash_token, generate_api_key}; +pub mod token; diff --git a/crates/funnel-core/src/error.rs b/crates/funnel-core/src/error.rs deleted file mode 100644 index b6b979c..0000000 --- a/crates/funnel-core/src/error.rs +++ /dev/null @@ -1,19 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum TunnelIdError { - #[error("tunnel ID cannot be empty")] - Empty, - - #[error("tunnel ID must be at least 3 characters, got {0}")] - TooShort(usize), - - #[error("tunnel ID must be at most 63 characters, got {0}")] - TooLong(usize), - - #[error("tunnel ID must be lowercase")] - NotLowercase, - - #[error("tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen")] - InvalidFormat, -} diff --git a/crates/funnel-core/src/lib.rs b/crates/funnel-core/src/lib.rs index 3ba0702..6ae79bf 100644 --- a/crates/funnel-core/src/lib.rs +++ b/crates/funnel-core/src/lib.rs @@ -1,4 +1,3 @@ pub mod auth; -pub mod error; pub mod protocol; pub mod tunnel; diff --git a/crates/funnel-core/src/protocol/handshake.rs b/crates/funnel-core/src/protocol/handshake.rs index f9be233..f731393 100644 --- a/crates/funnel-core/src/protocol/handshake.rs +++ b/crates/funnel-core/src/protocol/handshake.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::tunnel::TunnelId; +use crate::tunnel::id::TunnelId; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Handshake { @@ -9,33 +9,29 @@ pub struct Handshake { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HandshakeResponse { - success: bool, - error: Option, +#[serde(tag = "status")] +pub enum HandshakeResponse { + #[serde(rename = "ok")] + Ok, + #[serde(rename = "rejected")] + Rejected { reason: String }, } impl HandshakeResponse { pub fn ok() -> Self { - Self { - success: true, - error: None, - } + Self::Ok } pub fn rejected(reason: impl Into) -> Self { - Self { - success: false, - error: Some(reason.into()), + Self::Rejected { + reason: reason.into(), } } pub fn into_result(self) -> Result<(), HandshakeRejected> { - if self.success { - Ok(()) - } else { - Err(HandshakeRejected { - reason: self.error.unwrap_or_else(|| "unknown error".to_string()), - }) + match self { + Self::Ok => Ok(()), + Self::Rejected { reason } => Err(HandshakeRejected { reason }), } } } diff --git a/crates/funnel-core/src/protocol/mod.rs b/crates/funnel-core/src/protocol/mod.rs index 8542261..d8218d6 100644 --- a/crates/funnel-core/src/protocol/mod.rs +++ b/crates/funnel-core/src/protocol/mod.rs @@ -2,9 +2,5 @@ pub mod frame; pub mod handshake; pub mod request; -pub use frame::{read_frame, read_meta, write_frame, write_meta, FrameError}; -pub use handshake::{Handshake, HandshakeRejected, HandshakeResponse}; -pub use request::{RequestMeta, ResponseMeta}; - /// application level protocol name used for QUIC ALPN negotiation pub const QUIC_ALPN: &[u8] = b"funnel/1"; diff --git a/crates/funnel-core/src/tunnel/id.rs b/crates/funnel-core/src/tunnel/id.rs index bc4fd10..1f488b2 100644 --- a/crates/funnel-core/src/tunnel/id.rs +++ b/crates/funnel-core/src/tunnel/id.rs @@ -1,13 +1,24 @@ use std::fmt; -use std::sync::LazyLock; -use regex::Regex; use serde::{Deserialize, Serialize}; -use crate::error::TunnelIdError; +#[derive(Debug, thiserror::Error)] +pub enum TunnelIdError { + #[error("tunnel ID cannot be empty")] + Empty, -static TUNNEL_ID_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$").expect("valid regex")); + #[error("tunnel ID must be at least 3 characters, got {0}")] + TooShort(usize), + + #[error("tunnel ID must be at most 63 characters, got {0}")] + TooLong(usize), + + #[error("tunnel ID must be lowercase")] + NotLowercase, + + #[error("tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen")] + InvalidFormat, +} const DOMAIN_SAFE_ALPHABET: &[char] = &[ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', @@ -49,7 +60,14 @@ impl TunnelId { return Err(TunnelIdError::NotLowercase); } - if !TUNNEL_ID_REGEX.is_match(s) { + let bytes = s.as_bytes(); + let is_alnum = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit(); + let is_label_char = |b: u8| is_alnum(b) || b == b'-'; + + if !is_alnum(bytes[0]) + || !is_alnum(bytes[bytes.len() - 1]) + || !bytes[1..bytes.len() - 1].iter().all(|&b| is_label_char(b)) + { return Err(TunnelIdError::InvalidFormat); } diff --git a/crates/funnel-core/src/tunnel/mod.rs b/crates/funnel-core/src/tunnel/mod.rs index 0c73029..fd6bb6c 100644 --- a/crates/funnel-core/src/tunnel/mod.rs +++ b/crates/funnel-core/src/tunnel/mod.rs @@ -1,3 +1 @@ -mod id; - -pub use id::TunnelId; +pub mod id; diff --git a/crates/funnel-e2e/Cargo.toml b/crates/funnel-e2e/Cargo.toml index 128f369..63f1002 100644 --- a/crates/funnel-e2e/Cargo.toml +++ b/crates/funnel-e2e/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "funnel-e2e" version = "0.1.0" -edition = "2024" +edition.workspace = true [dev-dependencies] -tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } -axum = "0.8" +tokio.workspace = true +reqwest = { workspace = true, features = ["rustls"] } +axum.workspace = true diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index dc19524..47a1b20 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -1,35 +1,32 @@ [package] name = "funnel-server" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] -funnel-core = { path = "../funnel-core" } +funnel-core.workspace = true -anyhow = "1" -argon2 = "0.5" -axum = "0.8" -chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4", features = ["derive", "env"] } +anyhow.workspace = true +axum.workspace = true +chrono.workspace = true +clap.workspace = true dashmap = "6" http-body-util = "0.1" -jsonwebtoken = "9" +ipnetwork = "0.20" metrics = "0.24" metrics-exporter-prometheus = "0.16" -quinn = "0.11" +quinn.workspace = true rcgen = "0.13" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -ipnetwork = "0.20" -sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "migrate", "ipnetwork"] } -thiserror = "2" -tokio = { version = "1", features = ["full"] } -tokio-util = { version = "0.7", features = ["io"] } -tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "trace"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -uuid = { version = "1", features = ["v7", "serde"] } +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber = { workspace = true, features = ["json"] } +uuid.workspace = true # tls aws-config = "1" @@ -37,12 +34,12 @@ aws-sdk-route53 = "1" axum-server = { version = "0.8", features = ["tls-rustls"] } instant-acme = { version = "0.8", features = ["rcgen"] } lru = "0.16" -reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "query"] } -rustls = "0.23" +reqwest = { workspace = true, features = ["rustls", "json", "query"] } +rustls = { workspace = true, features = ["ring", "logging", "std", "tls12"] } rustls-pemfile = "2" x509-parser = "0.18" [dev-dependencies] axum-test = "16" -pretty_assertions = "1" -sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono"] } +pretty_assertions.workspace = true +sqlx.workspace = true diff --git a/crates/funnel-server/src/api/tunnels.rs b/crates/funnel-server/src/api/tunnels.rs index 0b7dc94..f925ba5 100644 --- a/crates/funnel-server/src/api/tunnels.rs +++ b/crates/funnel-server/src/api/tunnels.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use axum::Json; use axum::extract::{Path, State}; -use funnel_core::tunnel::TunnelId; +use funnel_core::tunnel::id::TunnelId; use crate::app::AppState; use crate::error::AppError; diff --git a/crates/funnel-server/src/db/api_keys.rs b/crates/funnel-server/src/db/api_keys.rs index 320e0c1..9423e1e 100644 --- a/crates/funnel-server/src/db/api_keys.rs +++ b/crates/funnel-server/src/db/api_keys.rs @@ -3,7 +3,7 @@ use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; -use funnel_core::auth; +use funnel_core::auth::token as auth; #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct ApiKey { diff --git a/crates/funnel-server/src/error.rs b/crates/funnel-server/src/error.rs index 8b37bb4..aec36de 100644 --- a/crates/funnel-server/src/error.rs +++ b/crates/funnel-server/src/error.rs @@ -1,7 +1,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::Json; -use funnel_core::error::TunnelIdError; +use funnel_core::tunnel::id::TunnelIdError; use serde::Serialize; #[derive(Debug, Serialize)] diff --git a/crates/funnel-server/src/proxy/router.rs b/crates/funnel-server/src/proxy/router.rs index ea1f867..3d7a7cc 100644 --- a/crates/funnel-server/src/proxy/router.rs +++ b/crates/funnel-server/src/proxy/router.rs @@ -5,8 +5,8 @@ use axum::extract::State; use axum::http::{Request, Response, StatusCode}; use tokio_util::io::ReaderStream; -use funnel_core::protocol::{RequestMeta, ResponseMeta}; -use funnel_core::tunnel::TunnelId; +use funnel_core::protocol::request::{RequestMeta, ResponseMeta}; +use funnel_core::tunnel::id::TunnelId; use crate::app::AppState; use crate::tunnel::connection::{CountedRecvStream, SendError}; diff --git a/crates/funnel-server/src/quic/handler.rs b/crates/funnel-server/src/quic/handler.rs index 7a8572c..85f2c6b 100644 --- a/crates/funnel-server/src/quic/handler.rs +++ b/crates/funnel-server/src/quic/handler.rs @@ -1,6 +1,7 @@ use std::sync::Arc; -use funnel_core::protocol::{self, Handshake, HandshakeResponse}; +use funnel_core::protocol::frame; +use funnel_core::protocol::handshake::{Handshake, HandshakeResponse}; use crate::app::AppState; use crate::tunnel::connection::ActiveTunnel; @@ -10,7 +11,7 @@ pub enum ConnectionError { Connection(#[from] quinn::ConnectionError), #[error("frame error: {0}")] - Frame(#[from] protocol::FrameError), + Frame(#[from] frame::FrameError), #[error("tunnel id conflict: {0}")] Conflict(String), @@ -25,7 +26,7 @@ pub async fn handle_connection( .await .map_err(ConnectionError::Connection)?; - let handshake: Handshake = protocol::read_meta(&mut recv).await?; + let handshake: Handshake = frame::read_meta(&mut recv).await?; let tunnel_id = handshake.tunnel_id; let tunnel = Arc::new(ActiveTunnel::new(tunnel_id.clone(), conn.clone())); @@ -36,11 +37,11 @@ pub async fn handle_connection( .is_err() { let resp = HandshakeResponse::rejected(format!("tunnel id already in use: {tunnel_id}")); - let _ = protocol::write_meta(&mut send, &resp).await; + let _ = frame::write_meta(&mut send, &resp).await; return Err(ConnectionError::Conflict(tunnel_id.to_string())); } - protocol::write_meta(&mut send, &HandshakeResponse::ok()).await?; + frame::write_meta(&mut send, &HandshakeResponse::ok()).await?; tracing::info!(tunnel_id = %tunnel_id, "tunnel connected via quic"); diff --git a/crates/funnel-server/src/tls/config.rs b/crates/funnel-server/src/tls/config.rs index 0e0ac6d..5ba8614 100644 --- a/crates/funnel-server/src/tls/config.rs +++ b/crates/funnel-server/src/tls/config.rs @@ -8,12 +8,20 @@ pub struct ProviderConfig { pub providers: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProviderType { + Cloudflare, + Route53, + Exec, +} + #[derive(Debug, Deserialize)] pub struct DnsProviderEntry { #[allow(dead_code)] pub name: String, #[serde(rename = "type")] - pub provider_type: String, + pub provider_type: ProviderType, pub domains: Vec, pub config: HashMap, } diff --git a/crates/funnel-server/src/tls/provider/mod.rs b/crates/funnel-server/src/tls/provider/mod.rs index 6c72893..7271039 100644 --- a/crates/funnel-server/src/tls/provider/mod.rs +++ b/crates/funnel-server/src/tls/provider/mod.rs @@ -12,7 +12,7 @@ use anyhow::{Context, Result}; use self::cloudflare::CloudflareProvider; use self::exec::ExecProvider; use self::route53::Route53Provider; -use super::config::ProviderConfig; +use super::config::{ProviderConfig, ProviderType}; pub type BoxFuture<'a, T> = Pin + Send + 'a>>; @@ -37,26 +37,25 @@ impl ProviderMux { let mut mux = Self::new(); for entry in &config.providers { - let provider: Arc = match entry.provider_type.as_str() { - "cloudflare" => { + let provider: Arc = match entry.provider_type { + ProviderType::Cloudflare => { let api_token = entry .config .get("api_token") .context("cloudflare provider requires 'api_token' in config")?; Arc::new(CloudflareProvider::new(api_token.clone())) } - "route53" => { + ProviderType::Route53 => { let region = entry.config.get("region").cloned(); Arc::new(Route53Provider::new(region).await) } - "exec" => { + ProviderType::Exec => { let command = entry .config .get("command") .context("exec provider requires 'command' in config")?; Arc::new(ExecProvider::new(command.clone())) } - other => anyhow::bail!("unsupported DNS provider type: {other}"), }; for domain in &entry.domains { diff --git a/crates/funnel-server/src/tunnel/connection.rs b/crates/funnel-server/src/tunnel/connection.rs index e8fe513..9cecef4 100644 --- a/crates/funnel-server/src/tunnel/connection.rs +++ b/crates/funnel-server/src/tunnel/connection.rs @@ -6,8 +6,9 @@ use std::time::Duration; use http_body_util::BodyExt; use tokio::io::{AsyncRead, ReadBuf}; -use funnel_core::protocol::{self, FrameError, RequestMeta, ResponseMeta}; -use funnel_core::tunnel::TunnelId; +use funnel_core::protocol::frame::{self as frame, FrameError}; +use funnel_core::protocol::request::{RequestMeta, ResponseMeta}; +use funnel_core::tunnel::id::TunnelId; use super::stats::TunnelStats; @@ -60,7 +61,7 @@ impl ActiveTunnel { .await .map_err(SendError::OpenStream)?; - protocol::write_meta(&mut send, &meta) + frame::write_meta(&mut send, &meta) .await .map_err(SendError::SendMeta)?; @@ -82,7 +83,7 @@ impl ActiveTunnel { metrics::counter!("funnel_bytes_out_total").increment(bytes_sent); metrics::histogram!("funnel_request_body_bytes").record(bytes_sent as f64); - let resp_meta: ResponseMeta = protocol::read_meta(&mut recv) + let resp_meta: ResponseMeta = frame::read_meta(&mut recv) .await .map_err(SendError::ReadResponse)?; diff --git a/crates/funnel-server/src/tunnel/manager.rs b/crates/funnel-server/src/tunnel/manager.rs index 803c4c7..8fc05d5 100644 --- a/crates/funnel-server/src/tunnel/manager.rs +++ b/crates/funnel-server/src/tunnel/manager.rs @@ -4,7 +4,7 @@ use dashmap::DashMap; use dashmap::mapref::entry::Entry; use serde::Serialize; -use funnel_core::tunnel::TunnelId; +use funnel_core::tunnel::id::TunnelId; use super::connection::ActiveTunnel; use super::stats::TunnelStatsSnapshot; diff --git a/crates/funnel-web/Cargo.toml b/crates/funnel-web/Cargo.toml index 0945db1..3b7b773 100644 --- a/crates/funnel-web/Cargo.toml +++ b/crates/funnel-web/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "funnel-web" version = "0.1.0" -edition = "2024" +edition.workspace = true [dependencies] -funnel-core = { path = "../funnel-core" } +funnel-core.workspace = true leptos = { version = "0.7", features = ["csr"] } -serde = { version = "1", features = ["derive"] } +serde.workspace = true From b3816d816e8b370719c8c2eae6161bcba6466ec6 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 3 May 2026 22:56:51 +0200 Subject: [PATCH 19/42] chore(ci): remove github workflows --- .github/workflows/ci.yml | 252 --------------------------------- .github/workflows/quality.yml | 141 ------------------ .github/workflows/release.yml | 53 ------- .github/workflows/security.yml | 89 ------------ 4 files changed, 535 deletions(-) delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/quality.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 99074bd..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,252 +0,0 @@ -name: 🚀 CI Pipeline - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - workflow_dispatch: - -env: - GO_VERSION: '1.21' - CACHE_VERSION: v1 - -jobs: - quality: - name: 🔍 code quality - runs-on: ubuntu-latest - steps: - - name: 📥 checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: 🔧 setup go environment - uses: actions/setup-go@v5 - with: - go-version-file: go.work - cache: false - - - name: 📦 cache dependencies - uses: actions/cache@v3 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ env.CACHE_VERSION }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum', '**/go.work.sum') }} - restore-keys: | - ${{ env.CACHE_VERSION }}-${{ runner.os }}-go- - - - name: 📋 download dependencies - run: make deps - - - name: 🎨 check code formatting - run: | - echo "🔍 Checking code formatting..." - make fmt - if [ -n "$(git status --porcelain)" ]; then - echo "❌ Code is not formatted properly" - echo "📋 Here are the formatting issues:" - git diff - exit 1 - fi - echo "✅ Code formatting is correct" - - - name: 🔎 run linter - run: | - echo "🔍 Running linter checks..." - make lint - echo "✅ Linting completed successfully" - - - name: 🛡️ security scan - uses: securego/gosec@master - with: - args: '-fmt sarif -out gosec.sarif ./...' - continue-on-error: true - - - name: 📊 upload security scan results - uses: github/codeql-action/upload-sarif@v2 - if: always() - with: - sarif_file: gosec.sarif - continue-on-error: true - - test: - name: 🧪 Test Suite - runs-on: ubuntu-latest - needs: [quality] - steps: - - name: 📥 checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: 🔧 setup go environment - uses: actions/setup-go@v5 - with: - go-version-file: go.work - cache: false - - - name: 📦 cache dependencies - uses: actions/cache@v3 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ env.CACHE_VERSION }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum', '**/go.work.sum') }} - restore-keys: | - ${{ env.CACHE_VERSION }}-${{ runner.os }}-go- - - - name: 📋 download dependencies - run: make deps - - - name: 🏃 run tests - run: | - echo "🧪 Running test suite..." - make test - echo "✅ Tests completed successfully" - - - name: 📊 generate coverage report - run: | - echo "📊 Generating coverage reports for all modules..." - coverage_files="" - - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - module_name=$(basename "$dir") - echo "🔍 Processing module: $module_name (in $dir)" - - (cd "$dir" && go test -v -coverprofile="coverage-${module_name}.out" ./...) - - if [ -f "$dir/coverage-${module_name}.out" ]; then - echo "✅ Coverage generated for $module_name" - coverage_files="$coverage_files $dir/coverage-${module_name}.out" - fi - done - - echo "📋 Coverage files generated: $coverage_files" - - - name: 📈 upload coverage reports - uses: actions/upload-artifact@v4 - if: always() - with: - name: coverage-reports - path: "**/coverage-*.out" - retention-days: 30 - - build: - name: 🏗️ Build (${{ matrix.goos }}/${{ matrix.goarch }}) - runs-on: ubuntu-latest - needs: [quality, test] - strategy: - fail-fast: false - matrix: - include: - - goos: linux - goarch: amd64 - name: "🐧 Linux AMD64" - - goos: linux - goarch: arm64 - name: "🐧 Linux ARM64" - - goos: darwin - goarch: amd64 - name: "🍎 macOS Intel" - - goos: darwin - goarch: arm64 - name: "🍎 macOS Apple Silicon" - - goos: windows - goarch: amd64 - name: "🪟 Windows AMD64" - steps: - - name: 📥 checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: 🔧 setup go environment - uses: actions/setup-go@v5 - with: - go-version-file: go.work - cache: false - - - name: 📦 cache dependencies - uses: actions/cache@v3 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ env.CACHE_VERSION }}-${{ runner.os }}-go-${{ hashFiles('**/go.sum', '**/go.work.sum') }} - restore-keys: | - ${{ env.CACHE_VERSION }}-${{ runner.os }}-go- - - - name: 📋 download dependencies - run: make deps - - - name: 🔨 build binaries for ${{ matrix.name }} - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - run: | - EXT="" - if [ "$GOOS" = "windows" ]; then - EXT=".exe" - fi - - BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - GIT_COMMIT=${GITHUB_SHA::7} - GIT_TAG=${GITHUB_REF#refs/tags/} - VERSION="0.0.1a" - - echo "🏗️ Building funnel client for ${{ matrix.name }}..." - GOOS=$GOOS GOARCH=$GOARCH go build \ - -ldflags "-X github.com/karol-broda/funnel/version.Version=$VERSION \ - -X github.com/karol-broda/funnel/version.BuildDate=$BUILD_DATE \ - -X github.com/karol-broda/funnel/version.GitCommit=$GIT_COMMIT \ - -X github.com/karol-broda/funnel/version.GitTag=$GIT_TAG \ - -s -w" \ - -o funnel-$GOOS-$GOARCH$EXT \ - ./cmd/funnel - - echo "🏗️ Building funnel server for ${{ matrix.name }}..." - GOOS=$GOOS GOARCH=$GOARCH go build \ - -ldflags "-X github.com/karol-broda/funnel/version.Version=$VERSION \ - -X github.com/karol-broda/funnel/version.BuildDate=$BUILD_DATE \ - -X github.com/karol-broda/funnel/version.GitCommit=$GIT_COMMIT \ - -X github.com/karol-broda/funnel/version.GitTag=$GIT_TAG \ - -s -w" \ - -o funnel-server-$GOOS-$GOARCH$EXT \ - ./cmd/server - - echo "✅ Build completed successfully for ${{ matrix.name }}" - - if [ -f "funnel-$GOOS-$GOARCH$EXT" ] && [ -f "funnel-server-$GOOS-$GOARCH$EXT" ]; then - echo "📦 Binary sizes:" - ls -lh funnel*-$GOOS-$GOARCH$EXT - else - echo "❌ Build verification failed" - exit 1 - fi - - rm -f funnel-$GOOS-$GOARCH$EXT funnel-server-$GOOS-$GOARCH$EXT - - build-summary: - name: 📋 Build Summary - runs-on: ubuntu-latest - needs: [build] - if: always() - steps: - - name: 📊 build results summary - run: | - echo "## 🏗️ Build Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Platform | Status |" >> $GITHUB_STEP_SUMMARY - echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY - echo "| 🐧 Linux AMD64 | ${{ needs.build.result == 'success' && '✅ Success' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| 🐧 Linux ARM64 | ${{ needs.build.result == 'success' && '✅ Success' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| 🍎 macOS Intel | ${{ needs.build.result == 'success' && '✅ Success' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| 🍎 macOS Apple Silicon | ${{ needs.build.result == 'success' && '✅ Success' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "| 🪟 Windows AMD64 | ${{ needs.build.result == 'success' && '✅ Success' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### 📊 Pipeline Results" >> $GITHUB_STEP_SUMMARY - echo "- 🔍 Code Quality: ${{ needs.quality.result == 'success' && '✅ Passed' || '❌ Failed' }}" >> $GITHUB_STEP_SUMMARY - echo "- 🧪 Tests: ${{ needs.test.result == 'success' && '✅ Passed' || '❌ Failed' }}" >> $GITHUB_STEP_SUMMARY - echo "- 🏗️ Builds: ${{ needs.build.result == 'success' && '✅ All Successful' || '❌ Some Failed' }}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml deleted file mode 100644 index b08541c..0000000 --- a/.github/workflows/quality.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: Code Quality - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - workflow_dispatch: - -env: - GO_VERSION: '1.21' - -jobs: - benchmarks: - name: Benchmarks - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.work - - - name: Cache Go modules - uses: actions/cache@v3 - with: - path: | - ~/.cache/go-build - ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum', '**/go.work.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - - name: Download dependencies - run: make deps - - - name: Run benchmarks - run: | - echo "running benchmarks across all modules..." - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "running benchmarks for module in $dir..." - (cd $dir && go test -bench=. -benchmem -run=^$ ./...) || echo "no benchmarks found in $dir" - done - - code-metrics: - name: Code Metrics - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.work - - - name: Download dependencies - run: make deps - - - name: Install code analysis tools - run: | - go install github.com/fzipp/gocyclo/cmd/gocyclo@latest - go install github.com/gordonklaus/ineffassign@latest - go install github.com/client9/misspell/cmd/misspell@latest - go install honnef.co/go/tools/cmd/staticcheck@latest - - - name: Generate code metrics - run: | - echo "generating code metrics..." - - echo "## Code Complexity Analysis" - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "### Module: $(basename $dir)" - (cd $dir && gocyclo -top 10 .) 2>/dev/null || echo "no complexity issues found" - done - - - name: Check for inefficient assignments - run: | - echo "## Inefficient Assignments" - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "### Module: $(basename $dir)" - (cd $dir && ineffassign ./...) 2>/dev/null || echo "no inefficient assignments found" - done - - - name: Check for misspellings - run: | - echo "## Spelling Check" - misspell -error . 2>&1 || echo "no spelling errors found" - - - name: Run static analysis - run: | - echo "## Static Analysis (staticcheck)" - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "### Module: $(basename $dir)" - (cd $dir && staticcheck ./...) 2>/dev/null || echo "no static analysis issues found" - done - - - name: Generate summary - run: | - echo "## 📊 Code Quality Summary" - echo "Generated on: $(date)" - echo "### 📁 Module Count" - MODULE_COUNT=$(find . -name "go.mod" -not -path "./go.work*" | wc -l) - echo "Total modules: $MODULE_COUNT" - echo "### 📋 File Statistics" - GO_FILES=$(find . -name "*.go" -not -path "./vendor/*" | wc -l) - echo "Go files: $GO_FILES" - TOTAL_LINES=$(find . -name "*.go" -not -path "./vendor/*" -exec wc -l {} + | tail -1 | awk '{print $1}') - echo "Total lines of Go code: $TOTAL_LINES" - - dependency-graph: - name: Dependency Analysis - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.work - - - name: Download dependencies - run: make deps - - - name: Analyze dependencies - run: | - echo "analyzing dependencies..." - - echo "## 📦 Dependency Analysis" - echo "Generated on: $(date)" - - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "### Module: $(basename $dir)" - echo "#### Direct Dependencies" - (cd $dir && go list -m all | head -20) - echo "#### Dependency Graph" - (cd $dir && go mod graph | head -30) - done \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 308837e..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: GoReleaser - -on: - push: - tags: - - "v*.*.*" - workflow_dispatch: - inputs: - tag: - description: 'The tag to release (e.g. v1.2.3)' - required: true - -permissions: - contents: write - packages: write - -jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.event.inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: 'go.work' - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v5 - with: - version: ~> v2 - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - \ No newline at end of file diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml deleted file mode 100644 index cc4f035..0000000 --- a/.github/workflows/security.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Security - -on: - schedule: - # run every monday at 9 AM UTC - - cron: '0 9 * * 1' - workflow_dispatch: - -env: - GO_VERSION: '1.21' - -jobs: - security-audit: - name: Security Audit - runs-on: ubuntu-latest - permissions: - security-events: write - contents: read - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.work - - - name: Download dependencies - run: make deps - - - name: Run Go vulnerability check - run: | - echo "checking for known vulnerabilities..." - go install golang.org/x/vuln/cmd/govulncheck@latest - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "scanning module in $dir..." - (cd $dir && govulncheck ./...) - done - - - name: Run Nancy security scan - run: | - echo "running nancy security scan..." - go install github.com/sonatypecommunity/nancy@latest - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "nancy scan for module in $dir..." - (cd $dir && go list -json -deps ./... | nancy sleuth) - done - - - name: Run Gosec security scan - run: | - echo "running gosec security scan..." - go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest - for dir in $(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do - echo "gosec scan for module in $dir..." - (cd $dir && gosec ./...) - done - - dependency-review: - name: Dependency Review - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Dependency Review - uses: actions/dependency-review-action@v3 - - generate-sbom: - name: Generate SBOM - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.work - - - name: Download dependencies - run: make deps - - - name: Generate SBOM - run: | - echo "generating software bill of materials..." - go install github.com/anchore/syft/cmd/syft@latest - syft . -o table - echo "sbom generation completed" \ No newline at end of file From be54028e5977c13223a553afa86fa72a49306baf Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Mon, 4 May 2026 00:17:26 +0200 Subject: [PATCH 20/42] refactor: enable strict clippy lints and replace unwrap/expect with proper error propagation --- Cargo.toml | 26 ++ Makefile | 256 ------------------ crates/funnel-client/Cargo.toml | 3 + crates/funnel-client/src/config.rs | 14 +- crates/funnel-client/src/forwarder.rs | 20 +- crates/funnel-client/src/main.rs | 20 +- crates/funnel-client/src/runner.rs | 27 +- crates/funnel-client/src/tunnel.rs | 14 +- crates/funnel-core/Cargo.toml | 3 + crates/funnel-core/src/auth/token.rs | 34 ++- crates/funnel-core/src/protocol/frame.rs | 74 ++--- crates/funnel-core/src/protocol/handshake.rs | 2 +- crates/funnel-core/src/protocol/request.rs | 16 +- crates/funnel-core/src/tunnel/id.rs | 54 +++- crates/funnel-e2e/Cargo.toml | 3 + crates/funnel-e2e/src/lib.rs | 1 + crates/funnel-e2e/tests/e2e/harness.rs | 153 +++++++---- crates/funnel-e2e/tests/e2e/main.rs | 139 ++++------ crates/funnel-server/Cargo.toml | 3 + crates/funnel-server/src/app.rs | 5 +- crates/funnel-server/src/auth/mod.rs | 1 + crates/funnel-server/src/db/api_keys.rs | 92 +++---- .../funnel-server/src/db/tunnel_sessions.rs | 55 ++-- crates/funnel-server/src/db/users.rs | 60 ++-- crates/funnel-server/src/error.rs | 12 +- crates/funnel-server/src/main.rs | 37 +-- crates/funnel-server/src/metrics.rs | 6 +- crates/funnel-server/src/proxy/headers.rs | 5 +- crates/funnel-server/src/proxy/router.rs | 35 +-- crates/funnel-server/src/quic/handler.rs | 4 +- crates/funnel-server/src/tls/config.rs | 2 +- crates/funnel-server/src/tls/manager.rs | 65 +++-- crates/funnel-server/src/tls/mod.rs | 11 +- .../src/tls/provider/cloudflare.rs | 7 +- crates/funnel-server/src/tls/provider/exec.rs | 8 +- crates/funnel-server/src/tunnel/connection.rs | 23 +- crates/funnel-server/src/tunnel/manager.rs | 12 +- crates/funnel-server/src/tunnel/stats.rs | 2 +- crates/funnel-web/Cargo.toml | 3 + justfile | 49 ++++ 40 files changed, 639 insertions(+), 717 deletions(-) delete mode 100644 Makefile create mode 100644 justfile diff --git a/Cargo.toml b/Cargo.toml index 1b60801..76dd736 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,3 +54,29 @@ uuid = { version = "1", features = ["v7", "serde"] } # misc pretty_assertions = "1" + +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } + +module_name_repetitions = "allow" +must_use_candidate = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +doc_markdown = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +too_many_lines = "allow" +cast_possible_truncation = "allow" +cast_precision_loss = "allow" +cast_sign_loss = "allow" +cast_lossless = "allow" +items_after_statements = "allow" +future_not_send = "allow" +option_if_let_else = "allow" + +unwrap_used = "deny" +expect_used = "deny" + +[workspace.lints.rust] +unsafe_code = "deny" diff --git a/Makefile b/Makefile deleted file mode 100644 index 51ea342..0000000 --- a/Makefile +++ /dev/null @@ -1,256 +0,0 @@ -PROJECT_NAME := funnel -VERSION := 0.0.1a -BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") -GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") -GIT_TAG := $(shell git describe --tags --exact-match 2>/dev/null || echo "unknown") - -LDFLAGS := -ldflags "\ - -X github.com/karol-broda/funnel/version.Version=$(VERSION) \ - -X github.com/karol-broda/funnel/version.BuildDate=$(BUILD_DATE) \ - -X github.com/karol-broda/funnel/version.GitCommit=$(GIT_COMMIT) \ - -X github.com/karol-broda/funnel/version.GitTag=$(GIT_TAG) \ - -s -w" - -BIN_DIR := bin -DIST_DIR := dist - -CLIENT_NAME := funnel -SERVER_NAME := funnel-server - -CLIENT_TARGET := $(BIN_DIR)/$(CLIENT_NAME) -SERVER_TARGET := $(BIN_DIR)/$(SERVER_NAME) - -PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 - -.PHONY: all -all: clean build - -.PHONY: help -help: - @echo "Available targets:" - @echo " build - build client and server binaries" - @echo " build-client - build only client binary" - @echo " build-server - build only server binary" - @echo " run-client - run client with default settings" - @echo " run-server - run server with default settings" - @echo " test - run all tests" - @echo " test-verbose - run all tests with verbose output" - @echo " test-coverage - run all tests with coverage" - @echo " test-race - run all tests with race detection" - @echo " clean - clean build artifacts" - @echo " deps - download dependencies" - @echo " tidy - recursively tidy all module dependencies" - @echo " deps-install - tidy and install all dependencies" - @echo " list-modules - list all discovered modules" - @echo " fmt - format code" - @echo " lint - run linter" - @echo " version - show version information" - @echo " release - build release binaries for all platforms" - @echo " install - install binaries to GOPATH/bin" - -.PHONY: build -build: $(CLIENT_TARGET) $(SERVER_TARGET) - -.PHONY: build-client -build-client: $(CLIENT_TARGET) - -.PHONY: build-server -build-server: $(SERVER_TARGET) - -$(CLIENT_TARGET): deps - @mkdir -p $(BIN_DIR) - @echo "building client..." - go build $(LDFLAGS) -o $(CLIENT_TARGET) ./cmd/funnel - -$(SERVER_TARGET): deps - @mkdir -p $(BIN_DIR) - @echo "building server..." - go build $(LDFLAGS) -o $(SERVER_TARGET) ./cmd/server - -.PHONY: run-client -run-client: build-client - $(CLIENT_TARGET) -server=http://localhost:8080 -local=localhost:3000 - -.PHONY: run-server -run-server: build-server - $(SERVER_TARGET) -port=8080 - -.PHONY: test -test: - @echo "running tests..." - @echo "discovering modules..." - @failed=0; \ - for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "testing module in $$dir..."; \ - if ! (cd $$dir && go test ./...); then \ - echo "tests failed in $$dir"; \ - failed=1; \ - fi; \ - done; \ - if [ $$failed -eq 1 ]; then \ - echo "some tests failed"; \ - exit 1; \ - fi - @echo "all modules tested successfully" - -.PHONY: test-verbose -test-verbose: - @echo "running tests with verbose output..." - @echo "discovering modules..." - @failed=0; \ - for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "testing module in $$dir..."; \ - if ! (cd $$dir && go test -v ./...); then \ - echo "tests failed in $$dir"; \ - failed=1; \ - fi; \ - done; \ - if [ $$failed -eq 1 ]; then \ - echo "some tests failed"; \ - exit 1; \ - fi - @echo "all modules tested successfully" - -.PHONY: test-coverage -test-coverage: - @echo "running tests with coverage..." - @echo "discovering modules..." - @failed=0; \ - for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "testing module in $$dir with coverage..."; \ - if ! (cd $$dir && go test -cover ./...); then \ - echo "tests failed in $$dir"; \ - failed=1; \ - fi; \ - done; \ - if [ $$failed -eq 1 ]; then \ - echo "some tests failed"; \ - exit 1; \ - fi - @echo "all modules tested successfully" - -.PHONY: test-race -test-race: - @echo "running tests with race detection..." - @echo "discovering modules..." - @failed=0; \ - for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "testing module in $$dir with race detection..."; \ - if ! (cd $$dir && go test -race ./...); then \ - echo "tests failed in $$dir"; \ - failed=1; \ - fi; \ - done; \ - if [ $$failed -eq 1 ]; then \ - echo "some tests failed"; \ - exit 1; \ - fi - @echo "all modules tested successfully" - -.PHONY: clean -clean: - @echo "cleaning build artifacts..." - rm -rf $(BIN_DIR) - rm -rf $(DIST_DIR) - go clean ./... - -.PHONY: deps -deps: - @echo "downloading dependencies..." - go work sync - go mod download - - - -.PHONY: tidy -tidy: - @echo "tidying dependencies recursively..." - @echo "discovering modules..." - @for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "tidying module in $$dir..."; \ - cd $$dir && go mod tidy && cd - > /dev/null; \ - done - @echo "synchronizing workspace..." - go work sync - @echo "all modules tidied successfully" - -.PHONY: deps-install -deps-install: tidy - @echo "installing all dependencies..." - go mod download - @echo "dependencies installed successfully" - -.PHONY: list-modules -list-modules: - @echo "discovered modules:" - @find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort | sed 's/^/ /' - -.PHONY: fmt -fmt: - @echo "formatting code..." - @echo "discovering modules..." - @for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "formatting module in $$dir..."; \ - (cd $$dir && go fmt ./...); \ - done - @echo "all modules formatted successfully" - -.PHONY: lint -lint: - @echo "running linter..." - @if ! command -v golangci-lint >/dev/null 2>&1; then \ - echo "golangci-lint not found. installing..."; \ - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \ - fi - @echo "discovering modules..." - @for dir in $$(find . -name "go.mod" -not -path "./go.work*" -exec dirname {} \; | sort); do \ - echo "linting module in $$dir..."; \ - (cd $$dir && golangci-lint run ./...); \ - done - @echo "all modules linted successfully" - -.PHONY: version -version: - @echo "version: $(VERSION)" - @echo "build date: $(BUILD_DATE)" - @echo "git commit: $(GIT_COMMIT)" - @echo "git tag: $(GIT_TAG)" - -.PHONY: release -release: clean - @echo "building release binaries..." - @mkdir -p $(DIST_DIR) - $(eval SELECTED_PLATFORMS := $(filter-out release,$(MAKECMDGOALS))) - $(eval BUILD_PLATFORMS := $(if $(SELECTED_PLATFORMS),$(SELECTED_PLATFORMS),$(PLATFORMS))) - @for platform in $(BUILD_PLATFORMS); do \ - GOOS=$$(echo $$platform | cut -d/ -f1); \ - GOARCH=$$(echo $$platform | cut -d/ -f2); \ - EXT=""; \ - if [ "$$GOOS" = "windows" ]; then \ - EXT=".exe"; \ - fi; \ - echo "building $$GOOS/$$GOARCH..."; \ - GOOS=$$GOOS GOARCH=$$GOARCH go build $(LDFLAGS) -o $(DIST_DIR)/$(CLIENT_NAME)-$$GOOS-$$GOARCH$$EXT ./cmd/funnel && \ - GOOS=$$GOOS GOARCH=$$GOARCH go build $(LDFLAGS) -o $(DIST_DIR)/$(SERVER_NAME)-$$GOOS-$$GOARCH$$EXT ./cmd/server; \ - done - @echo "release binaries built in $(DIST_DIR)/" - -$(foreach platform,$(PLATFORMS),$(eval .PHONY: $(platform))) - -.PHONY: install -install: build - @echo "installing binaries..." - go install $(LDFLAGS) ./cmd/funnel - go install $(LDFLAGS) ./cmd/server - -.PHONY: dev-setup -dev-setup: - @echo "setting up development environment..." - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - $(MAKE) deps-install - @echo "development environment setup complete" - -.PHONY: build-old -build-old: - @echo "legacy build target - use 'make build' instead" - @$(MAKE) build diff --git a/crates/funnel-client/Cargo.toml b/crates/funnel-client/Cargo.toml index b361f9d..a1c605e 100644 --- a/crates/funnel-client/Cargo.toml +++ b/crates/funnel-client/Cargo.toml @@ -3,6 +3,9 @@ name = "funnel-client" version = "0.1.0" edition.workspace = true +[lints] +workspace = true + [dependencies] funnel-core.workspace = true diff --git a/crates/funnel-client/src/config.rs b/crates/funnel-client/src/config.rs index c79cd74..5b709a7 100644 --- a/crates/funnel-client/src/config.rs +++ b/crates/funnel-client/src/config.rs @@ -85,8 +85,10 @@ mod tests { assert!(config.inlets.is_empty()); } + type TestResult = Result<(), Box>; + #[test] - fn roundtrip_toml() { + fn roundtrip_toml() -> TestResult { let mut config = Config::default(); config.inlets.insert( "default".to_string(), @@ -97,13 +99,17 @@ mod tests { }, ); - let serialized = toml::to_string_pretty(&config).unwrap(); - let parsed: Config = toml::from_str(&serialized).unwrap(); + let serialized = toml::to_string_pretty(&config)?; + let parsed: Config = toml::from_str(&serialized)?; - let inlet = parsed.inlets.get("default").unwrap(); + let inlet = parsed + .inlets + .get("default") + .ok_or("expected default inlet")?; assert_eq!(inlet.server, "https://tunnel.example.com"); assert_eq!(inlet.token.as_deref(), Some("sk_test123")); assert!(inlet.domain.is_none()); + Ok(()) } #[test] diff --git a/crates/funnel-client/src/forwarder.rs b/crates/funnel-client/src/forwarder.rs index 0b00414..635377d 100644 --- a/crates/funnel-client/src/forwarder.rs +++ b/crates/funnel-client/src/forwarder.rs @@ -20,22 +20,17 @@ pub struct Forwarder { } impl Forwarder { - pub fn new(local_addr: String) -> Self { + pub fn new(local_addr: String) -> Result { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(30)) .pool_max_idle_per_host(10) - .build() - .expect("failed to build http client"); + .build()?; - Self { client, local_addr } + Ok(Self { client, local_addr }) } - pub async fn forward( - &self, - meta: RequestMeta, - body: reqwest::Body, - ) -> (ResponseMeta, Vec) { + pub async fn forward(&self, meta: RequestMeta, body: reqwest::Body) -> (ResponseMeta, Vec) { match self.try_forward(&meta, body).await { Ok(resp) => resp, Err(e) => { @@ -85,7 +80,7 @@ fn is_hop_by_hop(name: &str) -> bool { fn collect_response_headers(headers: &reqwest::header::HeaderMap) -> HashMap> { let mut map: HashMap> = HashMap::new(); - for (name, value) in headers.iter() { + for (name, value) in headers { if is_hop_by_hop(name.as_str()) { continue; } @@ -98,10 +93,7 @@ fn collect_response_headers(headers: &reqwest::header::HeaderMap) -> HashMap (ResponseMeta, Vec) { let mut headers = HashMap::new(); headers.insert("content-type".to_string(), vec!["text/plain".to_string()]); - ( - ResponseMeta { status, headers }, - msg.as_bytes().to_vec(), - ) + (ResponseMeta { status, headers }, msg.as_bytes().to_vec()) } #[cfg(test)] diff --git a/crates/funnel-client/src/main.rs b/crates/funnel-client/src/main.rs index 5ce49da..85adbe7 100644 --- a/crates/funnel-client/src/main.rs +++ b/crates/funnel-client/src/main.rs @@ -78,7 +78,7 @@ enum ConfigAction { async fn main() -> anyhow::Result<()> { rustls::crypto::ring::default_provider() .install_default() - .expect("failed to install default crypto provider"); + .map_err(|_| anyhow::anyhow!("failed to install default crypto provider"))?; tracing_subscriber::fmt() .with_env_filter( @@ -115,13 +115,25 @@ async fn run_http( ) -> anyhow::Result<()> { let local_addr = normalize_address(&address); - let cfg = config::load().unwrap_or_default(); + let cfg = match config::load() { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!(error = %e, "failed to load config, using defaults"); + config::Config::default() + } + }; let inlet = config::get_inlet(&cfg, &inlet_name); let server_url = server_flag .or_else(|| inlet.map(|i| i.server.clone())) .filter(|s| !s.is_empty()) - .map(|s| if s.contains("://") { s } else { format!("http://{s}") }) + .map(|s| { + if s.contains("://") { + s + } else { + format!("http://{s}") + } + }) .ok_or_else(|| { anyhow::anyhow!( "no server configured. use --server or run: funnel config set-server " @@ -146,7 +158,7 @@ async fn run_http( let client = tunnel::TunnelClient::new( tunnel_id, - server_url, + &server_url, local_addr, token, quic_port, diff --git a/crates/funnel-client/src/runner.rs b/crates/funnel-client/src/runner.rs index 7da50a1..c186695 100644 --- a/crates/funnel-client/src/runner.rs +++ b/crates/funnel-client/src/runner.rs @@ -37,8 +37,8 @@ pub async fn run(client: &TunnelClient, shutdown: CancellationToken) { tracing::info!(delay_secs = delay.as_secs_f64(), "reconnecting"); tokio::select! { - _ = tokio::time::sleep(delay) => {}, - _ = shutdown.cancelled() => break, + () = tokio::time::sleep(delay) => {}, + () = shutdown.cancelled() => break, } } @@ -54,7 +54,11 @@ fn backoff_delay(attempt: u32) -> Duration { pub fn build_public_url(server_url: &str, tunnel_id: &TunnelId) -> Option { let url = Url::parse(server_url).ok()?; let host = url.host_str()?; - let scheme = if url.scheme() == "https" { "https" } else { "http" }; + let scheme = if url.scheme() == "https" { + "https" + } else { + "http" + }; match url.port() { Some(port) => Some(format!("{scheme}://{tunnel_id}.{host}:{port}")), @@ -80,24 +84,29 @@ mod tests { assert_eq!(backoff_delay(20), MAX_BACKOFF); } + type TestResult = Result<(), Box>; + #[test] - fn public_url_http() { - let id = TunnelId::new("my-tunnel").expect("valid tunnel id"); + fn public_url_http() -> TestResult { + let id = TunnelId::new("my-tunnel")?; let url = build_public_url("http://tunnel.example.com", &id); assert_eq!(url.as_deref(), Some("http://my-tunnel.tunnel.example.com")); + Ok(()) } #[test] - fn public_url_https() { - let id = TunnelId::new("my-tunnel").expect("valid tunnel id"); + fn public_url_https() -> TestResult { + let id = TunnelId::new("my-tunnel")?; let url = build_public_url("https://tunnel.example.com", &id); assert_eq!(url.as_deref(), Some("https://my-tunnel.tunnel.example.com")); + Ok(()) } #[test] - fn public_url_with_port() { - let id = TunnelId::new("abc").expect("valid tunnel id"); + fn public_url_with_port() -> TestResult { + let id = TunnelId::new("abc")?; let url = build_public_url("http://localhost:8080", &id); assert_eq!(url.as_deref(), Some("http://abc.localhost:8080")); + Ok(()) } } diff --git a/crates/funnel-client/src/tunnel.rs b/crates/funnel-client/src/tunnel.rs index 7e4cc8e..0d268bf 100644 --- a/crates/funnel-client/src/tunnel.rs +++ b/crates/funnel-client/src/tunnel.rs @@ -6,10 +6,10 @@ use tokio_util::io::ReaderStream; use tokio_util::sync::CancellationToken; use url::Url; +use funnel_core::protocol::QUIC_ALPN; use funnel_core::protocol::frame; use funnel_core::protocol::handshake::{Handshake, HandshakeResponse}; use funnel_core::protocol::request::RequestMeta; -use funnel_core::protocol::QUIC_ALPN; use funnel_core::tunnel::id::TunnelId; use crate::forwarder::Forwarder; @@ -29,13 +29,13 @@ pub struct TunnelClient { impl TunnelClient { pub fn new( tunnel_id: TunnelId, - server_url: String, + server_url: &str, local_addr: String, token: Option, quic_port: u16, insecure: bool, ) -> anyhow::Result { - let url = Url::parse(&server_url)?; + let url = Url::parse(server_url)?; let host = url .host_str() .ok_or_else(|| anyhow::anyhow!("no host in server url"))? @@ -67,7 +67,7 @@ impl TunnelClient { /// connect to the server via quic and run the tunnel until it disconnects. pub async fn run(&self, cancel: CancellationToken) -> anyhow::Result<()> { let conn = self.connect().await?; - let forwarder = Arc::new(Forwarder::new(self.local_addr.clone())); + let forwarder = Arc::new(Forwarder::new(self.local_addr.clone())?); let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_REQUESTS)); loop { @@ -91,7 +91,7 @@ impl TunnelClient { Ok(p) => p, Err(_) => return, }, - _ = cancel.cancelled() => return, + () = cancel.cancelled() => return, }; if let Err(e) = handle_stream(send, recv, &fwd).await { @@ -99,7 +99,7 @@ impl TunnelClient { } }); } - _ = cancel.cancelled() => break, + () = cancel.cancelled() => break, } } @@ -114,7 +114,7 @@ impl TunnelClient { let conn = tokio::time::timeout(HANDSHAKE_TIMEOUT, connecting) .await - .map_err(|_| anyhow::anyhow!("connection timed out")) + .map_err(|_| anyhow::anyhow!("quic handshake timed out after {HANDSHAKE_TIMEOUT:?}")) .and_then(|r| r.map_err(Into::into))?; let (mut send, mut recv) = conn.open_bi().await?; diff --git a/crates/funnel-core/Cargo.toml b/crates/funnel-core/Cargo.toml index 6a3ff11..b7edeea 100644 --- a/crates/funnel-core/Cargo.toml +++ b/crates/funnel-core/Cargo.toml @@ -3,6 +3,9 @@ name = "funnel-core" version = "0.1.0" edition.workspace = true +[lints] +workspace = true + [dependencies] base64 = "0.22" getrandom = "0.3" diff --git a/crates/funnel-core/src/auth/token.rs b/crates/funnel-core/src/auth/token.rs index 04e5e7f..a804b26 100644 --- a/crates/funnel-core/src/auth/token.rs +++ b/crates/funnel-core/src/auth/token.rs @@ -30,10 +30,14 @@ impl fmt::Display for ApiKeyPrefix { } } -pub fn generate_api_key() -> String { +pub fn generate_api_key() -> Result { let mut bytes = [0u8; RANDOM_BYTES]; - getrandom::fill(&mut bytes).expect("failed to generate random bytes"); - format!("{}{}", PREFIX, BASE64_URL_SAFE_NO_PAD.encode(bytes)) + getrandom::fill(&mut bytes)?; + Ok(format!( + "{}{}", + PREFIX, + BASE64_URL_SAFE_NO_PAD.encode(bytes) + )) } /// sha256 is fine here since api keys are high entropy random tokens, @@ -49,23 +53,26 @@ mod tests { use super::*; #[test] - fn generate_key_has_prefix() { - let key = generate_api_key(); + fn generate_key_has_prefix() -> Result<(), getrandom::Error> { + let key = generate_api_key()?; assert!(key.starts_with(PREFIX)); + Ok(()) } #[test] - fn generate_key_has_sufficient_length() { - let key = generate_api_key(); + fn generate_key_has_sufficient_length() -> Result<(), getrandom::Error> { + let key = generate_api_key()?; // prefix (3) + base64 of 32 bytes (43) = 46 chars assert!(key.len() >= 40); + Ok(()) } #[test] - fn generated_keys_are_unique() { - let a = generate_api_key(); - let b = generate_api_key(); + fn generated_keys_are_unique() -> Result<(), getrandom::Error> { + let a = generate_api_key()?; + let b = generate_api_key()?; assert_ne!(a, b); + Ok(()) } #[test] @@ -88,17 +95,18 @@ mod tests { } #[test] - fn prefix_from_key() { - let key = generate_api_key(); + fn prefix_from_key() -> Result<(), getrandom::Error> { + let key = generate_api_key()?; let prefix = ApiKeyPrefix::from_key(&key); assert_eq!(prefix.as_ref().len(), VISIBLE_PREFIX_LEN); assert!(key.starts_with(prefix.as_ref())); + Ok(()) } #[test] fn prefix_display() { let prefix = ApiKeyPrefix::from_key("sk_abcdefghij_rest"); - assert_eq!(format!("{}", prefix), "sk_abcdefg..."); + assert_eq!(format!("{prefix}"), "sk_abcdefg..."); } #[test] diff --git a/crates/funnel-core/src/protocol/frame.rs b/crates/funnel-core/src/protocol/frame.rs index 092099c..03be5b6 100644 --- a/crates/funnel-core/src/protocol/frame.rs +++ b/crates/funnel-core/src/protocol/frame.rs @@ -1,8 +1,8 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -const MAX_META_SIZE: u32 = 1_048_576; // 1mb - -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Error)] pub enum FrameError { #[error("io error: {0}")] Io(#[from] std::io::Error), @@ -10,16 +10,18 @@ pub enum FrameError { #[error("frame too large: {size} bytes (max {max})")] TooLarge { size: u32, max: u32 }, - #[error("encode error: {0}")] - Encode(#[from] rmp_serde::encode::Error), + #[error("empty frame")] + Empty, #[error("decode error: {0}")] Decode(#[from] rmp_serde::decode::Error), + + #[error("encode error: {0}")] + Encode(#[from] rmp_serde::encode::Error), } -/// write a length prefixed frame: [4 bytes big endian u32 length][data] -pub async fn write_frame( - writer: &mut W, +pub async fn write_frame( + writer: &mut (impl AsyncWrite + Unpin), data: &[u8], ) -> Result<(), FrameError> { let len = data.len() as u32; @@ -28,15 +30,18 @@ pub async fn write_frame( Ok(()) } -/// read a length prefixed frame, rejecting frames larger than max_size -pub async fn read_frame( - reader: &mut R, +pub async fn read_frame( + reader: &mut (impl AsyncRead + Unpin), max_size: u32, ) -> Result, FrameError> { let mut len_buf = [0u8; 4]; reader.read_exact(&mut len_buf).await?; let len = u32::from_be_bytes(len_buf); + if len == 0 { + return Err(FrameError::Empty); + } + if len > max_size { return Err(FrameError::TooLarge { size: len, @@ -49,62 +54,69 @@ pub async fn read_frame( Ok(buf) } -/// serialize T as msgpack and write as a length prefixed frame -pub async fn write_meta( - writer: &mut W, - meta: &T, +pub async fn write_meta( + writer: &mut (impl AsyncWrite + Unpin), + value: &impl Serialize, ) -> Result<(), FrameError> { - let data = rmp_serde::to_vec_named(meta)?; + let data = rmp_serde::to_vec_named(value)?; write_frame(writer, &data).await } -/// read a length prefixed frame and deserialize as msgpack -pub async fn read_meta( - reader: &mut R, +const MAX_META_SIZE: u32 = 1024 * 1024; + +pub async fn read_meta Deserialize<'de>>( + reader: &mut (impl AsyncRead + Unpin), ) -> Result { let data = read_frame(reader, MAX_META_SIZE).await?; - let value = rmp_serde::from_slice(&data)?; - Ok(value) + Ok(rmp_serde::from_slice(&data)?) } #[cfg(test)] mod tests { use super::*; + type TestResult = Result<(), Box>; + #[tokio::test] - async fn frame_roundtrip() { + async fn frame_roundtrip() -> TestResult { let data = b"hello world"; let mut buf = Vec::new(); - write_frame(&mut buf, data).await.unwrap(); + write_frame(&mut buf, data).await?; let mut cursor = std::io::Cursor::new(buf); - let result = read_frame(&mut cursor, 1024).await.unwrap(); + let result = read_frame(&mut cursor, 1024).await?; assert_eq!(result, data); + Ok(()) } #[tokio::test] - async fn frame_too_large() { + async fn frame_too_large() -> TestResult { let data = vec![0u8; 100]; let mut buf = Vec::new(); - write_frame(&mut buf, &data).await.unwrap(); + write_frame(&mut buf, &data).await?; let mut cursor = std::io::Cursor::new(buf); let result = read_frame(&mut cursor, 50).await; - assert!(matches!(result, Err(FrameError::TooLarge { size: 100, max: 50 }))); + assert!(matches!( + result, + Err(FrameError::TooLarge { size: 100, max: 50 }) + )); + Ok(()) } #[tokio::test] - async fn meta_roundtrip() { + async fn meta_roundtrip() -> TestResult { use std::collections::HashMap; let original: HashMap = - [("key".into(), "value".into())].into_iter().collect(); + std::iter::once(("key".into(), "value".into())).collect(); let mut buf = Vec::new(); - write_meta(&mut buf, &original).await.unwrap(); + write_meta(&mut buf, &original).await?; let mut cursor = std::io::Cursor::new(buf); - let decoded: HashMap = read_meta(&mut cursor).await.unwrap(); + let decoded: HashMap = read_meta(&mut cursor).await?; assert_eq!(decoded, original); + Ok(()) } } diff --git a/crates/funnel-core/src/protocol/handshake.rs b/crates/funnel-core/src/protocol/handshake.rs index f731393..5407f51 100644 --- a/crates/funnel-core/src/protocol/handshake.rs +++ b/crates/funnel-core/src/protocol/handshake.rs @@ -18,7 +18,7 @@ pub enum HandshakeResponse { } impl HandshakeResponse { - pub fn ok() -> Self { + pub const fn ok() -> Self { Self::Ok } diff --git a/crates/funnel-core/src/protocol/request.rs b/crates/funnel-core/src/protocol/request.rs index b437149..019e079 100644 --- a/crates/funnel-core/src/protocol/request.rs +++ b/crates/funnel-core/src/protocol/request.rs @@ -19,8 +19,10 @@ pub struct ResponseMeta { mod tests { use super::*; + type TestResult = Result<(), Box>; + #[test] - fn request_meta_roundtrip() { + fn request_meta_roundtrip() -> TestResult { let meta = RequestMeta { method: "POST".into(), path: "/api/data".into(), @@ -31,21 +33,23 @@ mod tests { }, }; - let encoded = rmp_serde::to_vec_named(&meta).unwrap(); - let decoded: RequestMeta = rmp_serde::from_slice(&encoded).unwrap(); + let encoded = rmp_serde::to_vec_named(&meta)?; + let decoded: RequestMeta = rmp_serde::from_slice(&encoded)?; assert_eq!(decoded.method, "POST"); assert_eq!(decoded.path, "/api/data"); + Ok(()) } #[test] - fn response_meta_roundtrip() { + fn response_meta_roundtrip() -> TestResult { let meta = ResponseMeta { status: 200, headers: HashMap::new(), }; - let encoded = rmp_serde::to_vec_named(&meta).unwrap(); - let decoded: ResponseMeta = rmp_serde::from_slice(&encoded).unwrap(); + let encoded = rmp_serde::to_vec_named(&meta)?; + let decoded: ResponseMeta = rmp_serde::from_slice(&encoded)?; assert_eq!(decoded.status, 200); + Ok(()) } } diff --git a/crates/funnel-core/src/tunnel/id.rs b/crates/funnel-core/src/tunnel/id.rs index 1f488b2..1f293f9 100644 --- a/crates/funnel-core/src/tunnel/id.rs +++ b/crates/funnel-core/src/tunnel/id.rs @@ -16,13 +16,15 @@ pub enum TunnelIdError { #[error("tunnel ID must be lowercase")] NotLowercase, - #[error("tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen")] + #[error( + "tunnel ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen" + )] InvalidFormat, } const DOMAIN_SAFE_ALPHABET: &[char] = &[ - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', - 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', + 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', ]; const DEFAULT_LENGTH: usize = 8; @@ -121,8 +123,14 @@ mod tests { #[test] fn too_short() { - assert!(matches!(TunnelId::new("ab"), Err(TunnelIdError::TooShort(2)))); - assert!(matches!(TunnelId::new("a"), Err(TunnelIdError::TooShort(1)))); + assert!(matches!( + TunnelId::new("ab"), + Err(TunnelIdError::TooShort(2)) + )); + assert!(matches!( + TunnelId::new("a"), + Err(TunnelIdError::TooShort(1)) + )); } #[test] @@ -144,10 +152,22 @@ mod tests { #[test] fn invalid_format() { - assert!(matches!(TunnelId::new("-abc"), Err(TunnelIdError::InvalidFormat))); - assert!(matches!(TunnelId::new("abc-"), Err(TunnelIdError::InvalidFormat))); - assert!(matches!(TunnelId::new("ab_c"), Err(TunnelIdError::InvalidFormat))); - assert!(matches!(TunnelId::new("ab c"), Err(TunnelIdError::InvalidFormat))); + assert!(matches!( + TunnelId::new("-abc"), + Err(TunnelIdError::InvalidFormat) + )); + assert!(matches!( + TunnelId::new("abc-"), + Err(TunnelIdError::InvalidFormat) + )); + assert!(matches!( + TunnelId::new("ab_c"), + Err(TunnelIdError::InvalidFormat) + )); + assert!(matches!( + TunnelId::new("ab c"), + Err(TunnelIdError::InvalidFormat) + )); } #[test] @@ -159,22 +179,26 @@ mod tests { } } + type TestResult = Result<(), Box>; + #[test] - fn display_and_into_string() { - let id = TunnelId::new("my-tunnel").unwrap(); + fn display_and_into_string() -> TestResult { + let id = TunnelId::new("my-tunnel")?; assert_eq!(id.to_string(), "my-tunnel"); let s: String = id.into(); assert_eq!(s, "my-tunnel"); + Ok(()) } #[test] - fn serde_roundtrip() { - let id = TunnelId::new("test-123").unwrap(); - let json = serde_json::to_string(&id).unwrap(); + fn serde_roundtrip() -> TestResult { + let id = TunnelId::new("test-123")?; + let json = serde_json::to_string(&id)?; assert_eq!(json, "\"test-123\""); - let parsed: TunnelId = serde_json::from_str(&json).unwrap(); + let parsed: TunnelId = serde_json::from_str(&json)?; assert_eq!(parsed.as_ref(), "test-123"); + Ok(()) } #[test] diff --git a/crates/funnel-e2e/Cargo.toml b/crates/funnel-e2e/Cargo.toml index 63f1002..7bd4d26 100644 --- a/crates/funnel-e2e/Cargo.toml +++ b/crates/funnel-e2e/Cargo.toml @@ -3,6 +3,9 @@ name = "funnel-e2e" version = "0.1.0" edition.workspace = true +[lints] +workspace = true + [dev-dependencies] tokio.workspace = true reqwest = { workspace = true, features = ["rustls"] } diff --git a/crates/funnel-e2e/src/lib.rs b/crates/funnel-e2e/src/lib.rs index e69de29..8b13789 100644 --- a/crates/funnel-e2e/src/lib.rs +++ b/crates/funnel-e2e/src/lib.rs @@ -0,0 +1 @@ + diff --git a/crates/funnel-e2e/tests/e2e/harness.rs b/crates/funnel-e2e/tests/e2e/harness.rs index de9ea9d..68ccaad 100644 --- a/crates/funnel-e2e/tests/e2e/harness.rs +++ b/crates/funnel-e2e/tests/e2e/harness.rs @@ -1,11 +1,12 @@ +use std::fs::File; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::Duration; +use axum::Router; use axum::extract::DefaultBodyLimit; use axum::routing::{get, post}; -use axum::Router; use tokio::task::JoinHandle; const TUNNEL_ID: &str = "e2etest"; @@ -17,34 +18,39 @@ pub struct TestEnv { server_process: Child, client_process: Child, local_server_handle: JoinHandle<()>, + server_log: PathBuf, + client_log: PathBuf, pub http_port: u16, pub host_header: String, pub client: reqwest::Client, } impl TestEnv { - pub async fn start() -> Self { - let (local_handle, local_port) = start_mock_server(); - let http_port = free_port(); - let quic_port = free_port(); + pub async fn start() -> Result> { + let (local_handle, local_port) = start_mock_server()?; + let http_port = free_port()?; + let quic_port = free_port()?; let host_header = format!("{TUNNEL_ID}.localhost:{http_port}"); - let server_process = start_server_process(http_port, quic_port); + let (server_process, server_log) = start_server_process(http_port, quic_port)?; wait_for_tcp(http_port).await; - let client_process = start_client_process(local_port, http_port, quic_port, TUNNEL_ID); + let (client_process, client_log) = + start_client_process(local_port, http_port, quic_port, TUNNEL_ID)?; let client = reqwest::Client::new(); wait_for_tunnel(&client, http_port, &host_header).await; - Self { + Ok(Self { server_process, client_process, local_server_handle: local_handle, + server_log, + client_log, http_port, host_header, client, - } + }) } pub fn tunnel_request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { @@ -57,6 +63,23 @@ impl TestEnv { pub fn server_url(&self, path: &str) -> String { format!("http://127.0.0.1:{}{}", self.http_port, path) } + + fn dump_logs(&self) { + if let Ok(content) = std::fs::read_to_string(&self.server_log) + && !content.is_empty() + { + eprintln!("\n--- funnel-server stderr ---"); + eprintln!("{content}"); + eprintln!("--- end funnel-server stderr ---\n"); + } + if let Ok(content) = std::fs::read_to_string(&self.client_log) + && !content.is_empty() + { + eprintln!("\n--- funnel-client stderr ---"); + eprintln!("{content}"); + eprintln!("--- end funnel-client stderr ---\n"); + } + } } impl Drop for TestEnv { @@ -66,47 +89,57 @@ impl Drop for TestEnv { self.local_server_handle.abort(); let _ = self.server_process.wait(); let _ = self.client_process.wait(); + + if std::thread::panicking() { + self.dump_logs(); + } + + let _ = std::fs::remove_file(&self.server_log); + let _ = std::fs::remove_file(&self.client_log); } } -fn free_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port() +fn free_port() -> Result { + Ok(TcpListener::bind("127.0.0.1:0")?.local_addr()?.port()) } -fn binary_path(name: &str) -> PathBuf { +fn binary_path(name: &str) -> Result { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let path = manifest_dir - .parent() - .unwrap() + let workspace_root = manifest_dir .parent() - .unwrap() - .join("target") - .join("debug") - .join(name); - assert!( - path.exists(), - "binary not found at {}: run `cargo build -p funnel-server -p funnel-client` first", - path.display() - ); - path + .and_then(|p| p.parent()) + .ok_or("could not resolve workspace root from CARGO_MANIFEST_DIR")?; + let path = workspace_root.join("target").join("debug").join(name); + if !path.exists() { + return Err(format!( + "binary not found at {}: run `cargo build -p funnel-server -p funnel-client` first", + path.display() + )); + } + Ok(path) +} + +fn log_file(name: &str) -> Result<(File, PathBuf), std::io::Error> { + let path = std::env::temp_dir().join(format!("funnel-{name}-{}.log", std::process::id())); + let file = File::create(&path)?; + Ok((file, path)) } -fn start_mock_server() -> (JoinHandle<()>, u16) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - listener.set_nonblocking(true).unwrap(); +fn start_mock_server() -> Result<(JoinHandle<()>, u16), std::io::Error> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + listener.set_nonblocking(true)?; + + let tokio_listener = tokio::net::TcpListener::from_std(listener)?; let handle = tokio::spawn(async move { - let listener = tokio::net::TcpListener::from_std(listener).unwrap(); let app = mock_app(); - axum::serve(listener, app).await.unwrap(); + if let Err(e) = axum::serve(tokio_listener, app).await { + eprintln!("mock server error: {e}"); + } }); - (handle, port) + Ok((handle, port)) } fn mock_app() -> Router { @@ -123,10 +156,7 @@ fn mock_app() -> Router { .iter() .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) .collect(); - ( - [("x-custom-header", "test-value")], - axum::Json(map), - ) + ([("x-custom-header", "test-value")], axum::Json(map)) }), ) .route( @@ -136,8 +166,13 @@ fn mock_app() -> Router { .layer(DefaultBodyLimit::max(MOCK_BODY_LIMIT)) } -fn start_server_process(http_port: u16, quic_port: u16) -> Child { - Command::new(binary_path("funnel-server")) +fn start_server_process( + http_port: u16, + quic_port: u16, +) -> Result<(Child, PathBuf), Box> { + let (stderr_file, log_path) = log_file("server")?; + + let child = Command::new(binary_path("funnel-server")?) .args([ "--port", &http_port.to_string(), @@ -147,9 +182,10 @@ fn start_server_process(http_port: u16, quic_port: u16) -> Child { "127.0.0.1", ]) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("failed to start funnel-server") + .stderr(Stdio::from(stderr_file)) + .spawn()?; + + Ok((child, log_path)) } fn start_client_process( @@ -157,8 +193,10 @@ fn start_client_process( server_http_port: u16, quic_port: u16, tunnel_id: &str, -) -> Child { - Command::new(binary_path("funnel-client")) +) -> Result<(Child, PathBuf), Box> { + let (stderr_file, log_path) = log_file("client")?; + + let child = Command::new(binary_path("funnel-client")?) .args([ "http", &format!("127.0.0.1:{local_port}"), @@ -171,17 +209,19 @@ fn start_client_process( "--insecure", ]) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("failed to start funnel-client") + .stderr(Stdio::from(stderr_file)) + .spawn()?; + + Ok((child, log_path)) } async fn wait_for_tcp(port: u16) { let deadline = tokio::time::Instant::now() + READY_TIMEOUT; loop { - if tokio::time::Instant::now() > deadline { - panic!("tcp port {port} did not become ready"); - } + assert!( + tokio::time::Instant::now() <= deadline, + "tcp port {port} did not become ready" + ); if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")) .await .is_ok() @@ -196,9 +236,10 @@ async fn wait_for_tunnel(client: &reqwest::Client, http_port: u16, host_header: let url = format!("http://127.0.0.1:{http_port}/hello"); let deadline = tokio::time::Instant::now() + READY_TIMEOUT; loop { - if tokio::time::Instant::now() > deadline { - panic!("tunnel did not become ready"); - } + assert!( + tokio::time::Instant::now() <= deadline, + "tunnel did not become ready" + ); match client.get(&url).header("host", host_header).send().await { Ok(resp) if resp.status().is_success() => return, _ => tokio::time::sleep(POLL_INTERVAL).await, diff --git a/crates/funnel-e2e/tests/e2e/main.rs b/crates/funnel-e2e/tests/e2e/main.rs index ce6dd07..7f02dd6 100644 --- a/crates/funnel-e2e/tests/e2e/main.rs +++ b/crates/funnel-e2e/tests/e2e/main.rs @@ -3,66 +3,55 @@ mod harness; use harness::TestEnv; use reqwest::Method; +type TestResult = Result<(), Box>; + #[tokio::test(flavor = "multi_thread")] -async fn e2e() { - let env = TestEnv::start().await; - - basic_get(&env).await; - post_echo(&env).await; - large_body(&env).await; - concurrent_requests(&env).await; - response_headers(&env).await; - metrics_endpoint(&env).await; - unknown_tunnel_404(&env).await; -} +async fn basic_get() -> TestResult { + let env = TestEnv::start().await?; -async fn basic_get(env: &TestEnv) { - let resp = env - .tunnel_request(Method::GET, "/hello") - .send() - .await - .unwrap(); - - assert_eq!(resp.status(), 200, "basic_get: expected 200"); - assert_eq!( - resp.text().await.unwrap(), - "hello from local service", - "basic_get: unexpected body" - ); + let resp = env.tunnel_request(Method::GET, "/hello").send().await?; + + assert_eq!(resp.status(), 200); + assert_eq!(resp.text().await?, "hello from local service"); + Ok(()) } -async fn post_echo(env: &TestEnv) { +#[tokio::test(flavor = "multi_thread")] +async fn post_echo() -> TestResult { + let env = TestEnv::start().await?; + let resp = env .tunnel_request(Method::POST, "/echo") .body("test body") .send() - .await - .unwrap(); - - assert_eq!(resp.status(), 200, "post_echo: expected 200"); - assert_eq!( - resp.text().await.unwrap(), - "echo: test body", - "post_echo: unexpected body" - ); + .await?; + + assert_eq!(resp.status(), 200); + assert_eq!(resp.text().await?, "echo: test body"); + Ok(()) } -async fn large_body(env: &TestEnv) { +#[tokio::test(flavor = "multi_thread")] +async fn large_body() -> TestResult { + let env = TestEnv::start().await?; let size = 2_500_000; let payload = vec![b'x'; size]; + let resp = env .tunnel_request(Method::POST, "/large") .body(payload) .send() - .await - .unwrap(); + .await?; - assert_eq!(resp.status(), 200, "large_body: expected 200"); - let body = resp.bytes().await.unwrap(); - assert_eq!(body.len(), size, "large_body: response size mismatch"); + assert_eq!(resp.status(), 200); + let body = resp.bytes().await?; + assert_eq!(body.len(), size); + Ok(()) } -async fn concurrent_requests(env: &TestEnv) { +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_requests() -> TestResult { + let env = TestEnv::start().await?; let mut set = tokio::task::JoinSet::new(); for _ in 0..10 { @@ -70,67 +59,59 @@ async fn concurrent_requests(env: &TestEnv) { let url = format!("http://127.0.0.1:{}/hello", env.http_port); let host = env.host_header.clone(); - set.spawn(async move { - client - .get(&url) - .header("host", &host) - .send() - .await - .unwrap() - }); + set.spawn(async move { client.get(&url).header("host", &host).send().await }); } let mut ok_count = 0; while let Some(result) = set.join_next().await { - let resp = result.unwrap(); - assert_eq!(resp.status(), 200, "concurrent_requests: expected 200"); + let resp = result??; + assert_eq!(resp.status(), 200); ok_count += 1; } - assert_eq!(ok_count, 10, "concurrent_requests: expected 10 responses"); + assert_eq!(ok_count, 10); + Ok(()) } -async fn response_headers(env: &TestEnv) { - let resp = env - .tunnel_request(Method::GET, "/headers") - .send() - .await - .unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn response_headers() -> TestResult { + let env = TestEnv::start().await?; - assert_eq!(resp.status(), 200, "response_headers: expected 200"); + let resp = env.tunnel_request(Method::GET, "/headers").send().await?; + assert_eq!(resp.status(), 200); let custom = resp.headers().get("x-custom-header"); - assert_eq!( - custom.and_then(|v| v.to_str().ok()), - Some("test-value"), - "response_headers: missing or wrong x-custom-header" - ); + assert_eq!(custom.and_then(|v| v.to_str().ok()), Some("test-value")); + Ok(()) } -async fn metrics_endpoint(env: &TestEnv) { +#[tokio::test(flavor = "multi_thread")] +async fn metrics_endpoint() -> TestResult { + let env = TestEnv::start().await?; + let resp = env .client - .get(&env.server_url("/api/metrics")) + .get(env.server_url("/api/metrics")) .send() - .await - .unwrap(); - - assert_eq!(resp.status(), 200, "metrics_endpoint: expected 200"); - let body = resp.text().await.unwrap(); - assert!( - body.contains("funnel_requests_total"), - "metrics_endpoint: expected funnel_requests_total in prometheus output" - ); + .await?; + + assert_eq!(resp.status(), 200); + let body = resp.text().await?; + assert!(body.contains("funnel_requests_total")); + Ok(()) } -async fn unknown_tunnel_404(env: &TestEnv) { +#[tokio::test(flavor = "multi_thread")] +async fn unknown_tunnel_returns_404() -> TestResult { + let env = TestEnv::start().await?; let url = format!("http://127.0.0.1:{}/hello", env.http_port); + let resp = env .client .get(&url) .header("host", format!("nonexistent.localhost:{}", env.http_port)) .send() - .await - .unwrap(); + .await?; - assert_eq!(resp.status(), 404, "unknown_tunnel_404: expected 404"); + assert_eq!(resp.status(), 404); + Ok(()) } diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index 47a1b20..3e6ac8c 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -3,6 +3,9 @@ name = "funnel-server" version = "0.1.0" edition.workspace = true +[lints] +workspace = true + [dependencies] funnel-core.workspace = true diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs index f343b6e..6845bd0 100644 --- a/crates/funnel-server/src/app.rs +++ b/crates/funnel-server/src/app.rs @@ -48,10 +48,7 @@ pub fn build_router(state: Arc, metrics_handle: PrometheusHandle) -> R ) .route("/keys", get(api::keys::list).post(api::keys::create)) .route("/keys/{id}", axum::routing::delete(api::keys::revoke)) - .route( - "/metrics", - get(metrics::handler).with_state(metrics_handle), - ); + .route("/metrics", get(metrics::handler).with_state(metrics_handle)); Router::new() .nest("/api", api_routes) diff --git a/crates/funnel-server/src/auth/mod.rs b/crates/funnel-server/src/auth/mod.rs index e69de29..8b13789 100644 --- a/crates/funnel-server/src/auth/mod.rs +++ b/crates/funnel-server/src/auth/mod.rs @@ -0,0 +1 @@ + diff --git a/crates/funnel-server/src/db/api_keys.rs b/crates/funnel-server/src/db/api_keys.rs index 9423e1e..a8f5b76 100644 --- a/crates/funnel-server/src/db/api_keys.rs +++ b/crates/funnel-server/src/db/api_keys.rs @@ -42,16 +42,17 @@ pub async fn create( user_id: Uuid, name: &str, ) -> Result<(String, ApiKey), sqlx::Error> { - let plaintext = auth::generate_api_key(); + let plaintext = auth::generate_api_key() + .map_err(|e| sqlx::Error::Protocol(format!("failed to generate api key: {e}")))?; let hash = auth::hash_token(&plaintext); let prefix = auth::ApiKeyPrefix::from_key(&plaintext); let key = sqlx::query_as::<_, ApiKey>( - r#" + r" INSERT INTO api_keys (user_id, name, key_hash, key_prefix) VALUES ($1, $2, $3, $4) RETURNING * - "#, + ", ) .bind(user_id) .bind(name) @@ -66,12 +67,10 @@ pub async fn create( pub async fn validate(pool: &PgPool, plaintext: &str) -> Result, sqlx::Error> { let hash = auth::hash_token(plaintext); - sqlx::query_as::<_, ApiKey>( - "SELECT * FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL", - ) - .bind(&hash) - .fetch_optional(pool) - .await + sqlx::query_as::<_, ApiKey>("SELECT * FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL") + .bind(&hash) + .fetch_optional(pool) + .await } pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { @@ -100,7 +99,9 @@ mod tests { use super::*; use crate::db::users; - async fn setup_user(pool: &PgPool) -> users::User { + type TestResult = Result<(), Box>; + + async fn setup_user(pool: &PgPool) -> Result { users::create( pool, users::NewUser { @@ -112,83 +113,82 @@ mod tests { }, ) .await - .unwrap() } #[tokio::test] #[ignore = "requires database"] - async fn create_and_validate_key() { - let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) - .await - .unwrap(); + async fn create_and_validate_key() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - let user = setup_user(&pool).await; - let (plaintext, key) = create(&pool, user.id, "test-key").await.unwrap(); + let user = setup_user(&pool).await?; + let (plaintext, key) = create(&pool, user.id, "test-key").await?; assert!(plaintext.starts_with("sk_")); assert_eq!(key.name, "test-key"); assert_eq!(key.user_id, user.id); assert!(key.revoked_at.is_none()); - let validated = validate(&pool, &plaintext).await.unwrap(); + let validated = validate(&pool, &plaintext).await?; assert!(validated.is_some()); - assert_eq!(validated.unwrap().id, key.id); + assert_eq!(validated.ok_or("expected Some")?.id, key.id); + + Ok(()) } #[tokio::test] #[ignore = "requires database"] - async fn validate_invalid_key() { - let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) - .await - .unwrap(); + async fn validate_invalid_key() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - let result = validate(&pool, "sk_totally_bogus_key").await.unwrap(); + let result = validate(&pool, "sk_totally_bogus_key").await?; assert!(result.is_none()); + + Ok(()) } #[tokio::test] #[ignore = "requires database"] - async fn revoke_key_prevents_validation() { - let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) - .await - .unwrap(); + async fn revoke_key_prevents_validation() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - let user = setup_user(&pool).await; - let (plaintext, key) = create(&pool, user.id, "revoke-test").await.unwrap(); + let user = setup_user(&pool).await?; + let (plaintext, key) = create(&pool, user.id, "revoke-test").await?; - let revoked = revoke(&pool, key.id, user.id).await.unwrap(); + let revoked = revoke(&pool, key.id, user.id).await?; assert!(revoked); - let validated = validate(&pool, &plaintext).await.unwrap(); + let validated = validate(&pool, &plaintext).await?; assert!(validated.is_none()); + + Ok(()) } #[tokio::test] #[ignore = "requires database"] - async fn list_keys_for_user() { - let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) - .await - .unwrap(); + async fn list_keys_for_user() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - let user = setup_user(&pool).await; - create(&pool, user.id, "key-1").await.unwrap(); - create(&pool, user.id, "key-2").await.unwrap(); + let user = setup_user(&pool).await?; + create(&pool, user.id, "key-1").await?; + create(&pool, user.id, "key-2").await?; - let keys = list_for_user(&pool, user.id).await.unwrap(); + let keys = list_for_user(&pool, user.id).await?; assert_eq!(keys.len(), 2); + + Ok(()) } #[tokio::test] #[ignore = "requires database"] - async fn duplicate_key_name_rejected() { - let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) - .await - .unwrap(); + async fn duplicate_key_name_rejected() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - let user = setup_user(&pool).await; - create(&pool, user.id, "dupe-name").await.unwrap(); + let user = setup_user(&pool).await?; + create(&pool, user.id, "dupe-name").await?; let result = create(&pool, user.id, "dupe-name").await; assert!(result.is_err()); + + Ok(()) } } diff --git a/crates/funnel-server/src/db/tunnel_sessions.rs b/crates/funnel-server/src/db/tunnel_sessions.rs index a710b03..00ddd9d 100644 --- a/crates/funnel-server/src/db/tunnel_sessions.rs +++ b/crates/funnel-server/src/db/tunnel_sessions.rs @@ -24,11 +24,11 @@ pub async fn create( client_ip: Option, ) -> Result { sqlx::query_as::<_, TunnelSession>( - r#" + r" INSERT INTO tunnel_sessions (user_id, tunnel_id, client_ip) VALUES ($1, $2, $3) RETURNING * - "#, + ", ) .bind(user_id) .bind(tunnel_id) @@ -45,11 +45,11 @@ pub async fn disconnect( requests: i64, ) -> Result { let result = sqlx::query( - r#" + r" UPDATE tunnel_sessions SET disconnected_at = now(), bytes_in = $2, bytes_out = $3, requests = $4 WHERE id = $1 AND disconnected_at IS NULL - "#, + ", ) .bind(session_id) .bind(bytes_in) @@ -67,12 +67,12 @@ pub async fn list_for_user( limit: i64, ) -> Result, sqlx::Error> { sqlx::query_as::<_, TunnelSession>( - r#" + r" SELECT * FROM tunnel_sessions WHERE user_id = $1 ORDER BY connected_at DESC LIMIT $2 - "#, + ", ) .bind(user_id) .bind(limit) @@ -93,7 +93,9 @@ mod tests { use super::*; use crate::db::users; - async fn setup_user(pool: &PgPool) -> users::User { + type TestResult = Result<(), Box>; + + async fn setup_user(pool: &PgPool) -> Result { users::create( pool, users::NewUser { @@ -105,49 +107,44 @@ mod tests { }, ) .await - .unwrap() } #[tokio::test] #[ignore = "requires database"] - async fn create_and_disconnect_session() { - let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) - .await - .unwrap(); + async fn create_and_disconnect_session() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - let user = setup_user(&pool).await; - let ip: IpNetwork = "192.168.1.1".parse::().unwrap().into(); - let session = create(&pool, user.id, "test-tunnel", Some(ip)) - .await - .unwrap(); + let user = setup_user(&pool).await?; + let ip: IpNetwork = "192.168.1.1".parse::()?.into(); + let session = create(&pool, user.id, "test-tunnel", Some(ip)).await?; assert_eq!(session.tunnel_id, "test-tunnel"); assert!(session.disconnected_at.is_none()); assert_eq!(session.bytes_in, 0); assert!(session.client_ip.is_some()); - let disconnected = disconnect(&pool, session.id, 1024, 2048, 10) - .await - .unwrap(); + let disconnected = disconnect(&pool, session.id, 1024, 2048, 10).await?; assert!(disconnected); + + Ok(()) } #[tokio::test] #[ignore = "requires database"] - async fn list_active_sessions() { - let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) - .await - .unwrap(); + async fn list_active_sessions() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - let user = setup_user(&pool).await; - let s1 = create(&pool, user.id, "active-1", None).await.unwrap(); - let s2 = create(&pool, user.id, "active-2", None).await.unwrap(); + let user = setup_user(&pool).await?; + let s1 = create(&pool, user.id, "active-1", None).await?; + let s2 = create(&pool, user.id, "active-2", None).await?; - disconnect(&pool, s1.id, 0, 0, 0).await.unwrap(); + disconnect(&pool, s1.id, 0, 0, 0).await?; - let active = list_active(&pool).await.unwrap(); + let active = list_active(&pool).await?; let active_ids: Vec = active.iter().map(|s| s.id).collect(); assert!(active_ids.contains(&s2.id)); assert!(!active_ids.contains(&s1.id)); + + Ok(()) } } diff --git a/crates/funnel-server/src/db/users.rs b/crates/funnel-server/src/db/users.rs index 9466d4f..def0858 100644 --- a/crates/funnel-server/src/db/users.rs +++ b/crates/funnel-server/src/db/users.rs @@ -42,22 +42,20 @@ pub async fn find_by_provider( provider: &str, provider_id: &str, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, User>( - "SELECT * FROM users WHERE provider = $1 AND provider_id = $2", - ) - .bind(provider) - .bind(provider_id) - .fetch_optional(pool) - .await + sqlx::query_as::<_, User>("SELECT * FROM users WHERE provider = $1 AND provider_id = $2") + .bind(provider) + .bind(provider_id) + .fetch_optional(pool) + .await } pub async fn create(pool: &PgPool, new_user: NewUser) -> Result { sqlx::query_as::<_, User>( - r#" + r" INSERT INTO users (email, name, avatar_url, provider, provider_id) VALUES ($1, $2, $3, $4, $5) RETURNING * - "#, + ", ) .bind(&new_user.email) .bind(&new_user.name) @@ -72,7 +70,7 @@ pub async fn create(pool: &PgPool, new_user: NewUser) -> Result Result { sqlx::query_as::<_, User>( - r#" + r" INSERT INTO users (email, name, avatar_url, provider, provider_id) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (provider, provider_id) @@ -82,7 +80,7 @@ pub async fn upsert_from_oauth(pool: &PgPool, new_user: NewUser) -> Result Result>; + fn test_new_user() -> NewUser { NewUser { email: format!("test-{}@example.com", Uuid::now_v7()), @@ -110,40 +110,38 @@ mod tests { // run with: DATABASE_URL=postgres://... cargo test -- --ignored #[tokio::test] #[ignore = "requires database"] - async fn create_and_find_user() { - let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) - .await - .unwrap(); + async fn create_and_find_user() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; let new = test_new_user(); let email = new.email.clone(); - let created = create(&pool, new).await.unwrap(); + let created = create(&pool, new).await?; assert_eq!(created.email, email); assert!(created.id != Uuid::nil()); - let found = find_by_id(&pool, created.id).await.unwrap(); + let found = find_by_id(&pool, created.id).await?; assert!(found.is_some()); - assert_eq!(found.unwrap().email, email); + assert_eq!(found.ok_or("expected Some")?.email, email); + + Ok(()) } #[tokio::test] #[ignore = "requires database"] - async fn find_nonexistent_user() { - let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) - .await - .unwrap(); + async fn find_nonexistent_user() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - let found = find_by_id(&pool, Uuid::now_v7()).await.unwrap(); + let found = find_by_id(&pool, Uuid::now_v7()).await?; assert!(found.is_none()); + + Ok(()) } #[tokio::test] #[ignore = "requires database"] - async fn upsert_creates_then_updates() { - let pool = PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) - .await - .unwrap(); + async fn upsert_creates_then_updates() -> TestResult { + let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; let provider_id = Uuid::now_v7().to_string(); @@ -157,8 +155,7 @@ mod tests { provider_id: provider_id.clone(), }, ) - .await - .unwrap(); + .await?; let second = upsert_from_oauth( &pool, @@ -170,11 +167,12 @@ mod tests { provider_id: provider_id.clone(), }, ) - .await - .unwrap(); + .await?; assert_eq!(first.id, second.id); assert_eq!(second.name.as_deref(), Some("Updated")); assert!(second.avatar_url.is_some()); + + Ok(()) } } diff --git a/crates/funnel-server/src/error.rs b/crates/funnel-server/src/error.rs index aec36de..eeca712 100644 --- a/crates/funnel-server/src/error.rs +++ b/crates/funnel-server/src/error.rs @@ -1,6 +1,6 @@ +use axum::Json; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use axum::Json; use funnel_core::tunnel::id::TunnelIdError; use serde::Serialize; @@ -33,10 +33,12 @@ pub enum AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { - Self::TunnelNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), - Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), - Self::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), - Self::InvalidTunnelId(_) => (StatusCode::BAD_REQUEST, self.to_string()), + Self::TunnelNotFound(_) | Self::NotFound(_) => { + (StatusCode::NOT_FOUND, self.to_string()) + } + Self::BadRequest(_) | Self::InvalidTunnelId(_) => { + (StatusCode::BAD_REQUEST, self.to_string()) + } Self::Database(e) => { tracing::error!(error = %e, "database error"); ( diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index 2713f80..a6766e3 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -14,9 +14,9 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; +use axum::Router; use axum::extract::Request; use axum::response::Redirect; -use axum::Router; use clap::Parser; use sqlx::postgres::PgPoolOptions; use tracing_subscriber::EnvFilter; @@ -24,7 +24,7 @@ use tracing_subscriber::EnvFilter; use funnel_core::protocol::QUIC_ALPN; const QUIC_KEEP_ALIVE: Duration = Duration::from_secs(15); -const QUIC_IDLE_TIMEOUT: Duration = Duration::from_secs(60); +const QUIC_IDLE_TIMEOUT: Duration = Duration::from_mins(1); #[derive(Parser)] #[command(name = "funnel-server", about = "Funnel tunnel server")] @@ -78,7 +78,7 @@ struct Cli { async fn main() -> anyhow::Result<()> { rustls::crypto::ring::default_provider() .install_default() - .expect("failed to install default crypto provider"); + .map_err(|_| anyhow::anyhow!("failed to install default crypto provider"))?; tracing_subscriber::fmt() .with_env_filter( @@ -99,9 +99,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("connected to database"); - sqlx::migrate!("../../migrations") - .run(&pool) - .await?; + sqlx::migrate!("../../migrations").run(&pool).await?; tracing::info!("database migrations applied"); Some(pool) @@ -118,7 +116,7 @@ async fn main() -> anyhow::Result<()> { } async fn run_plain(cli: Cli, pool: Option) -> anyhow::Result<()> { - let metrics_handle = metrics::setup(); + let metrics_handle = metrics::setup()?; let state = Arc::new(app::AppState::new(pool, false)); let router = app::build_router(Arc::clone(&state), metrics_handle); @@ -129,8 +127,10 @@ async fn run_plain(cli: Cli, pool: Option) -> anyhow::Result<()> { tracing::info!(addr = %addr, "http server listening"); - let http_server = - axum::serve(listener, router.into_make_service_with_connect_info::()); + let http_server = axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ); tokio::select! { res = http_server => { res?; } @@ -150,11 +150,15 @@ async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<() .as_deref() .ok_or_else(|| anyhow::anyhow!("--dns-providers-config required when TLS is enabled"))?; - let rustls_config = - tls::setup(Path::new(&cli.cert_dir), Path::new(config_path), email, cli.acme_staging) - .await?; + let rustls_config = tls::setup( + Path::new(&cli.cert_dir), + Path::new(config_path), + email, + cli.acme_staging, + ) + .await?; - let metrics_handle = metrics::setup(); + let metrics_handle = metrics::setup()?; let state = Arc::new(app::AppState::new(pool, true)); let router = app::build_router(Arc::clone(&state), metrics_handle); @@ -216,9 +220,7 @@ fn build_self_signed_quic_config() -> anyhow::Result { let mut transport = quinn::TransportConfig::default(); transport.keep_alive_interval(Some(QUIC_KEEP_ALIVE)); - transport.max_idle_timeout(Some( - quinn::IdleTimeout::try_from(QUIC_IDLE_TIMEOUT)?, - )); + transport.max_idle_timeout(Some(quinn::IdleTimeout::try_from(QUIC_IDLE_TIMEOUT)?)); server_config.transport_config(Arc::new(transport)); @@ -262,8 +264,7 @@ fn https_redirect_router(tls_port: u16) -> Router { let path = request .uri() .path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or("/"); + .map_or("/", axum::http::uri::PathAndQuery::as_str); let location = if tls_port == 443 { format!("https://{host_without_port}{path}") diff --git a/crates/funnel-server/src/metrics.rs b/crates/funnel-server/src/metrics.rs index 3ea125a..19ec765 100644 --- a/crates/funnel-server/src/metrics.rs +++ b/crates/funnel-server/src/metrics.rs @@ -2,11 +2,9 @@ use axum::http::StatusCode; use axum::response::IntoResponse; use metrics_exporter_prometheus::PrometheusHandle; -pub fn setup() -> PrometheusHandle { +pub fn setup() -> anyhow::Result { let builder = metrics_exporter_prometheus::PrometheusBuilder::new(); - builder - .install_recorder() - .expect("failed to install prometheus recorder") + Ok(builder.install_recorder()?) } pub async fn handler( diff --git a/crates/funnel-server/src/proxy/headers.rs b/crates/funnel-server/src/proxy/headers.rs index 5c54f9d..8687dcd 100644 --- a/crates/funnel-server/src/proxy/headers.rs +++ b/crates/funnel-server/src/proxy/headers.rs @@ -12,7 +12,7 @@ pub fn prepare_forwarding_headers( ) -> HashMap> { let mut headers: HashMap> = HashMap::new(); - for (name, value) in original.iter() { + for (name, value) in original { let key = name.as_str().to_string(); let val = value.to_str().unwrap_or("").to_string(); headers.entry(key).or_default().push(val); @@ -55,7 +55,8 @@ mod tests { #[test] fn adds_forwarding_headers() { let headers = HeaderMap::new(); - let result = prepare_forwarding_headers(&headers, "my-tunnel.example.com", test_addr(), false); + let result = + prepare_forwarding_headers(&headers, "my-tunnel.example.com", test_addr(), false); assert_eq!(result["x-forwarded-for"], vec!["192.168.1.100"]); assert_eq!(result["x-forwarded-host"], vec!["my-tunnel.example.com"]); diff --git a/crates/funnel-server/src/proxy/router.rs b/crates/funnel-server/src/proxy/router.rs index 3d7a7cc..e77a062 100644 --- a/crates/funnel-server/src/proxy/router.rs +++ b/crates/funnel-server/src/proxy/router.rs @@ -8,9 +8,9 @@ use tokio_util::io::ReaderStream; use funnel_core::protocol::request::{RequestMeta, ResponseMeta}; use funnel_core::tunnel::id::TunnelId; +use super::headers::prepare_forwarding_headers; use crate::app::AppState; use crate::tunnel::connection::{CountedRecvStream, SendError}; -use super::headers::prepare_forwarding_headers; /// axum fallback handler that routes requests based on subdomain. /// requests to `{tunnel_id}.{base_domain}` are forwarded through the matching tunnel. @@ -29,14 +29,12 @@ pub async fn handle_tunnel_request( None => return not_found("tunnel not found"), }; - let tunnel_id = match TunnelId::new(&subdomain) { - Ok(id) => id, - Err(_) => return not_found("tunnel not found"), + let Ok(tunnel_id) = TunnelId::new(&subdomain) else { + return not_found("tunnel not found"); }; - let tunnel = match state.tunnels.get(&tunnel_id) { - Some(t) => t, - None => return not_found("tunnel not found"), + let Some(tunnel) = state.tunnels.get(&tunnel_id) else { + return not_found("tunnel not found"); }; let method = request.method().to_string(); @@ -46,8 +44,7 @@ pub async fn handle_tunnel_request( let remote_addr = request .extensions() .get::>() - .map(|ci| ci.0) - .unwrap_or(fallback_addr); + .map_or(fallback_addr, |ci| ci.0); let headers = prepare_forwarding_headers(request.headers(), host, remote_addr, state.is_tls); @@ -60,10 +57,8 @@ pub async fn handle_tunnel_request( let body = request.into_body(); match tunnel.send_request(meta, body).await { - Ok((resp_meta, recv_stream)) => build_response(resp_meta, recv_stream), - Err(SendError::Timeout) => { - error_response(StatusCode::GATEWAY_TIMEOUT, "request timed out") - } + Ok((resp_meta, recv_stream)) => build_response(&resp_meta, recv_stream), + Err(SendError::Timeout) => error_response(StatusCode::GATEWAY_TIMEOUT, "request timed out"), Err(SendError::ReadBody(e)) => { tracing::debug!(error = %e, "failed to read request body"); error_response(StatusCode::BAD_REQUEST, "failed to read request body") @@ -93,7 +88,7 @@ fn extract_subdomain(host: &str) -> Option<&str> { } } -fn build_response(meta: ResponseMeta, recv: CountedRecvStream) -> Response { +fn build_response(meta: &ResponseMeta, recv: CountedRecvStream) -> Response { let mut builder = Response::builder().status(meta.status); if let Some(headers) = builder.headers_mut() { @@ -126,7 +121,7 @@ fn error_response(status: StatusCode, msg: &str) -> Response { .status(status) .header("content-type", "text/plain") .body(Body::from(msg.to_string())) - .expect("hardcoded error response must be valid") + .unwrap_or_else(|_| Response::new(Body::from(msg.to_string()))) } #[cfg(test)] @@ -135,13 +130,19 @@ mod tests { #[test] fn extract_subdomain_basic() { - assert_eq!(extract_subdomain("my-tunnel.example.com"), Some("my-tunnel")); + assert_eq!( + extract_subdomain("my-tunnel.example.com"), + Some("my-tunnel") + ); assert_eq!(extract_subdomain("abc.example.com"), Some("abc")); } #[test] fn extract_subdomain_with_port() { - assert_eq!(extract_subdomain("my-tunnel.example.com:8080"), Some("my-tunnel")); + assert_eq!( + extract_subdomain("my-tunnel.example.com:8080"), + Some("my-tunnel") + ); } #[test] diff --git a/crates/funnel-server/src/quic/handler.rs b/crates/funnel-server/src/quic/handler.rs index 85f2c6b..e4a1a71 100644 --- a/crates/funnel-server/src/quic/handler.rs +++ b/crates/funnel-server/src/quic/handler.rs @@ -1,9 +1,9 @@ use std::sync::Arc; -use funnel_core::protocol::frame; -use funnel_core::protocol::handshake::{Handshake, HandshakeResponse}; use crate::app::AppState; use crate::tunnel::connection::ActiveTunnel; +use funnel_core::protocol::frame; +use funnel_core::protocol::handshake::{Handshake, HandshakeResponse}; #[derive(Debug, thiserror::Error)] pub enum ConnectionError { diff --git a/crates/funnel-server/src/tls/config.rs b/crates/funnel-server/src/tls/config.rs index 5ba8614..b0f6574 100644 --- a/crates/funnel-server/src/tls/config.rs +++ b/crates/funnel-server/src/tls/config.rs @@ -29,7 +29,7 @@ pub struct DnsProviderEntry { impl ProviderConfig { pub async fn load(path: &Path) -> anyhow::Result { let contents = tokio::fs::read_to_string(path).await?; - let config: ProviderConfig = serde_json::from_str(&contents)?; + let config: Self = serde_json::from_str(&contents)?; Ok(config) } } diff --git a/crates/funnel-server/src/tls/manager.rs b/crates/funnel-server/src/tls/manager.rs index 345fb54..6fb4c9d 100644 --- a/crates/funnel-server/src/tls/manager.rs +++ b/crates/funnel-server/src/tls/manager.rs @@ -16,9 +16,9 @@ use x509_parser::prelude::*; use super::provider::ProviderMux; const LRU_CACHE_SIZE: std::num::NonZeroUsize = std::num::NonZeroUsize::new(512).unwrap(); -const RENEWAL_CHECK_INTERVAL: Duration = Duration::from_secs(3600); -const RENEWAL_WINDOW: Duration = Duration::from_secs(30 * 24 * 3600); -const ACME_POLL_TIMEOUT: Duration = Duration::from_secs(300); +const RENEWAL_CHECK_INTERVAL: Duration = Duration::from_hours(1); +const RENEWAL_WINDOW: Duration = Duration::from_hours(720); +const ACME_POLL_TIMEOUT: Duration = Duration::from_mins(5); const DNS_PROPAGATION_DELAY: Duration = Duration::from_secs(10); struct CachedCert { @@ -70,7 +70,10 @@ impl CertificateManager { match self.load_from_disk(&domain).await { Ok(Some((certified_key, not_after))) => { tracing::info!(domain = %domain, "loaded certificate from disk"); - let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + let mut cache = self + .cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); cache.put( domain, CachedCert { @@ -101,11 +104,14 @@ impl CertificateManager { // double check cache after acquiring lock { - let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(cached) = cache.peek(domain) { - if cached.not_after > SystemTime::now() + RENEWAL_WINDOW { - return Ok(()); - } + let cache = self + .cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(cached) = cache.peek(domain) + && cached.not_after > SystemTime::now() + RENEWAL_WINDOW + { + return Ok(()); } } @@ -199,7 +205,10 @@ impl CertificateManager { let not_after = parse_cert_expiry(&cert_pem)?; { - let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + let mut cache = self + .cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); cache.put( domain.to_string(), CachedCert { @@ -213,7 +222,10 @@ impl CertificateManager { // clean up DNS records (best effort, after everything succeeded) for cleanup in &cleanups { - if let Err(e) = provider.cleanup(&cleanup.record_name, &cleanup.dns_value).await { + if let Err(e) = provider + .cleanup(&cleanup.record_name, &cleanup.dns_value) + .await + { tracing::warn!( record = %cleanup.record_name, error = %e, @@ -255,7 +267,10 @@ impl CertificateManager { async fn check_renewals(&self) { let domains_to_renew: Vec = { - let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + let cache = self + .cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); cache .iter() .filter(|(_, cert)| { @@ -278,16 +293,19 @@ impl CertificateManager { } impl ResolvesServerCert for CertificateManager { - fn resolve( - &self, - client_hello: rustls::server::ClientHello<'_>, - ) -> Option> { + fn resolve(&self, client_hello: rustls::server::ClientHello<'_>) -> Option> { let sni = client_hello.server_name()?; - let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + let cache = self + .cache + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let cached = cache.peek(sni)?; + let not_after = cached.not_after; + let key = Arc::clone(&cached.certified_key); + drop(cache); - if cached.not_after > SystemTime::now() { - Some(Arc::clone(&cached.certified_key)) + if not_after > SystemTime::now() { + Some(key) } else { None } @@ -315,18 +333,13 @@ fn parse_cert_expiry(cert_pem: &str) -> Result { .collect::, _>>()?; let cert = certs.first().context("no certificate found")?; let (_, parsed) = X509Certificate::from_der(cert.as_ref()) - .map_err(|e| anyhow::anyhow!(e.to_string())) - .context("failed to parse x509 certificate")?; + .map_err(|e| anyhow::anyhow!("failed to parse x509 certificate: {e}"))?; let not_after = parsed.validity().not_after; Ok(not_after.to_datetime().into()) } -async fn load_or_create_account( - cert_dir: &Path, - email: &str, - staging: bool, -) -> Result { +async fn load_or_create_account(cert_dir: &Path, email: &str, staging: bool) -> Result { let account_json_path = cert_dir.join("account.json"); if account_json_path.exists() { diff --git a/crates/funnel-server/src/tls/mod.rs b/crates/funnel-server/src/tls/mod.rs index 154bd5f..6bcb25f 100644 --- a/crates/funnel-server/src/tls/mod.rs +++ b/crates/funnel-server/src/tls/mod.rs @@ -17,14 +17,9 @@ pub async fn setup( let provider_mux = provider::ProviderMux::from_config(&provider_config).await?; let cert_manager = Arc::new( - manager::CertificateManager::new( - cert_dir.to_path_buf(), - provider_mux, - email, - acme_staging, - ) - .await - .context("failed to create certificate manager")?, + manager::CertificateManager::new(cert_dir.to_path_buf(), provider_mux, email, acme_staging) + .await + .context("failed to create certificate manager")?, ); cert_manager.preload_certificates().await?; diff --git a/crates/funnel-server/src/tls/provider/cloudflare.rs b/crates/funnel-server/src/tls/provider/cloudflare.rs index 36812ed..e2f5633 100644 --- a/crates/funnel-server/src/tls/provider/cloudflare.rs +++ b/crates/funnel-server/src/tls/provider/cloudflare.rs @@ -85,7 +85,9 @@ impl CloudflareProvider { for record_id in matching_records { self.client - .delete(format!("{API_BASE}/zones/{zone_id}/dns_records/{record_id}")) + .delete(format!( + "{API_BASE}/zones/{zone_id}/dns_records/{record_id}" + )) .bearer_auth(&self.api_token) .send() .await?; @@ -100,8 +102,7 @@ impl DnsChallenger for CloudflareProvider { let value = value.to_string(); Box::pin(async move { let zone_id = self.find_zone_id(&record_name).await?; - self.create_txt_record(&zone_id, &record_name, &value) - .await + self.create_txt_record(&zone_id, &record_name, &value).await }) } diff --git a/crates/funnel-server/src/tls/provider/exec.rs b/crates/funnel-server/src/tls/provider/exec.rs index 89c0854..42a5067 100644 --- a/crates/funnel-server/src/tls/provider/exec.rs +++ b/crates/funnel-server/src/tls/provider/exec.rs @@ -7,7 +7,7 @@ pub struct ExecProvider { } impl ExecProvider { - pub fn new(command: String) -> Self { + pub const fn new(command: String) -> Self { Self { command } } @@ -20,11 +20,7 @@ impl ExecProvider { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!( - "command exited with {}: {}", - output.status, - stderr.trim() - ); + anyhow::bail!("command exited with {}: {}", output.status, stderr.trim()); } Ok(()) } diff --git a/crates/funnel-server/src/tunnel/connection.rs b/crates/funnel-server/src/tunnel/connection.rs index 9cecef4..03e2abc 100644 --- a/crates/funnel-server/src/tunnel/connection.rs +++ b/crates/funnel-server/src/tunnel/connection.rs @@ -31,7 +31,7 @@ impl ActiveTunnel { } } - pub fn id(&self) -> &TunnelId { + pub const fn id(&self) -> &TunnelId { &self.id } @@ -39,7 +39,7 @@ impl ActiveTunnel { self.stats.snapshot() } - pub fn connected_at(&self) -> tokio::time::Instant { + pub const fn connected_at(&self) -> tokio::time::Instant { self.connected_at } @@ -55,11 +55,7 @@ impl ActiveTunnel { let start = std::time::Instant::now(); let result = tokio::time::timeout(REQUEST_TIMEOUT, async { - let (mut send, mut recv) = self - .conn - .open_bi() - .await - .map_err(SendError::OpenStream)?; + let (mut send, mut recv) = self.conn.open_bi().await.map_err(SendError::OpenStream)?; frame::write_meta(&mut send, &meta) .await @@ -71,9 +67,7 @@ impl ActiveTunnel { let frame = frame.map_err(SendError::ReadBody)?; if let Ok(data) = frame.into_data() { bytes_sent += data.len() as u64; - send.write_all(&data) - .await - .map_err(SendError::WriteBody)?; + send.write_all(&data).await.map_err(SendError::WriteBody)?; } } @@ -101,7 +95,8 @@ impl ActiveTunnel { Ok(response) } Ok(Err(e)) => { - metrics::counter!("funnel_requests_total", "outcome" => e.outcome_label()).increment(1); + metrics::counter!("funnel_requests_total", "outcome" => e.outcome_label()) + .increment(1); Err(e) } Err(_) => { @@ -142,7 +137,7 @@ pub enum SendError { } impl SendError { - pub fn outcome_label(&self) -> &'static str { + pub const fn outcome_label(&self) -> &'static str { match self { Self::OpenStream(_) => "stream_open_failed", Self::SendMeta(_) => "send_meta_failed", @@ -153,7 +148,6 @@ impl SendError { Self::Timeout => "timeout", } } - } /// wraps a quinn::RecvStream and tracks bytes read for metrics and per tunnel stats. @@ -165,7 +159,7 @@ pub struct CountedRecvStream { } impl CountedRecvStream { - fn new(inner: quinn::RecvStream, stats: Arc) -> Self { + const fn new(inner: quinn::RecvStream, stats: Arc) -> Self { Self { inner, bytes_read: 0, @@ -210,5 +204,4 @@ mod tests { fn send_error_outcome_labels() { assert_eq!(SendError::Timeout.outcome_label(), "timeout"); } - } diff --git a/crates/funnel-server/src/tunnel/manager.rs b/crates/funnel-server/src/tunnel/manager.rs index 8fc05d5..c630d64 100644 --- a/crates/funnel-server/src/tunnel/manager.rs +++ b/crates/funnel-server/src/tunnel/manager.rs @@ -75,17 +75,21 @@ mod tests { assert!(mgr.list().is_empty()); } + type TestResult = Result<(), Box>; + #[test] - fn get_returns_none_for_missing() { + fn get_returns_none_for_missing() -> TestResult { let mgr = TunnelManager::new(); - let id = TunnelId::new("test-abc").unwrap(); + let id = TunnelId::new("test-abc")?; assert!(mgr.get(&id).is_none()); + Ok(()) } #[test] - fn remove_returns_none_for_missing() { + fn remove_returns_none_for_missing() -> TestResult { let mgr = TunnelManager::new(); - let id = TunnelId::new("test-abc").unwrap(); + let id = TunnelId::new("test-abc")?; assert!(mgr.remove(&id).is_none()); + Ok(()) } } diff --git a/crates/funnel-server/src/tunnel/stats.rs b/crates/funnel-server/src/tunnel/stats.rs index d90738f..26f16af 100644 --- a/crates/funnel-server/src/tunnel/stats.rs +++ b/crates/funnel-server/src/tunnel/stats.rs @@ -16,7 +16,7 @@ pub struct TunnelStatsSnapshot { } impl TunnelStats { - pub fn new() -> Self { + pub const fn new() -> Self { Self { bytes_in: AtomicU64::new(0), bytes_out: AtomicU64::new(0), diff --git a/crates/funnel-web/Cargo.toml b/crates/funnel-web/Cargo.toml index 3b7b773..6945d8e 100644 --- a/crates/funnel-web/Cargo.toml +++ b/crates/funnel-web/Cargo.toml @@ -3,6 +3,9 @@ name = "funnel-web" version = "0.1.0" edition.workspace = true +[lints] +workspace = true + [dependencies] funnel-core.workspace = true diff --git a/justfile b/justfile new file mode 100644 index 0000000..551ab5f --- /dev/null +++ b/justfile @@ -0,0 +1,49 @@ +default: + @just --list + +# build all crates +build: + cargo build --workspace + +# build in release mode +release: + cargo build --workspace --release + +# run all unit tests with nextest +test *args: + cargo nextest run --workspace {{ args }} + +# run e2e tests (builds server + client first) +e2e *args: + cargo build -p funnel-server -p funnel-client + cargo test -p funnel-e2e {{ args }} + +# run clippy and check formatting +lint: + cargo clippy --workspace --all-targets -- -D warnings + cargo fmt --all -- --check + +# format all code +fmt: + cargo fmt --all + +# watch for changes and rebuild (requires cargo-watch) +dev: + bacon + +# run clippy with auto fix +fix: + cargo clippy --workspace --all-targets --fix --allow-dirty + cargo fmt --all + +# check that everything compiles without building +check: + cargo check --workspace --all-targets + +# audit dependencies for security vulnerabilities +audit: + cargo audit + +# find unused dependencies +machete: + cargo machete From 3aec4d9d40dc480b3ef9a431c82738efc692d973 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Fri, 8 May 2026 19:25:40 +0200 Subject: [PATCH 21/42] refactor(server): introduce store abstraction layer and extract tls/quic submodules --- Cargo.lock | 1 + crates/funnel-core/Cargo.toml | 1 + crates/funnel-core/src/protocol/request.rs | 105 +++++++ crates/funnel-server/src/api/health.rs | 4 +- crates/funnel-server/src/api/keys.rs | 15 +- crates/funnel-server/src/app.rs | 36 +-- crates/funnel-server/src/db/mod.rs | 1 - crates/funnel-server/src/error.rs | 36 ++- crates/funnel-server/src/main.rs | 144 +++------ crates/funnel-server/src/proxy/headers.rs | 17 +- crates/funnel-server/src/proxy/router.rs | 16 +- crates/funnel-server/src/quic/config.rs | 78 +++++ crates/funnel-server/src/quic/mod.rs | 1 + .../funnel-server/src/store/api_key_store.rs | 12 + crates/funnel-server/src/store/health.rs | 28 ++ .../src/store/memory/api_key_store.rs | 182 ++++++++++++ crates/funnel-server/src/store/memory/mod.rs | 3 + .../src/store/memory/session_recorder.rs | 196 +++++++++++++ .../src/store/memory/user_store.rs | 178 ++++++++++++ crates/funnel-server/src/store/mod.rs | 50 ++++ .../src/store/pg/api_key_store.rs | 46 +++ crates/funnel-server/src/store/pg/mod.rs | 3 + .../src/store/pg/session_recorder.rs | 59 ++++ .../funnel-server/src/store/pg/user_store.rs | 51 ++++ .../src/store/session_recorder.rs | 30 ++ .../src/store/tunnel_registry.rs | 14 + crates/funnel-server/src/store/user_store.rs | 12 + crates/funnel-server/src/tls/acme.rs | 181 ++++++++++++ crates/funnel-server/src/tls/cache.rs | 70 +++++ crates/funnel-server/src/tls/manager.rs | 274 ++---------------- crates/funnel-server/src/tls/mod.rs | 3 + crates/funnel-server/src/tls/redirect.rs | 25 ++ crates/funnel-server/src/tunnel/manager.rs | 23 ++ 33 files changed, 1487 insertions(+), 408 deletions(-) create mode 100644 crates/funnel-server/src/quic/config.rs create mode 100644 crates/funnel-server/src/store/api_key_store.rs create mode 100644 crates/funnel-server/src/store/health.rs create mode 100644 crates/funnel-server/src/store/memory/api_key_store.rs create mode 100644 crates/funnel-server/src/store/memory/mod.rs create mode 100644 crates/funnel-server/src/store/memory/session_recorder.rs create mode 100644 crates/funnel-server/src/store/memory/user_store.rs create mode 100644 crates/funnel-server/src/store/mod.rs create mode 100644 crates/funnel-server/src/store/pg/api_key_store.rs create mode 100644 crates/funnel-server/src/store/pg/mod.rs create mode 100644 crates/funnel-server/src/store/pg/session_recorder.rs create mode 100644 crates/funnel-server/src/store/pg/user_store.rs create mode 100644 crates/funnel-server/src/store/session_recorder.rs create mode 100644 crates/funnel-server/src/store/tunnel_registry.rs create mode 100644 crates/funnel-server/src/store/user_store.rs create mode 100644 crates/funnel-server/src/tls/acme.rs create mode 100644 crates/funnel-server/src/tls/cache.rs create mode 100644 crates/funnel-server/src/tls/redirect.rs diff --git a/Cargo.lock b/Cargo.lock index af833aa..87d8ab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1527,6 +1527,7 @@ dependencies = [ "base64", "getrandom 0.3.4", "hex", + "http 1.4.0", "nanoid", "pretty_assertions", "rmp-serde", diff --git a/crates/funnel-core/Cargo.toml b/crates/funnel-core/Cargo.toml index b7edeea..9d4ce22 100644 --- a/crates/funnel-core/Cargo.toml +++ b/crates/funnel-core/Cargo.toml @@ -10,6 +10,7 @@ workspace = true base64 = "0.22" getrandom = "0.3" hex = "0.4" +http.workspace = true nanoid = "0.4" rmp-serde.workspace = true serde.workspace = true diff --git a/crates/funnel-core/src/protocol/request.rs b/crates/funnel-core/src/protocol/request.rs index 019e079..7ebc1d5 100644 --- a/crates/funnel-core/src/protocol/request.rs +++ b/crates/funnel-core/src/protocol/request.rs @@ -1,7 +1,17 @@ use std::collections::HashMap; +use std::hash::BuildHasher; use serde::{Deserialize, Serialize}; +#[derive(Debug, thiserror::Error)] +pub enum ProtocolTypeError { + #[error("invalid http method: {0}")] + InvalidMethod(String), + + #[error("invalid http status code: {0}")] + InvalidStatus(u16), +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RequestMeta { pub method: String, @@ -9,12 +19,45 @@ pub struct RequestMeta { pub headers: HashMap>, } +impl RequestMeta { + pub fn http_method(&self) -> Result { + self.method + .parse() + .map_err(|_| ProtocolTypeError::InvalidMethod(self.method.clone())) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResponseMeta { pub status: u16, pub headers: HashMap>, } +impl ResponseMeta { + pub fn http_status(&self) -> Result { + http::StatusCode::from_u16(self.status) + .map_err(|_| ProtocolTypeError::InvalidStatus(self.status)) + } +} + +/// convert a protocol header map into an http `HeaderMap`, skipping invalid entries. +pub fn to_header_map( + headers: &HashMap, S>, +) -> http::HeaderMap { + let mut map = http::HeaderMap::new(); + for (name, values) in headers { + for value in values { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::try_from(name.as_str()), + http::HeaderValue::from_str(value), + ) { + map.append(name, value); + } + } + } + map +} + #[cfg(test)] mod tests { use super::*; @@ -52,4 +95,66 @@ mod tests { assert_eq!(decoded.status, 200); Ok(()) } + + #[test] + fn valid_http_method() { + let meta = RequestMeta { + method: "GET".into(), + path: "/".into(), + headers: HashMap::new(), + }; + assert_eq!(meta.http_method().ok(), Some(http::Method::GET)); + } + + #[test] + fn invalid_http_method() { + let meta = RequestMeta { + method: "".into(), + path: "/".into(), + headers: HashMap::new(), + }; + assert!(meta.http_method().is_err()); + } + + #[test] + fn valid_http_status() { + let meta = ResponseMeta { + status: 404, + headers: HashMap::new(), + }; + assert_eq!( + meta.http_status().ok(), + Some(http::StatusCode::NOT_FOUND) + ); + } + + #[test] + fn invalid_http_status() { + let meta = ResponseMeta { + status: 9999, + headers: HashMap::new(), + }; + assert!(meta.http_status().is_err()); + } + + #[test] + fn to_header_map_converts_valid_entries() { + let mut headers = HashMap::new(); + headers.insert("content-type".into(), vec!["text/html".into()]); + headers.insert("x-custom".into(), vec!["a".into(), "b".into()]); + + let map = to_header_map(&headers); + assert_eq!(map.get("content-type").and_then(|v| v.to_str().ok()), Some("text/html")); + assert_eq!(map.get_all("x-custom").iter().count(), 2); + } + + #[test] + fn to_header_map_skips_invalid() { + let mut headers = HashMap::new(); + headers.insert("valid".into(), vec!["ok".into()]); + headers.insert("".into(), vec!["bad name".into()]); + + let map = to_header_map(&headers); + assert_eq!(map.len(), 1); + } } diff --git a/crates/funnel-server/src/api/health.rs b/crates/funnel-server/src/api/health.rs index 9d50e27..b6b7940 100644 --- a/crates/funnel-server/src/api/health.rs +++ b/crates/funnel-server/src/api/health.rs @@ -14,7 +14,7 @@ pub struct HealthResponse { pub async fn handler(State(state): State>) -> Json { Json(HealthResponse { - status: "healthy", - uptime_secs: state.start_time.elapsed().as_secs(), + status: state.health.status(), + uptime_secs: state.health.uptime_secs(), }) } diff --git a/crates/funnel-server/src/api/keys.rs b/crates/funnel-server/src/api/keys.rs index a0f43dd..2127fc1 100644 --- a/crates/funnel-server/src/api/keys.rs +++ b/crates/funnel-server/src/api/keys.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::app::AppState; -use crate::db::api_keys::{self, ApiKeyView}; +use crate::db::api_keys::ApiKeyView; use crate::error::AppError; #[derive(Deserialize)] @@ -25,8 +25,7 @@ pub struct CreateKeyResponse { /// List all active API keys for a user. pub async fn list(State(state): State>) -> Result>, AppError> { // TODO: get user_id from auth middleware - // For now, return empty - let _pool = state.require_db()?; + let _keys = state.api_keys.list_for_user(Uuid::nil()).await?; Ok(Json(vec![])) } @@ -35,12 +34,11 @@ pub async fn create( State(state): State>, Json(req): Json, ) -> Result, AppError> { - let db = state.require_db()?; - let (plaintext, key) = api_keys::create(db, req.user_id, &req.name).await?; + let (plaintext, info) = state.api_keys.create(req.user_id, &req.name).await?; Ok(Json(CreateKeyResponse { key: plaintext, - info: key.into(), + info, })) } @@ -50,10 +48,9 @@ pub async fn revoke( Path(id): Path, ) -> Result, AppError> { // TODO: get user_id from auth middleware - let user_id = Uuid::nil(); // placeholder + let user_id = Uuid::nil(); - let db = state.require_db()?; - let revoked = api_keys::revoke(db, id, user_id).await?; + let revoked = state.api_keys.revoke(id, user_id).await?; if !revoked { return Err(AppError::NotFound("api key not found".into())); } diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs index 6845bd0..0b47b15 100644 --- a/crates/funnel-server/src/app.rs +++ b/crates/funnel-server/src/app.rs @@ -3,41 +3,29 @@ use std::sync::Arc; use axum::Router; use axum::routing::get; use metrics_exporter_prometheus::PrometheusHandle; -use sqlx::PgPool; -use tokio::time::Instant; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use crate::api; -use crate::error::AppError; use crate::metrics; use crate::proxy; -use crate::tunnel::manager::TunnelManager; +use crate::store::api_key_store::ApiKeyStore; +use crate::store::health::HealthReporter; +use crate::store::session_recorder::SessionRecorder; +use crate::store::tunnel_registry::TunnelRegistry; +use crate::store::user_store::UserStore; pub struct AppState { - pub db: Option, - pub tunnels: TunnelManager, - pub start_time: Instant, + pub tunnels: Arc, + pub api_keys: Arc, + #[allow(dead_code)] + pub users: Arc, + #[allow(dead_code)] + pub sessions: Arc, + pub health: Arc, pub is_tls: bool, } -impl AppState { - pub fn new(db: Option, is_tls: bool) -> Self { - Self { - db, - tunnels: TunnelManager::new(), - start_time: Instant::now(), - is_tls, - } - } - - pub fn require_db(&self) -> Result<&PgPool, AppError> { - self.db - .as_ref() - .ok_or_else(|| AppError::BadRequest("database not configured".into())) - } -} - pub fn build_router(state: Arc, metrics_handle: PrometheusHandle) -> Router { let api_routes = Router::new() .route("/health", get(api::health::handler)) diff --git a/crates/funnel-server/src/db/mod.rs b/crates/funnel-server/src/db/mod.rs index 6847d25..26e4ab3 100644 --- a/crates/funnel-server/src/db/mod.rs +++ b/crates/funnel-server/src/db/mod.rs @@ -1,4 +1,3 @@ -#[allow(dead_code)] pub mod api_keys; #[allow(dead_code)] pub mod tunnel_sessions; diff --git a/crates/funnel-server/src/error.rs b/crates/funnel-server/src/error.rs index eeca712..36ee8cd 100644 --- a/crates/funnel-server/src/error.rs +++ b/crates/funnel-server/src/error.rs @@ -4,6 +4,8 @@ use axum::response::{IntoResponse, Response}; use funnel_core::tunnel::id::TunnelIdError; use serde::Serialize; +use crate::store::StoreError; + #[derive(Debug, Serialize)] struct ApiErrorBody { error: String, @@ -17,14 +19,11 @@ pub enum AppError { #[error("not found: {0}")] NotFound(String), - #[error("bad request: {0}")] - BadRequest(String), - #[error("invalid tunnel id: {0}")] InvalidTunnelId(#[from] TunnelIdError), - #[error("database error")] - Database(#[from] sqlx::Error), + #[error("store error: {0}")] + Store(#[from] StoreError), #[error("internal error: {0}")] Internal(#[from] anyhow::Error), @@ -36,16 +35,27 @@ impl IntoResponse for AppError { Self::TunnelNotFound(_) | Self::NotFound(_) => { (StatusCode::NOT_FOUND, self.to_string()) } - Self::BadRequest(_) | Self::InvalidTunnelId(_) => { + Self::InvalidTunnelId(_) => { (StatusCode::BAD_REQUEST, self.to_string()) } - Self::Database(e) => { - tracing::error!(error = %e, "database error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "internal error".to_string(), - ) - } + Self::Store(e) => match e { + StoreError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), + StoreError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()), + StoreError::Database(db_err) => { + tracing::error!(error = %db_err, "database error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + StoreError::Other(msg) => { + tracing::error!(error = %msg, "store error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + }, Self::Internal(e) => { tracing::error!(error = %e, "internal error"); ( diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index a6766e3..cbf5ae9 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -6,25 +6,22 @@ mod error; mod metrics; mod proxy; mod quic; +mod store; mod tls; mod tunnel; use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; -use std::time::Duration; -use axum::Router; -use axum::extract::Request; -use axum::response::Redirect; use clap::Parser; use sqlx::postgres::PgPoolOptions; use tracing_subscriber::EnvFilter; -use funnel_core::protocol::QUIC_ALPN; - -const QUIC_KEEP_ALIVE: Duration = Duration::from_secs(15); -const QUIC_IDLE_TIMEOUT: Duration = Duration::from_mins(1); +use store::api_key_store::ApiKeyStore; +use store::health::UptimeHealthReporter; +use store::session_recorder::SessionRecorder; +use store::user_store::UserStore; #[derive(Parser)] #[command(name = "funnel-server", about = "Funnel tunnel server")] @@ -115,12 +112,43 @@ async fn main() -> anyhow::Result<()> { } } +fn build_state(pool: Option, is_tls: bool) -> Arc { + let tunnels = Arc::new(tunnel::manager::TunnelManager::new()); + + let (api_keys, users, sessions): ( + Arc, + Arc, + Arc, + ) = if let Some(pool) = pool { + ( + Arc::new(store::pg::api_key_store::PgApiKeyStore::new(pool.clone())), + Arc::new(store::pg::user_store::PgUserStore::new(pool.clone())), + Arc::new(store::pg::session_recorder::PgSessionRecorder::new(pool)), + ) + } else { + ( + Arc::new(store::memory::api_key_store::InMemoryApiKeyStore::new()), + Arc::new(store::memory::user_store::InMemoryUserStore::new()), + Arc::new(store::memory::session_recorder::InMemorySessionRecorder::new()), + ) + }; + + Arc::new(app::AppState { + tunnels, + api_keys, + users, + sessions, + health: Arc::new(UptimeHealthReporter::new()), + is_tls, + }) +} + async fn run_plain(cli: Cli, pool: Option) -> anyhow::Result<()> { let metrics_handle = metrics::setup()?; - let state = Arc::new(app::AppState::new(pool, false)); + let state = build_state(pool, false); let router = app::build_router(Arc::clone(&state), metrics_handle); - let quic_handle = spawn_quic_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; + let quic_handle = quic::config::spawn_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; let addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?; let listener = tokio::net::TcpListener::bind(addr).await?; @@ -159,10 +187,10 @@ async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<() .await?; let metrics_handle = metrics::setup()?; - let state = Arc::new(app::AppState::new(pool, true)); + let state = build_state(pool, true); let router = app::build_router(Arc::clone(&state), metrics_handle); - let quic_handle = spawn_quic_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; + let quic_handle = quic::config::spawn_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; let tls_addr: SocketAddr = format!("{}:{}", cli.host, cli.tls_port).parse()?; let http_addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?; @@ -172,7 +200,7 @@ async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<() let https_server = axum_server::bind_rustls(tls_addr, rustls_config) .serve(router.into_make_service_with_connect_info::()); - let redirect_router = https_redirect_router(cli.tls_port); + let redirect_router = tls::redirect::router(cli.tls_port); let http_listener = tokio::net::TcpListener::bind(http_addr).await?; let http_server = axum::serve(http_listener, redirect_router.into_make_service()); @@ -184,93 +212,3 @@ async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<() Ok(()) } - -fn spawn_quic_listener( - host: &str, - port: u16, - state: Arc, -) -> anyhow::Result> { - let server_config = build_self_signed_quic_config()?; - let endpoint = build_quic_endpoint(server_config, host, port)?; - - Ok(tokio::spawn(async move { - if let Err(e) = quic::listener::run(endpoint, state).await { - tracing::error!(error = %e, "quic listener failed"); - } - })) -} - -fn build_self_signed_quic_config() -> anyhow::Result { - let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])?; - let cert_der = cert.cert.der().clone(); - let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()); - - let mut server_crypto = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert( - vec![cert_der], - rustls::pki_types::PrivateKeyDer::Pkcs8(key_der), - )?; - - server_crypto.alpn_protocols = vec![QUIC_ALPN.to_vec()]; - - let mut server_config = quinn::ServerConfig::with_crypto(Arc::new( - quinn::crypto::rustls::QuicServerConfig::try_from(server_crypto)?, - )); - - let mut transport = quinn::TransportConfig::default(); - transport.keep_alive_interval(Some(QUIC_KEEP_ALIVE)); - transport.max_idle_timeout(Some(quinn::IdleTimeout::try_from(QUIC_IDLE_TIMEOUT)?)); - - server_config.transport_config(Arc::new(transport)); - - Ok(server_config) -} - -/// build a quic endpoint, using a dual stack ipv6 socket when the host is a -/// wildcard address so that both ipv4 and ipv6 clients can connect. -fn build_quic_endpoint( - config: quinn::ServerConfig, - host: &str, - port: u16, -) -> anyhow::Result { - let addr: SocketAddr = format!("{host}:{port}").parse()?; - - // when binding to 0.0.0.0, also bind the ipv6 wildcard so dual stack works - if addr == SocketAddr::from(([0, 0, 0, 0], port)) { - let v6_addr: SocketAddr = format!("[::]:{port}").parse()?; - let socket = std::net::UdpSocket::bind(v6_addr)?; - let runtime = quinn::default_runtime() - .ok_or_else(|| anyhow::anyhow!("no async runtime available for quic endpoint"))?; - Ok(quinn::Endpoint::new( - quinn::EndpointConfig::default(), - Some(config), - socket, - runtime, - )?) - } else { - Ok(quinn::Endpoint::server(config, addr)?) - } -} - -fn https_redirect_router(tls_port: u16) -> Router { - Router::new().fallback(move |request: Request| async move { - let host = request - .headers() - .get("host") - .and_then(|v| v.to_str().ok()) - .unwrap_or("localhost"); - let host_without_port = host.split(':').next().unwrap_or(host); - let path = request - .uri() - .path_and_query() - .map_or("/", axum::http::uri::PathAndQuery::as_str); - - let location = if tls_port == 443 { - format!("https://{host_without_port}{path}") - } else { - format!("https://{host_without_port}:{tls_port}{path}") - }; - Redirect::permanent(&location) - }) -} diff --git a/crates/funnel-server/src/proxy/headers.rs b/crates/funnel-server/src/proxy/headers.rs index 8687dcd..3411d50 100644 --- a/crates/funnel-server/src/proxy/headers.rs +++ b/crates/funnel-server/src/proxy/headers.rs @@ -23,7 +23,8 @@ pub fn prepare_forwarding_headers( // append to existing X-Forwarded-For or create new match headers.get("x-forwarded-for") { Some(existing) if !existing.is_empty() => { - let combined = format!("{}, {}", existing[0], client_ip); + let all = existing.join(", "); + let combined = format!("{all}, {client_ip}"); headers.insert("x-forwarded-for".to_string(), vec![combined]); } _ => { @@ -74,6 +75,20 @@ mod tests { assert_eq!(result["x-forwarded-for"], vec!["10.0.0.1, 192.168.1.100"]); } + #[test] + fn appends_to_multi_value_forwarded_for() { + let mut headers = HeaderMap::new(); + headers.append("x-forwarded-for", HeaderValue::from_static("10.0.0.1")); + headers.append("x-forwarded-for", HeaderValue::from_static("172.16.0.1")); + + let result = prepare_forwarding_headers(&headers, "t.example.com", test_addr(), false); + + assert_eq!( + result["x-forwarded-for"], + vec!["10.0.0.1, 172.16.0.1, 192.168.1.100"] + ); + } + #[test] fn sets_https_proto_when_tls() { let headers = HeaderMap::new(); diff --git a/crates/funnel-server/src/proxy/router.rs b/crates/funnel-server/src/proxy/router.rs index e77a062..2e6cd99 100644 --- a/crates/funnel-server/src/proxy/router.rs +++ b/crates/funnel-server/src/proxy/router.rs @@ -5,7 +5,7 @@ use axum::extract::State; use axum::http::{Request, Response, StatusCode}; use tokio_util::io::ReaderStream; -use funnel_core::protocol::request::{RequestMeta, ResponseMeta}; +use funnel_core::protocol::request::{self as proto, RequestMeta, ResponseMeta}; use funnel_core::tunnel::id::TunnelId; use super::headers::prepare_forwarding_headers; @@ -89,19 +89,11 @@ fn extract_subdomain(host: &str) -> Option<&str> { } fn build_response(meta: &ResponseMeta, recv: CountedRecvStream) -> Response { - let mut builder = Response::builder().status(meta.status); + let status = meta.http_status().unwrap_or(StatusCode::BAD_GATEWAY); + let mut builder = Response::builder().status(status); if let Some(headers) = builder.headers_mut() { - for (name, values) in &meta.headers { - for value in values { - if let (Ok(name), Ok(value)) = ( - axum::http::HeaderName::try_from(name.as_str()), - axum::http::HeaderValue::from_str(value), - ) { - headers.append(name, value); - } - } - } + *headers = proto::to_header_map(&meta.headers); } let body_stream = ReaderStream::new(recv); diff --git a/crates/funnel-server/src/quic/config.rs b/crates/funnel-server/src/quic/config.rs new file mode 100644 index 0000000..6ae0ea1 --- /dev/null +++ b/crates/funnel-server/src/quic/config.rs @@ -0,0 +1,78 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use funnel_core::protocol::QUIC_ALPN; + +use crate::app::AppState; + +const KEEP_ALIVE: Duration = Duration::from_secs(15); +const IDLE_TIMEOUT: Duration = Duration::from_mins(1); + +pub fn spawn_listener( + host: &str, + port: u16, + state: Arc, +) -> anyhow::Result> { + let server_config = build_self_signed_config()?; + let endpoint = build_endpoint(server_config, host, port)?; + + Ok(tokio::spawn(async move { + if let Err(e) = super::listener::run(endpoint, state).await { + tracing::error!(error = %e, "quic listener failed"); + } + })) +} + +fn build_self_signed_config() -> anyhow::Result { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])?; + let cert_der = cert.cert.der().clone(); + let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()); + + let mut server_crypto = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![cert_der], + rustls::pki_types::PrivateKeyDer::Pkcs8(key_der), + )?; + + server_crypto.alpn_protocols = vec![QUIC_ALPN.to_vec()]; + + let mut server_config = quinn::ServerConfig::with_crypto(Arc::new( + quinn::crypto::rustls::QuicServerConfig::try_from(server_crypto)?, + )); + + let mut transport = quinn::TransportConfig::default(); + transport.keep_alive_interval(Some(KEEP_ALIVE)); + transport.max_idle_timeout(Some(quinn::IdleTimeout::try_from(IDLE_TIMEOUT)?)); + + server_config.transport_config(Arc::new(transport)); + + Ok(server_config) +} + +/// build a quic endpoint, using a dual stack ipv6 socket when the host is a +/// wildcard address so that both ipv4 and ipv6 clients can connect. +fn build_endpoint( + config: quinn::ServerConfig, + host: &str, + port: u16, +) -> anyhow::Result { + let addr: SocketAddr = format!("{host}:{port}").parse()?; + + // when binding to 0.0.0.0, also bind the ipv6 wildcard so dual stack works + if addr == SocketAddr::from(([0, 0, 0, 0], port)) { + let v6_addr: SocketAddr = format!("[::]:{port}").parse()?; + let socket = std::net::UdpSocket::bind(v6_addr)?; + let runtime = quinn::default_runtime() + .ok_or_else(|| anyhow::anyhow!("no async runtime available for quic endpoint"))?; + Ok(quinn::Endpoint::new( + quinn::EndpointConfig::default(), + Some(config), + socket, + runtime, + )?) + } else { + Ok(quinn::Endpoint::server(config, addr)?) + } +} diff --git a/crates/funnel-server/src/quic/mod.rs b/crates/funnel-server/src/quic/mod.rs index dae6833..bd7bb29 100644 --- a/crates/funnel-server/src/quic/mod.rs +++ b/crates/funnel-server/src/quic/mod.rs @@ -1,2 +1,3 @@ +pub mod config; pub mod handler; pub mod listener; diff --git a/crates/funnel-server/src/store/api_key_store.rs b/crates/funnel-server/src/store/api_key_store.rs new file mode 100644 index 0000000..3959caf --- /dev/null +++ b/crates/funnel-server/src/store/api_key_store.rs @@ -0,0 +1,12 @@ +use uuid::Uuid; + +use crate::db::api_keys::{ApiKey, ApiKeyView}; +use super::{BoxFuture, StoreError}; + +#[allow(dead_code)] +pub trait ApiKeyStore: Send + Sync { + fn create(&self, user_id: Uuid, name: &str) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>>; + fn validate(&self, plaintext: &str) -> BoxFuture<'_, Result, StoreError>>; + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>>; + fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result>; +} diff --git a/crates/funnel-server/src/store/health.rs b/crates/funnel-server/src/store/health.rs new file mode 100644 index 0000000..a044c7f --- /dev/null +++ b/crates/funnel-server/src/store/health.rs @@ -0,0 +1,28 @@ +use tokio::time::Instant; + +pub trait HealthReporter: Send + Sync { + fn status(&self) -> &'static str; + fn uptime_secs(&self) -> u64; +} + +pub struct UptimeHealthReporter { + start_time: Instant, +} + +impl UptimeHealthReporter { + pub fn new() -> Self { + Self { + start_time: Instant::now(), + } + } +} + +impl HealthReporter for UptimeHealthReporter { + fn status(&self) -> &'static str { + "healthy" + } + + fn uptime_secs(&self) -> u64 { + self.start_time.elapsed().as_secs() + } +} diff --git a/crates/funnel-server/src/store/memory/api_key_store.rs b/crates/funnel-server/src/store/memory/api_key_store.rs new file mode 100644 index 0000000..664f60a --- /dev/null +++ b/crates/funnel-server/src/store/memory/api_key_store.rs @@ -0,0 +1,182 @@ +use std::sync::{PoisonError, RwLock}; + +use chrono::Utc; +use uuid::Uuid; + +use crate::db::api_keys::{ApiKey, ApiKeyView}; +use crate::store::api_key_store::ApiKeyStore; +use crate::store::{BoxFuture, StoreError}; +use funnel_core::auth::token as auth; + +pub struct InMemoryApiKeyStore { + keys: RwLock>, +} + +impl InMemoryApiKeyStore { + pub const fn new() -> Self { + Self { + keys: RwLock::new(Vec::new()), + } + } +} + +impl ApiKeyStore for InMemoryApiKeyStore { + fn create(&self, user_id: Uuid, name: &str) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>> { + let name = name.to_string(); + Box::pin(async move { + let plaintext = auth::generate_api_key() + .map_err(|e| StoreError::Other(format!("failed to generate api key: {e}")))?; + let hash = auth::hash_token(&plaintext); + let prefix = auth::ApiKeyPrefix::from_key(&plaintext); + + let key = ApiKey { + id: Uuid::now_v7(), + user_id, + name: name.clone(), + key_hash: hash, + key_prefix: prefix.as_ref().to_string(), + created_at: Utc::now(), + revoked_at: None, + }; + + let view = { + let mut keys = self.keys.write().unwrap_or_else(PoisonError::into_inner); + + if keys.iter().any(|k| k.user_id == user_id && k.name == name && k.revoked_at.is_none()) { + return Err(StoreError::Conflict(format!("api key name already exists: {name}"))); + } + + let view = ApiKeyView::from(key.clone()); + keys.push(key); + view + }; + + Ok((plaintext, view)) + }) + } + + fn validate(&self, plaintext: &str) -> BoxFuture<'_, Result, StoreError>> { + let hash = auth::hash_token(plaintext); + Box::pin(async move { + let keys = self.keys.read().unwrap_or_else(PoisonError::into_inner); + Ok(keys.iter().find(|k| k.key_hash == hash && k.revoked_at.is_none()).cloned()) + }) + } + + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let keys = self.keys.read().unwrap_or_else(PoisonError::into_inner); + Ok(keys + .iter() + .filter(|k| k.user_id == user_id && k.revoked_at.is_none()) + .cloned() + .map(Into::into) + .collect()) + }) + } + + fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let mut keys = self.keys.write().unwrap_or_else(PoisonError::into_inner); + if let Some(key) = keys.iter_mut().find(|k| k.id == key_id && k.user_id == user_id && k.revoked_at.is_none()) { + key.revoked_at = Some(Utc::now()); + Ok(true) + } else { + Ok(false) + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn user_id() -> Uuid { + Uuid::now_v7() + } + + #[tokio::test] + async fn create_and_validate() { + let store = InMemoryApiKeyStore::new(); + let uid = user_id(); + + let (plaintext, view) = store.create(uid, "test-key").await.unwrap(); + assert_eq!(view.name, "test-key"); + + let found = store.validate(&plaintext).await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().id, view.id); + } + + #[tokio::test] + async fn validate_wrong_key_returns_none() { + let store = InMemoryApiKeyStore::new(); + let found = store.validate("fnl_boguskey1234567890").await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn duplicate_name_returns_conflict() { + let store = InMemoryApiKeyStore::new(); + let uid = user_id(); + + store.create(uid, "dup").await.unwrap(); + let result = store.create(uid, "dup").await; + assert!(matches!(result, Err(StoreError::Conflict(_)))); + } + + #[tokio::test] + async fn list_for_user_only_returns_matching() { + let store = InMemoryApiKeyStore::new(); + let uid1 = user_id(); + let uid2 = user_id(); + + store.create(uid1, "a").await.unwrap(); + store.create(uid1, "b").await.unwrap(); + store.create(uid2, "c").await.unwrap(); + + let list = store.list_for_user(uid1).await.unwrap(); + assert_eq!(list.len(), 2); + assert!(list.iter().all(|k| k.name == "a" || k.name == "b")); + } + + #[tokio::test] + async fn revoke_makes_key_invalid() { + let store = InMemoryApiKeyStore::new(); + let uid = user_id(); + + let (plaintext, view) = store.create(uid, "to-revoke").await.unwrap(); + let revoked = store.revoke(view.id, uid).await.unwrap(); + assert!(revoked); + + let found = store.validate(&plaintext).await.unwrap(); + assert!(found.is_none()); + + let list = store.list_for_user(uid).await.unwrap(); + assert!(list.is_empty()); + } + + #[tokio::test] + async fn revoke_wrong_user_returns_false() { + let store = InMemoryApiKeyStore::new(); + let uid = user_id(); + let other = user_id(); + + let (_plaintext, view) = store.create(uid, "key").await.unwrap(); + let revoked = store.revoke(view.id, other).await.unwrap(); + assert!(!revoked); + } + + #[tokio::test] + async fn can_reuse_name_after_revoke() { + let store = InMemoryApiKeyStore::new(); + let uid = user_id(); + + let (_plaintext, view) = store.create(uid, "reuse").await.unwrap(); + store.revoke(view.id, uid).await.unwrap(); + + let result = store.create(uid, "reuse").await; + assert!(result.is_ok()); + } +} diff --git a/crates/funnel-server/src/store/memory/mod.rs b/crates/funnel-server/src/store/memory/mod.rs new file mode 100644 index 0000000..c428364 --- /dev/null +++ b/crates/funnel-server/src/store/memory/mod.rs @@ -0,0 +1,3 @@ +pub mod api_key_store; +pub mod session_recorder; +pub mod user_store; diff --git a/crates/funnel-server/src/store/memory/session_recorder.rs b/crates/funnel-server/src/store/memory/session_recorder.rs new file mode 100644 index 0000000..6813fee --- /dev/null +++ b/crates/funnel-server/src/store/memory/session_recorder.rs @@ -0,0 +1,196 @@ +use std::sync::{PoisonError, RwLock}; + +use chrono::Utc; +use ipnetwork::IpNetwork; +use uuid::Uuid; + +use crate::db::tunnel_sessions::TunnelSession; +use crate::store::session_recorder::SessionRecorder; +use crate::store::{BoxFuture, StoreError}; + +pub struct InMemorySessionRecorder { + sessions: RwLock>, +} + +impl InMemorySessionRecorder { + pub const fn new() -> Self { + Self { + sessions: RwLock::new(Vec::new()), + } + } +} + +impl SessionRecorder for InMemorySessionRecorder { + fn record_connect( + &self, + user_id: Uuid, + tunnel_id: &str, + client_ip: Option, + ) -> BoxFuture<'_, Result> { + let tunnel_id = tunnel_id.to_string(); + Box::pin(async move { + let session = TunnelSession { + id: Uuid::now_v7(), + user_id, + tunnel_id, + client_ip, + connected_at: Utc::now(), + disconnected_at: None, + bytes_in: 0, + bytes_out: 0, + requests: 0, + }; + { + let mut sessions = self.sessions.write().unwrap_or_else(PoisonError::into_inner); + sessions.push(session.clone()); + } + Ok(session) + }) + } + + fn record_disconnect( + &self, + session_id: Uuid, + bytes_in: i64, + bytes_out: i64, + requests: i64, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let mut sessions = self.sessions.write().unwrap_or_else(PoisonError::into_inner); + if let Some(session) = sessions.iter_mut().find(|s| s.id == session_id && s.disconnected_at.is_none()) { + session.disconnected_at = Some(Utc::now()); + session.bytes_in = bytes_in; + session.bytes_out = bytes_out; + session.requests = requests; + Ok(true) + } else { + Ok(false) + } + }) + } + + fn list_active(&self) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let sessions = self.sessions.read().unwrap_or_else(PoisonError::into_inner); + Ok(sessions.iter().filter(|s| s.disconnected_at.is_none()).cloned().collect()) + }) + } + + fn list_for_user( + &self, + user_id: Uuid, + limit: i64, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let sessions = self.sessions.read().unwrap_or_else(PoisonError::into_inner); + let limit = usize::try_from(limit).unwrap_or(usize::MAX); + Ok(sessions + .iter() + .filter(|s| s.user_id == user_id) + .rev() + .take(limit) + .cloned() + .collect()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn user_id() -> Uuid { + Uuid::now_v7() + } + + #[tokio::test] + async fn record_connect_and_list_active() { + let store = InMemorySessionRecorder::new(); + let uid = user_id(); + + let session = store.record_connect(uid, "tunnel-1", None).await.unwrap(); + assert_eq!(session.tunnel_id, "tunnel-1"); + assert_eq!(session.user_id, uid); + assert!(session.disconnected_at.is_none()); + + let active = store.list_active().await.unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].id, session.id); + } + + #[tokio::test] + async fn record_disconnect_removes_from_active() { + let store = InMemorySessionRecorder::new(); + let uid = user_id(); + + let session = store.record_connect(uid, "tunnel-2", None).await.unwrap(); + let disconnected = store.record_disconnect(session.id, 100, 200, 5).await.unwrap(); + assert!(disconnected); + + let active = store.list_active().await.unwrap(); + assert!(active.is_empty()); + } + + #[tokio::test] + async fn record_disconnect_updates_stats() { + let store = InMemorySessionRecorder::new(); + let uid = user_id(); + + let session = store.record_connect(uid, "tunnel-3", None).await.unwrap(); + store.record_disconnect(session.id, 1024, 2048, 10).await.unwrap(); + + let all = store.list_for_user(uid, 100).await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].bytes_in, 1024); + assert_eq!(all[0].bytes_out, 2048); + assert_eq!(all[0].requests, 10); + assert!(all[0].disconnected_at.is_some()); + } + + #[tokio::test] + async fn disconnect_nonexistent_returns_false() { + let store = InMemorySessionRecorder::new(); + let result = store.record_disconnect(Uuid::now_v7(), 0, 0, 0).await.unwrap(); + assert!(!result); + } + + #[tokio::test] + async fn disconnect_already_disconnected_returns_false() { + let store = InMemorySessionRecorder::new(); + let uid = user_id(); + + let session = store.record_connect(uid, "tunnel-4", None).await.unwrap(); + store.record_disconnect(session.id, 0, 0, 0).await.unwrap(); + + let again = store.record_disconnect(session.id, 0, 0, 0).await.unwrap(); + assert!(!again); + } + + #[tokio::test] + async fn list_for_user_respects_limit() { + let store = InMemorySessionRecorder::new(); + let uid = user_id(); + + for i in 0..5 { + store.record_connect(uid, &format!("tunnel-{i}"), None).await.unwrap(); + } + + let limited = store.list_for_user(uid, 3).await.unwrap(); + assert_eq!(limited.len(), 3); + } + + #[tokio::test] + async fn list_for_user_filters_by_user() { + let store = InMemorySessionRecorder::new(); + let uid1 = user_id(); + let uid2 = user_id(); + + store.record_connect(uid1, "a", None).await.unwrap(); + store.record_connect(uid2, "b", None).await.unwrap(); + store.record_connect(uid1, "c", None).await.unwrap(); + + let list = store.list_for_user(uid1, 100).await.unwrap(); + assert_eq!(list.len(), 2); + assert!(list.iter().all(|s| s.user_id == uid1)); + } +} diff --git a/crates/funnel-server/src/store/memory/user_store.rs b/crates/funnel-server/src/store/memory/user_store.rs new file mode 100644 index 0000000..60f8e70 --- /dev/null +++ b/crates/funnel-server/src/store/memory/user_store.rs @@ -0,0 +1,178 @@ +use std::sync::{PoisonError, RwLock}; + +use chrono::Utc; +use uuid::Uuid; + +use crate::db::users::{NewUser, User}; +use crate::store::user_store::UserStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct InMemoryUserStore { + users: RwLock>, +} + +impl InMemoryUserStore { + pub const fn new() -> Self { + Self { + users: RwLock::new(Vec::new()), + } + } +} + +impl UserStore for InMemoryUserStore { + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let users = self.users.read().unwrap_or_else(PoisonError::into_inner); + Ok(users.iter().find(|u| u.id == id).cloned()) + }) + } + + fn find_by_email(&self, email: &str) -> BoxFuture<'_, Result, StoreError>> { + let email = email.to_string(); + Box::pin(async move { + let users = self.users.read().unwrap_or_else(PoisonError::into_inner); + Ok(users.iter().find(|u| u.email == email).cloned()) + }) + } + + fn find_by_provider(&self, provider: &str, provider_id: &str) -> BoxFuture<'_, Result, StoreError>> { + let provider = provider.to_string(); + let provider_id = provider_id.to_string(); + Box::pin(async move { + let users = self.users.read().unwrap_or_else(PoisonError::into_inner); + Ok(users.iter().find(|u| u.provider == provider && u.provider_id == provider_id).cloned()) + }) + } + + fn create(&self, new_user: NewUser) -> BoxFuture<'_, Result> { + Box::pin(async move { + let now = Utc::now(); + let user = User { + id: Uuid::now_v7(), + email: new_user.email, + name: new_user.name, + avatar_url: new_user.avatar_url, + provider: new_user.provider, + provider_id: new_user.provider_id, + created_at: now, + updated_at: now, + }; + { + let mut users = self.users.write().unwrap_or_else(PoisonError::into_inner); + users.push(user.clone()); + } + Ok(user) + }) + } + + fn upsert_from_oauth(&self, new_user: NewUser) -> BoxFuture<'_, Result> { + Box::pin(async move { + let mut users = self.users.write().unwrap_or_else(PoisonError::into_inner); + let result = if let Some(existing) = users.iter_mut().find(|u| u.provider == new_user.provider && u.provider_id == new_user.provider_id) { + existing.email = new_user.email; + existing.name = new_user.name; + existing.avatar_url = new_user.avatar_url; + existing.updated_at = Utc::now(); + existing.clone() + } else { + let now = Utc::now(); + let user = User { + id: Uuid::now_v7(), + email: new_user.email, + name: new_user.name, + avatar_url: new_user.avatar_url, + provider: new_user.provider, + provider_id: new_user.provider_id, + created_at: now, + updated_at: now, + }; + users.push(user.clone()); + user + }; + drop(users); + Ok(result) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_new_user(email: &str) -> NewUser { + NewUser { + email: email.to_string(), + name: Some("Test User".to_string()), + avatar_url: None, + provider: "github".to_string(), + provider_id: format!("gh_{email}"), + } + } + + #[tokio::test] + async fn create_and_find_by_id() { + let store = InMemoryUserStore::new(); + let user = store.create(make_new_user("a@test.com")).await.unwrap(); + + let found = store.find_by_id(user.id).await.unwrap(); + assert_eq!(found.unwrap().email, "a@test.com"); + } + + #[tokio::test] + async fn find_by_id_missing_returns_none() { + let store = InMemoryUserStore::new(); + let found = store.find_by_id(Uuid::now_v7()).await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn find_by_email() { + let store = InMemoryUserStore::new(); + store.create(make_new_user("b@test.com")).await.unwrap(); + + let found = store.find_by_email("b@test.com").await.unwrap(); + assert!(found.is_some()); + + let missing = store.find_by_email("nope@test.com").await.unwrap(); + assert!(missing.is_none()); + } + + #[tokio::test] + async fn find_by_provider() { + let store = InMemoryUserStore::new(); + store.create(make_new_user("c@test.com")).await.unwrap(); + + let found = store.find_by_provider("github", "gh_c@test.com").await.unwrap(); + assert!(found.is_some()); + + let missing = store.find_by_provider("github", "gh_nope").await.unwrap(); + assert!(missing.is_none()); + } + + #[tokio::test] + async fn upsert_creates_new_user() { + let store = InMemoryUserStore::new(); + let user = store.upsert_from_oauth(make_new_user("d@test.com")).await.unwrap(); + assert_eq!(user.email, "d@test.com"); + + let found = store.find_by_id(user.id).await.unwrap(); + assert!(found.is_some()); + } + + #[tokio::test] + async fn upsert_updates_existing_user() { + let store = InMemoryUserStore::new(); + let original = store.upsert_from_oauth(make_new_user("e@test.com")).await.unwrap(); + + let mut updated_input = make_new_user("new_e@test.com"); + updated_input.provider = "github".to_string(); + updated_input.provider_id = "gh_e@test.com".to_string(); + updated_input.name = Some("Updated Name".to_string()); + + let updated = store.upsert_from_oauth(updated_input).await.unwrap(); + assert_eq!(updated.id, original.id); + assert_eq!(updated.email, "new_e@test.com"); + assert_eq!(updated.name, Some("Updated Name".to_string())); + assert!(updated.updated_at > original.updated_at); + } +} diff --git a/crates/funnel-server/src/store/mod.rs b/crates/funnel-server/src/store/mod.rs new file mode 100644 index 0000000..42f59e1 --- /dev/null +++ b/crates/funnel-server/src/store/mod.rs @@ -0,0 +1,50 @@ +pub mod api_key_store; +pub mod health; +pub mod memory; +pub mod pg; +#[allow(dead_code)] +pub mod session_recorder; +pub mod tunnel_registry; +#[allow(dead_code)] +pub mod user_store; + +use std::fmt; +use std::future::Future; +use std::pin::Pin; + +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +#[derive(Debug)] +pub enum StoreError { + Database(sqlx::Error), + #[allow(dead_code)] + NotFound, + Conflict(String), + Other(String), +} + +impl fmt::Display for StoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Database(e) => write!(f, "database error: {e}"), + Self::NotFound => write!(f, "not found"), + Self::Conflict(msg) => write!(f, "conflict: {msg}"), + Self::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for StoreError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Database(e) => Some(e), + _ => None, + } + } +} + +impl From for StoreError { + fn from(e: sqlx::Error) -> Self { + Self::Database(e) + } +} diff --git a/crates/funnel-server/src/store/pg/api_key_store.rs b/crates/funnel-server/src/store/pg/api_key_store.rs new file mode 100644 index 0000000..88cb5e7 --- /dev/null +++ b/crates/funnel-server/src/store/pg/api_key_store.rs @@ -0,0 +1,46 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db::api_keys::{self, ApiKey, ApiKeyView}; +use crate::store::api_key_store::ApiKeyStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct PgApiKeyStore { + pool: PgPool, +} + +impl PgApiKeyStore { + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl ApiKeyStore for PgApiKeyStore { + fn create(&self, user_id: Uuid, name: &str) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>> { + let name = name.to_string(); + Box::pin(async move { + let (plaintext, key) = api_keys::create(&self.pool, user_id, &name).await?; + Ok((plaintext, key.into())) + }) + } + + fn validate(&self, plaintext: &str) -> BoxFuture<'_, Result, StoreError>> { + let plaintext = plaintext.to_string(); + Box::pin(async move { + Ok(api_keys::validate(&self.pool, &plaintext).await?) + }) + } + + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let keys = api_keys::list_for_user(&self.pool, user_id).await?; + Ok(keys.into_iter().map(Into::into).collect()) + }) + } + + fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + Ok(api_keys::revoke(&self.pool, key_id, user_id).await?) + }) + } +} diff --git a/crates/funnel-server/src/store/pg/mod.rs b/crates/funnel-server/src/store/pg/mod.rs new file mode 100644 index 0000000..c428364 --- /dev/null +++ b/crates/funnel-server/src/store/pg/mod.rs @@ -0,0 +1,3 @@ +pub mod api_key_store; +pub mod session_recorder; +pub mod user_store; diff --git a/crates/funnel-server/src/store/pg/session_recorder.rs b/crates/funnel-server/src/store/pg/session_recorder.rs new file mode 100644 index 0000000..c5d399c --- /dev/null +++ b/crates/funnel-server/src/store/pg/session_recorder.rs @@ -0,0 +1,59 @@ +use ipnetwork::IpNetwork; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db::tunnel_sessions::{self, TunnelSession}; +use crate::store::session_recorder::SessionRecorder; +use crate::store::{BoxFuture, StoreError}; + +pub struct PgSessionRecorder { + pool: PgPool, +} + +impl PgSessionRecorder { + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl SessionRecorder for PgSessionRecorder { + fn record_connect( + &self, + user_id: Uuid, + tunnel_id: &str, + client_ip: Option, + ) -> BoxFuture<'_, Result> { + let tunnel_id = tunnel_id.to_string(); + Box::pin(async move { + Ok(tunnel_sessions::create(&self.pool, user_id, &tunnel_id, client_ip).await?) + }) + } + + fn record_disconnect( + &self, + session_id: Uuid, + bytes_in: i64, + bytes_out: i64, + requests: i64, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + Ok(tunnel_sessions::disconnect(&self.pool, session_id, bytes_in, bytes_out, requests).await?) + }) + } + + fn list_active(&self) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + Ok(tunnel_sessions::list_active(&self.pool).await?) + }) + } + + fn list_for_user( + &self, + user_id: Uuid, + limit: i64, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + Ok(tunnel_sessions::list_for_user(&self.pool, user_id, limit).await?) + }) + } +} diff --git a/crates/funnel-server/src/store/pg/user_store.rs b/crates/funnel-server/src/store/pg/user_store.rs new file mode 100644 index 0000000..5f0bd42 --- /dev/null +++ b/crates/funnel-server/src/store/pg/user_store.rs @@ -0,0 +1,51 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db::users::{self, NewUser, User}; +use crate::store::user_store::UserStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct PgUserStore { + pool: PgPool, +} + +impl PgUserStore { + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl UserStore for PgUserStore { + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + Ok(users::find_by_id(&self.pool, id).await?) + }) + } + + fn find_by_email(&self, email: &str) -> BoxFuture<'_, Result, StoreError>> { + let email = email.to_string(); + Box::pin(async move { + Ok(users::find_by_email(&self.pool, &email).await?) + }) + } + + fn find_by_provider(&self, provider: &str, provider_id: &str) -> BoxFuture<'_, Result, StoreError>> { + let provider = provider.to_string(); + let provider_id = provider_id.to_string(); + Box::pin(async move { + Ok(users::find_by_provider(&self.pool, &provider, &provider_id).await?) + }) + } + + fn create(&self, new_user: NewUser) -> BoxFuture<'_, Result> { + Box::pin(async move { + Ok(users::create(&self.pool, new_user).await?) + }) + } + + fn upsert_from_oauth(&self, new_user: NewUser) -> BoxFuture<'_, Result> { + Box::pin(async move { + Ok(users::upsert_from_oauth(&self.pool, new_user).await?) + }) + } +} diff --git a/crates/funnel-server/src/store/session_recorder.rs b/crates/funnel-server/src/store/session_recorder.rs new file mode 100644 index 0000000..43f1b98 --- /dev/null +++ b/crates/funnel-server/src/store/session_recorder.rs @@ -0,0 +1,30 @@ +use ipnetwork::IpNetwork; +use uuid::Uuid; + +use crate::db::tunnel_sessions::TunnelSession; +use super::{BoxFuture, StoreError}; + +pub trait SessionRecorder: Send + Sync { + fn record_connect( + &self, + user_id: Uuid, + tunnel_id: &str, + client_ip: Option, + ) -> BoxFuture<'_, Result>; + + fn record_disconnect( + &self, + session_id: Uuid, + bytes_in: i64, + bytes_out: i64, + requests: i64, + ) -> BoxFuture<'_, Result>; + + fn list_active(&self) -> BoxFuture<'_, Result, StoreError>>; + + fn list_for_user( + &self, + user_id: Uuid, + limit: i64, + ) -> BoxFuture<'_, Result, StoreError>>; +} diff --git a/crates/funnel-server/src/store/tunnel_registry.rs b/crates/funnel-server/src/store/tunnel_registry.rs new file mode 100644 index 0000000..fefbfce --- /dev/null +++ b/crates/funnel-server/src/store/tunnel_registry.rs @@ -0,0 +1,14 @@ +use std::sync::Arc; + +use funnel_core::tunnel::id::TunnelId; + +use crate::tunnel::connection::ActiveTunnel; +use crate::tunnel::manager::TunnelInfo; + +pub trait TunnelRegistry: Send + Sync { + fn insert(&self, id: TunnelId, tunnel: Arc) -> Result<(), Arc>; + fn remove(&self, id: &TunnelId) -> Option>; + fn get(&self, id: &TunnelId) -> Option>; + fn list(&self) -> Vec; + fn count(&self) -> usize; +} diff --git a/crates/funnel-server/src/store/user_store.rs b/crates/funnel-server/src/store/user_store.rs new file mode 100644 index 0000000..6ad2897 --- /dev/null +++ b/crates/funnel-server/src/store/user_store.rs @@ -0,0 +1,12 @@ +use uuid::Uuid; + +use crate::db::users::{NewUser, User}; +use super::{BoxFuture, StoreError}; + +pub trait UserStore: Send + Sync { + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>>; + fn find_by_email(&self, email: &str) -> BoxFuture<'_, Result, StoreError>>; + fn find_by_provider(&self, provider: &str, provider_id: &str) -> BoxFuture<'_, Result, StoreError>>; + fn create(&self, new_user: NewUser) -> BoxFuture<'_, Result>; + fn upsert_from_oauth(&self, new_user: NewUser) -> BoxFuture<'_, Result>; +} diff --git a/crates/funnel-server/src/tls/acme.rs b/crates/funnel-server/src/tls/acme.rs new file mode 100644 index 0000000..d85e8a7 --- /dev/null +++ b/crates/funnel-server/src/tls/acme.rs @@ -0,0 +1,181 @@ +use std::io::BufReader; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use anyhow::{Context, Result}; +use instant_acme::{ + Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, NewAccount, + NewOrder, RetryPolicy, +}; +use rustls::sign::CertifiedKey; +use x509_parser::prelude::*; + +use super::provider::DnsChallenger; + +const POLL_TIMEOUT: Duration = Duration::from_mins(5); +const DNS_PROPAGATION_DELAY: Duration = Duration::from_secs(10); + +pub async fn load_or_create_account( + cert_dir: &Path, + email: &str, + staging: bool, +) -> Result { + let account_json_path = cert_dir.join("account.json"); + + if account_json_path.exists() { + let json = tokio::fs::read_to_string(&account_json_path).await?; + let credentials: AccountCredentials = serde_json::from_str(&json)?; + let account = Account::builder() + .context("failed to create account builder")? + .from_credentials(credentials) + .await + .context("failed to load ACME account from credentials")?; + tracing::info!("loaded existing ACME account"); + return Ok(account); + } + + let directory_url = if staging { + instant_acme::LetsEncrypt::Staging.url() + } else { + instant_acme::LetsEncrypt::Production.url() + }; + + let (account, credentials) = Account::builder() + .context("failed to create account builder")? + .create( + &NewAccount { + contact: &[&format!("mailto:{email}")], + terms_of_service_agreed: true, + only_return_existing: false, + }, + directory_url.to_string(), + None, + ) + .await + .context("failed to create ACME account")?; + + let json = serde_json::to_string_pretty(&credentials)?; + tokio::fs::write(&account_json_path, &json).await?; + + tracing::info!("created new ACME account"); + Ok(account) +} + +pub async fn order_certificate( + account: &Account, + domain: &str, + provider: &Arc, +) -> Result<(String, String)> { + let identifiers = vec![Identifier::Dns(domain.to_string())]; + let mut order = account + .new_order(&NewOrder::new(&identifiers)) + .await + .context("failed to create ACME order")?; + + struct PendingCleanup { + record_name: String, + dns_value: String, + } + let mut cleanups = Vec::new(); + + { + let mut authz_stream = order.authorizations(); + while let Some(result) = authz_stream.next().await { + let mut authz = result.context("failed to get authorization")?; + + if matches!(authz.status, AuthorizationStatus::Valid) { + continue; + } + + let challenge_domain = match authz.identifier().identifier { + Identifier::Dns(d) => d.clone(), + _ => continue, + }; + + let mut challenge = authz + .challenge(ChallengeType::Dns01) + .context("no DNS-01 challenge found")?; + + let dns_value = challenge.key_authorization().dns_value(); + + let record_name = format!("_acme-challenge.{challenge_domain}"); + provider + .present(&record_name, &dns_value) + .await + .context("failed to present DNS challenge")?; + + tokio::time::sleep(DNS_PROPAGATION_DELAY).await; + + challenge + .set_ready() + .await + .context("failed to set challenge ready")?; + + cleanups.push(PendingCleanup { + record_name, + dns_value, + }); + } + } + + let retries = RetryPolicy::new().timeout(POLL_TIMEOUT); + + order + .poll_ready(&retries) + .await + .context("failed waiting for order to become ready")?; + + let private_key_pem = order + .finalize() + .await + .context("failed to finalize ACME order")?; + + let cert_pem = order + .poll_certificate(&retries) + .await + .context("failed to get certificate")?; + + // clean up DNS records (best effort) + for cleanup in &cleanups { + if let Err(e) = provider + .cleanup(&cleanup.record_name, &cleanup.dns_value) + .await + { + tracing::warn!( + record = %cleanup.record_name, + error = %e, + "failed to clean up DNS record" + ); + } + } + + Ok((cert_pem, private_key_pem)) +} + +pub fn load_certified_key(cert_pem: &str, key_pem: &str) -> Result { + let certs = rustls_pemfile::certs(&mut BufReader::new(cert_pem.as_bytes())) + .collect::, _>>() + .context("failed to parse certificate PEM")?; + + let key = rustls_pemfile::private_key(&mut BufReader::new(key_pem.as_bytes())) + .context("failed to parse private key PEM")? + .context("no private key found")?; + + let signing_key = rustls::crypto::ring::sign::any_supported_type(&key) + .map_err(|e| anyhow::anyhow!(e)) + .context("unsupported private key type")?; + + Ok(CertifiedKey::new(certs, signing_key)) +} + +pub fn parse_cert_expiry(cert_pem: &str) -> Result { + let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_pem.as_bytes())) + .collect::, _>>()?; + let cert = certs.first().context("no certificate found")?; + let (_, parsed) = X509Certificate::from_der(cert.as_ref()) + .map_err(|e| anyhow::anyhow!("failed to parse x509 certificate: {e}"))?; + + let not_after = parsed.validity().not_after; + Ok(not_after.to_datetime().into()) +} diff --git a/crates/funnel-server/src/tls/cache.rs b/crates/funnel-server/src/tls/cache.rs new file mode 100644 index 0000000..f4635e2 --- /dev/null +++ b/crates/funnel-server/src/tls/cache.rs @@ -0,0 +1,70 @@ +use std::num::NonZeroUsize; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, SystemTime}; + +use lru::LruCache; +use rustls::sign::CertifiedKey; + +const CAPACITY: NonZeroUsize = match NonZeroUsize::new(512) { + Some(v) => v, + None => panic!("cache size must be non-zero"), +}; + +pub struct CachedCert { + pub certified_key: Arc, + pub not_after: SystemTime, +} + +pub struct CertCache { + inner: Mutex>, +} + +impl CertCache { + pub fn new() -> Self { + Self { + inner: Mutex::new(LruCache::new(CAPACITY)), + } + } + + pub fn get(&self, domain: &str) -> Option<(Arc, SystemTime)> { + let cache = self + .inner + .lock() + .unwrap_or_else(PoisonError::into_inner); + let cached = cache.peek(domain)?; + let result = (Arc::clone(&cached.certified_key), cached.not_after); + drop(cache); + Some(result) + } + + pub fn put(&self, domain: String, key: CertifiedKey, not_after: SystemTime) { + let mut cache = self + .inner + .lock() + .unwrap_or_else(PoisonError::into_inner); + cache.put( + domain, + CachedCert { + certified_key: Arc::new(key), + not_after, + }, + ); + } + + pub fn domains_needing_renewal(&self, window: Duration) -> Vec { + let cache = self + .inner + .lock() + .unwrap_or_else(PoisonError::into_inner); + cache + .iter() + .filter(|(_, cert)| { + cert.not_after + .duration_since(SystemTime::now()) + .unwrap_or(Duration::ZERO) + < window + }) + .map(|(domain, _)| domain.clone()) + .collect() + } +} diff --git a/crates/funnel-server/src/tls/manager.rs b/crates/funnel-server/src/tls/manager.rs index 6fb4c9d..d3e72c9 100644 --- a/crates/funnel-server/src/tls/manager.rs +++ b/crates/funnel-server/src/tls/manager.rs @@ -1,33 +1,21 @@ -use std::io::BufReader; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::path::PathBuf; +use std::sync::Arc; use std::time::{Duration, SystemTime}; use anyhow::{Context, Result}; -use instant_acme::{ - Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, NewAccount, - NewOrder, RetryPolicy, -}; -use lru::LruCache; +use instant_acme::Account; use rustls::server::ResolvesServerCert; use rustls::sign::CertifiedKey; -use x509_parser::prelude::*; +use super::acme; +use super::cache::CertCache; use super::provider::ProviderMux; -const LRU_CACHE_SIZE: std::num::NonZeroUsize = std::num::NonZeroUsize::new(512).unwrap(); const RENEWAL_CHECK_INTERVAL: Duration = Duration::from_hours(1); const RENEWAL_WINDOW: Duration = Duration::from_hours(720); -const ACME_POLL_TIMEOUT: Duration = Duration::from_mins(5); -const DNS_PROPAGATION_DELAY: Duration = Duration::from_secs(10); - -struct CachedCert { - certified_key: Arc, - not_after: SystemTime, -} pub struct CertificateManager { - cache: Mutex>, + cache: CertCache, obtain_lock: tokio::sync::Mutex<()>, cert_dir: PathBuf, provider_mux: Arc, @@ -53,11 +41,10 @@ impl CertificateManager { .await .context("failed to create certificate directory")?; - let account = load_or_create_account(&cert_dir, email, staging).await?; - let cache = Mutex::new(LruCache::new(LRU_CACHE_SIZE)); + let account = acme::load_or_create_account(&cert_dir, email, staging).await?; Ok(Self { - cache, + cache: CertCache::new(), obtain_lock: tokio::sync::Mutex::new(()), cert_dir, provider_mux: Arc::new(provider_mux), @@ -70,17 +57,7 @@ impl CertificateManager { match self.load_from_disk(&domain).await { Ok(Some((certified_key, not_after))) => { tracing::info!(domain = %domain, "loaded certificate from disk"); - let mut cache = self - .cache - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - cache.put( - domain, - CachedCert { - certified_key: Arc::new(certified_key), - not_after, - }, - ); + self.cache.put(domain, certified_key, not_after); } Ok(None) => { tracing::info!(domain = %domain, "no cached certificate, obtaining from ACME"); @@ -103,16 +80,10 @@ impl CertificateManager { let _guard = self.obtain_lock.lock().await; // double check cache after acquiring lock + if let Some((_, not_after)) = self.cache.get(domain) + && not_after > SystemTime::now() + RENEWAL_WINDOW { - let cache = self - .cache - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(cached) = cache.peek(domain) - && cached.not_after > SystemTime::now() + RENEWAL_WINDOW - { - return Ok(()); - } + return Ok(()); } let provider = self @@ -122,118 +93,18 @@ impl CertificateManager { tracing::info!(domain = %domain, "ordering certificate from ACME"); - let identifiers = vec![Identifier::Dns(domain.to_string())]; - let mut order = self - .account - .new_order(&NewOrder::new(&identifiers)) - .await - .context("failed to create ACME order")?; - - // process authorizations: present DNS challenges and mark them ready - struct PendingCleanup { - record_name: String, - dns_value: String, - } - let mut cleanups = Vec::new(); - - { - let mut authz_stream = order.authorizations(); - while let Some(result) = authz_stream.next().await { - let mut authz = result.context("failed to get authorization")?; - - if matches!(authz.status, AuthorizationStatus::Valid) { - continue; - } - - let challenge_domain = match authz.identifier().identifier { - Identifier::Dns(d) => d.clone(), - _ => continue, - }; - - let mut challenge = authz - .challenge(ChallengeType::Dns01) - .context("no DNS-01 challenge found")?; - - let dns_value = challenge.key_authorization().dns_value(); - - let record_name = format!("_acme-challenge.{challenge_domain}"); - provider - .present(&record_name, &dns_value) - .await - .context("failed to present DNS challenge")?; + let (cert_pem, key_pem) = + acme::order_certificate(&self.account, domain, &provider).await?; - tokio::time::sleep(DNS_PROPAGATION_DELAY).await; + self.save_to_disk(domain, &cert_pem, &key_pem).await?; - challenge - .set_ready() - .await - .context("failed to set challenge ready")?; + let certified_key = acme::load_certified_key(&cert_pem, &key_pem)?; + let not_after = acme::parse_cert_expiry(&cert_pem)?; - cleanups.push(PendingCleanup { - record_name, - dns_value, - }); - } - } - - let retries = RetryPolicy::new().timeout(ACME_POLL_TIMEOUT); - - order - .poll_ready(&retries) - .await - .context("failed waiting for order to become ready")?; - - // finalize: generates ECDSA key + CSR internally, returns private key PEM - let private_key_pem = order - .finalize() - .await - .context("failed to finalize ACME order")?; - - let cert_pem = order - .poll_certificate(&retries) - .await - .context("failed to get certificate")?; - - // save to disk - let cert_path = self.cert_dir.join(format!("{domain}.crt")); - let key_path = self.cert_dir.join(format!("{domain}.key")); - tokio::fs::write(&cert_path, &cert_pem).await?; - tokio::fs::write(&key_path, &private_key_pem).await?; - - // parse and cache - let certified_key = load_certified_key(&cert_pem, &private_key_pem)?; - let not_after = parse_cert_expiry(&cert_pem)?; - - { - let mut cache = self - .cache - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - cache.put( - domain.to_string(), - CachedCert { - certified_key: Arc::new(certified_key), - not_after, - }, - ); - } + self.cache.put(domain.to_string(), certified_key, not_after); tracing::info!(domain = %domain, "certificate obtained and cached"); - // clean up DNS records (best effort, after everything succeeded) - for cleanup in &cleanups { - if let Err(e) = provider - .cleanup(&cleanup.record_name, &cleanup.dns_value) - .await - { - tracing::warn!( - record = %cleanup.record_name, - error = %e, - "failed to clean up DNS record" - ); - } - } - Ok(()) } @@ -248,12 +119,20 @@ impl CertificateManager { let cert_pem = tokio::fs::read_to_string(&cert_path).await?; let key_pem = tokio::fs::read_to_string(&key_path).await?; - let certified_key = load_certified_key(&cert_pem, &key_pem)?; - let not_after = parse_cert_expiry(&cert_pem)?; + let certified_key = acme::load_certified_key(&cert_pem, &key_pem)?; + let not_after = acme::parse_cert_expiry(&cert_pem)?; Ok(Some((certified_key, not_after))) } + async fn save_to_disk(&self, domain: &str, cert_pem: &str, key_pem: &str) -> Result<()> { + let cert_path = self.cert_dir.join(format!("{domain}.crt")); + let key_path = self.cert_dir.join(format!("{domain}.key")); + tokio::fs::write(&cert_path, cert_pem).await?; + tokio::fs::write(&key_path, key_pem).await?; + Ok(()) + } + pub fn spawn_renewal_task(self: &Arc) { let manager = Arc::clone(self); tokio::spawn(async move { @@ -266,24 +145,9 @@ impl CertificateManager { } async fn check_renewals(&self) { - let domains_to_renew: Vec = { - let cache = self - .cache - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - cache - .iter() - .filter(|(_, cert)| { - cert.not_after - .duration_since(SystemTime::now()) - .unwrap_or(Duration::ZERO) - < RENEWAL_WINDOW - }) - .map(|(domain, _)| domain.clone()) - .collect() - }; + let domains = self.cache.domains_needing_renewal(RENEWAL_WINDOW); - for domain in domains_to_renew { + for domain in domains { tracing::info!(domain = %domain, "renewing certificate"); if let Err(e) = self.obtain_certificate(&domain).await { tracing::error!(domain = %domain, error = %e, "failed to renew certificate"); @@ -295,14 +159,7 @@ impl CertificateManager { impl ResolvesServerCert for CertificateManager { fn resolve(&self, client_hello: rustls::server::ClientHello<'_>) -> Option> { let sni = client_hello.server_name()?; - let cache = self - .cache - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let cached = cache.peek(sni)?; - let not_after = cached.not_after; - let key = Arc::clone(&cached.certified_key); - drop(cache); + let (key, not_after) = self.cache.get(sni)?; if not_after > SystemTime::now() { Some(key) @@ -311,72 +168,3 @@ impl ResolvesServerCert for CertificateManager { } } } - -fn load_certified_key(cert_pem: &str, key_pem: &str) -> Result { - let certs = rustls_pemfile::certs(&mut BufReader::new(cert_pem.as_bytes())) - .collect::, _>>() - .context("failed to parse certificate PEM")?; - - let key = rustls_pemfile::private_key(&mut BufReader::new(key_pem.as_bytes())) - .context("failed to parse private key PEM")? - .context("no private key found")?; - - let signing_key = rustls::crypto::ring::sign::any_supported_type(&key) - .map_err(|e| anyhow::anyhow!(e)) - .context("unsupported private key type")?; - - Ok(CertifiedKey::new(certs, signing_key)) -} - -fn parse_cert_expiry(cert_pem: &str) -> Result { - let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_pem.as_bytes())) - .collect::, _>>()?; - let cert = certs.first().context("no certificate found")?; - let (_, parsed) = X509Certificate::from_der(cert.as_ref()) - .map_err(|e| anyhow::anyhow!("failed to parse x509 certificate: {e}"))?; - - let not_after = parsed.validity().not_after; - Ok(not_after.to_datetime().into()) -} - -async fn load_or_create_account(cert_dir: &Path, email: &str, staging: bool) -> Result { - let account_json_path = cert_dir.join("account.json"); - - if account_json_path.exists() { - let json = tokio::fs::read_to_string(&account_json_path).await?; - let credentials: AccountCredentials = serde_json::from_str(&json)?; - let account = Account::builder() - .context("failed to create account builder")? - .from_credentials(credentials) - .await - .context("failed to load ACME account from credentials")?; - tracing::info!("loaded existing ACME account"); - return Ok(account); - } - - let directory_url = if staging { - instant_acme::LetsEncrypt::Staging.url() - } else { - instant_acme::LetsEncrypt::Production.url() - }; - - let (account, credentials) = Account::builder() - .context("failed to create account builder")? - .create( - &NewAccount { - contact: &[&format!("mailto:{email}")], - terms_of_service_agreed: true, - only_return_existing: false, - }, - directory_url.to_string(), - None, - ) - .await - .context("failed to create ACME account")?; - - let json = serde_json::to_string_pretty(&credentials)?; - tokio::fs::write(&account_json_path, &json).await?; - - tracing::info!("created new ACME account"); - Ok(account) -} diff --git a/crates/funnel-server/src/tls/mod.rs b/crates/funnel-server/src/tls/mod.rs index 6bcb25f..a0f78af 100644 --- a/crates/funnel-server/src/tls/mod.rs +++ b/crates/funnel-server/src/tls/mod.rs @@ -1,6 +1,9 @@ +mod acme; +mod cache; pub mod config; pub mod manager; pub mod provider; +pub mod redirect; use std::path::Path; use std::sync::Arc; diff --git a/crates/funnel-server/src/tls/redirect.rs b/crates/funnel-server/src/tls/redirect.rs new file mode 100644 index 0000000..5f8ac7e --- /dev/null +++ b/crates/funnel-server/src/tls/redirect.rs @@ -0,0 +1,25 @@ +use axum::Router; +use axum::extract::Request; +use axum::response::Redirect; + +pub fn router(tls_port: u16) -> Router { + Router::new().fallback(move |request: Request| async move { + let host = request + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost"); + let host_without_port = host.split(':').next().unwrap_or(host); + let path = request + .uri() + .path_and_query() + .map_or("/", axum::http::uri::PathAndQuery::as_str); + + let location = if tls_port == 443 { + format!("https://{host_without_port}{path}") + } else { + format!("https://{host_without_port}:{tls_port}{path}") + }; + Redirect::permanent(&location) + }) +} diff --git a/crates/funnel-server/src/tunnel/manager.rs b/crates/funnel-server/src/tunnel/manager.rs index c630d64..931ca9a 100644 --- a/crates/funnel-server/src/tunnel/manager.rs +++ b/crates/funnel-server/src/tunnel/manager.rs @@ -8,6 +8,7 @@ use funnel_core::tunnel::id::TunnelId; use super::connection::ActiveTunnel; use super::stats::TunnelStatsSnapshot; +use crate::store::tunnel_registry::TunnelRegistry; pub struct TunnelManager { tunnels: DashMap>, @@ -64,6 +65,28 @@ impl TunnelManager { } } +impl TunnelRegistry for TunnelManager { + fn insert(&self, id: TunnelId, tunnel: Arc) -> Result<(), Arc> { + self.insert(id, tunnel) + } + + fn remove(&self, id: &TunnelId) -> Option> { + self.remove(id) + } + + fn get(&self, id: &TunnelId) -> Option> { + self.get(id) + } + + fn list(&self) -> Vec { + self.list() + } + + fn count(&self) -> usize { + self.count() + } +} + #[cfg(test)] mod tests { use super::*; From caae1dec5f13cf62bc4ff5293f7ea60bc7a2ca4c Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sat, 9 May 2026 18:08:52 +0200 Subject: [PATCH 22/42] feat(server): add API key auth middleware for key management endpoints --- Cargo.lock | 1 + crates/funnel-e2e/Cargo.toml | 3 +- crates/funnel-e2e/tests/e2e/auth.rs | 235 +++++++++++++++++++++++++ crates/funnel-e2e/tests/e2e/harness.rs | 8 +- crates/funnel-e2e/tests/e2e/main.rs | 1 + crates/funnel-server/src/api/keys.rs | 23 ++- crates/funnel-server/src/auth/mod.rs | 34 ++++ crates/funnel-server/src/error.rs | 4 + crates/funnel-server/src/main.rs | 14 ++ 9 files changed, 305 insertions(+), 18 deletions(-) create mode 100644 crates/funnel-e2e/tests/e2e/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 87d8ab0..e0b7692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1544,6 +1544,7 @@ version = "0.1.0" dependencies = [ "axum 0.8.9", "reqwest", + "serde_json", "tokio", ] diff --git a/crates/funnel-e2e/Cargo.toml b/crates/funnel-e2e/Cargo.toml index 7bd4d26..a693938 100644 --- a/crates/funnel-e2e/Cargo.toml +++ b/crates/funnel-e2e/Cargo.toml @@ -8,5 +8,6 @@ workspace = true [dev-dependencies] tokio.workspace = true -reqwest = { workspace = true, features = ["rustls"] } +reqwest = { workspace = true, features = ["rustls", "json"] } +serde_json.workspace = true axum.workspace = true diff --git a/crates/funnel-e2e/tests/e2e/auth.rs b/crates/funnel-e2e/tests/e2e/auth.rs new file mode 100644 index 0000000..fc73007 --- /dev/null +++ b/crates/funnel-e2e/tests/e2e/auth.rs @@ -0,0 +1,235 @@ +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; + +use crate::harness::{binary_path, free_port, log_file, wait_for_tcp}; + +type TestResult = Result<(), Box>; + +struct AuthTestEnv { + server_process: Child, + server_log: PathBuf, + http_port: u16, + seed_key: String, + client: reqwest::Client, +} + +impl AuthTestEnv { + async fn start() -> Result> { + let http_port = free_port()?; + let quic_port = free_port()?; + + let (stderr_file, log_path) = log_file("auth-server")?; + + let mut child = Command::new(binary_path("funnel-server")?) + .args([ + "--port", + &http_port.to_string(), + "--quic-port", + &quic_port.to_string(), + "--host", + "127.0.0.1", + "--seed-api-key", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::from(stderr_file)) + .spawn()?; + + let stdout = child.stdout.take().ok_or("stdout not piped")?; + let mut reader = BufReader::new(stdout); + let mut seed_key = String::new(); + reader.read_line(&mut seed_key)?; + let seed_key = seed_key.trim().to_string(); + + wait_for_tcp(http_port).await; + + Ok(Self { + server_process: child, + server_log: log_path, + http_port, + seed_key, + client: reqwest::Client::new(), + }) + } + + fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{}", self.http_port, path) + } + + fn auth_header(&self) -> String { + format!("Bearer {}", self.seed_key) + } + + fn dump_logs(&self) { + if let Ok(content) = std::fs::read_to_string(&self.server_log) + && !content.is_empty() + { + eprintln!("\n--- auth server stderr ---\n{content}\n--- end ---\n"); + } + } +} + +impl Drop for AuthTestEnv { + fn drop(&mut self) { + let _ = self.server_process.kill(); + let _ = self.server_process.wait(); + + if std::thread::panicking() { + self.dump_logs(); + } + + let _ = std::fs::remove_file(&self.server_log); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn keys_require_auth() -> TestResult { + let env = AuthTestEnv::start().await?; + + let get = env.client.get(env.url("/api/keys")).send().await?; + assert_eq!(get.status(), 401); + + let post = env + .client + .post(env.url("/api/keys")) + .json(&serde_json::json!({"name": "test"})) + .send() + .await?; + assert_eq!(post.status(), 401); + + let delete = env + .client + .delete(env.url("/api/keys/00000000-0000-0000-0000-000000000000")) + .send() + .await?; + assert_eq!(delete.status(), 401); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_bearer_token_rejected() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env + .client + .get(env.url("/api/keys")) + .header("authorization", "Bearer fnl_bogustoken1234567890") + .send() + .await?; + assert_eq!(resp.status(), 401); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn create_and_list_keys() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env + .client + .post(env.url("/api/keys")) + .header("authorization", env.auth_header()) + .json(&serde_json::json!({"name": "my-key"})) + .send() + .await?; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await?; + assert!(body["key"].as_str().is_some()); + assert_eq!(body["info"]["name"], "my-key"); + + // seed key + new key + let resp = env + .client + .get(env.url("/api/keys")) + .header("authorization", env.auth_header()) + .send() + .await?; + assert_eq!(resp.status(), 200); + let keys: Vec = resp.json().await?; + assert_eq!(keys.len(), 2); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn revoke_key() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env + .client + .post(env.url("/api/keys")) + .header("authorization", env.auth_header()) + .json(&serde_json::json!({"name": "to-revoke"})) + .send() + .await?; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await?; + let key_id = body["info"]["id"].as_str().ok_or("missing key id")?; + + let resp = env + .client + .delete(env.url(&format!("/api/keys/{key_id}"))) + .header("authorization", env.auth_header()) + .send() + .await?; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await?; + assert_eq!(body["revoked"], true); + + // only seed key remains + let resp = env + .client + .get(env.url("/api/keys")) + .header("authorization", env.auth_header()) + .send() + .await?; + let keys: Vec = resp.json().await?; + assert_eq!(keys.len(), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn revoked_key_cannot_authenticate() -> TestResult { + let env = AuthTestEnv::start().await?; + + // create a second key + let resp = env + .client + .post(env.url("/api/keys")) + .header("authorization", env.auth_header()) + .json(&serde_json::json!({"name": "ephemeral"})) + .send() + .await?; + let body: serde_json::Value = resp.json().await?; + let new_key = body["key"].as_str().ok_or("missing key")?.to_string(); + let new_key_id = body["info"]["id"].as_str().ok_or("missing key id")?; + + // verify it works + let resp = env + .client + .get(env.url("/api/keys")) + .header("authorization", format!("Bearer {new_key}")) + .send() + .await?; + assert_eq!(resp.status(), 200); + + // revoke it + env.client + .delete(env.url(&format!("/api/keys/{new_key_id}"))) + .header("authorization", env.auth_header()) + .send() + .await?; + + // revoked key should be rejected + let resp = env + .client + .get(env.url("/api/keys")) + .header("authorization", format!("Bearer {new_key}")) + .send() + .await?; + assert_eq!(resp.status(), 401); + + Ok(()) +} diff --git a/crates/funnel-e2e/tests/e2e/harness.rs b/crates/funnel-e2e/tests/e2e/harness.rs index 68ccaad..bf329c8 100644 --- a/crates/funnel-e2e/tests/e2e/harness.rs +++ b/crates/funnel-e2e/tests/e2e/harness.rs @@ -99,11 +99,11 @@ impl Drop for TestEnv { } } -fn free_port() -> Result { +pub fn free_port() -> Result { Ok(TcpListener::bind("127.0.0.1:0")?.local_addr()?.port()) } -fn binary_path(name: &str) -> Result { +pub fn binary_path(name: &str) -> Result { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let workspace_root = manifest_dir .parent() @@ -119,7 +119,7 @@ fn binary_path(name: &str) -> Result { Ok(path) } -fn log_file(name: &str) -> Result<(File, PathBuf), std::io::Error> { +pub fn log_file(name: &str) -> Result<(File, PathBuf), std::io::Error> { let path = std::env::temp_dir().join(format!("funnel-{name}-{}.log", std::process::id())); let file = File::create(&path)?; Ok((file, path)) @@ -215,7 +215,7 @@ fn start_client_process( Ok((child, log_path)) } -async fn wait_for_tcp(port: u16) { +pub async fn wait_for_tcp(port: u16) { let deadline = tokio::time::Instant::now() + READY_TIMEOUT; loop { assert!( diff --git a/crates/funnel-e2e/tests/e2e/main.rs b/crates/funnel-e2e/tests/e2e/main.rs index 7f02dd6..47e8c32 100644 --- a/crates/funnel-e2e/tests/e2e/main.rs +++ b/crates/funnel-e2e/tests/e2e/main.rs @@ -1,3 +1,4 @@ +mod auth; mod harness; use harness::TestEnv; diff --git a/crates/funnel-server/src/api/keys.rs b/crates/funnel-server/src/api/keys.rs index 2127fc1..f88e24d 100644 --- a/crates/funnel-server/src/api/keys.rs +++ b/crates/funnel-server/src/api/keys.rs @@ -6,14 +6,13 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::app::AppState; +use crate::auth::AuthUser; use crate::db::api_keys::ApiKeyView; use crate::error::AppError; #[derive(Deserialize)] pub struct CreateKeyRequest { pub name: String, - // TODO: extract user_id from auth middleware instead - pub user_id: Uuid, } #[derive(Serialize)] @@ -22,19 +21,20 @@ pub struct CreateKeyResponse { pub info: ApiKeyView, } -/// List all active API keys for a user. -pub async fn list(State(state): State>) -> Result>, AppError> { - // TODO: get user_id from auth middleware - let _keys = state.api_keys.list_for_user(Uuid::nil()).await?; - Ok(Json(vec![])) +pub async fn list( + State(state): State>, + AuthUser(user_id): AuthUser, +) -> Result>, AppError> { + let keys = state.api_keys.list_for_user(user_id).await?; + Ok(Json(keys)) } -/// Create a new API key. pub async fn create( State(state): State>, + AuthUser(user_id): AuthUser, Json(req): Json, ) -> Result, AppError> { - let (plaintext, info) = state.api_keys.create(req.user_id, &req.name).await?; + let (plaintext, info) = state.api_keys.create(user_id, &req.name).await?; Ok(Json(CreateKeyResponse { key: plaintext, @@ -42,14 +42,11 @@ pub async fn create( })) } -/// Revoke an API key. pub async fn revoke( State(state): State>, + AuthUser(user_id): AuthUser, Path(id): Path, ) -> Result, AppError> { - // TODO: get user_id from auth middleware - let user_id = Uuid::nil(); - let revoked = state.api_keys.revoke(id, user_id).await?; if !revoked { return Err(AppError::NotFound("api key not found".into())); diff --git a/crates/funnel-server/src/auth/mod.rs b/crates/funnel-server/src/auth/mod.rs index 8b13789..15c4285 100644 --- a/crates/funnel-server/src/auth/mod.rs +++ b/crates/funnel-server/src/auth/mod.rs @@ -1 +1,35 @@ +use std::sync::Arc; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use uuid::Uuid; + +use crate::app::AppState; +use crate::error::AppError; + +pub struct AuthUser(pub Uuid); + +impl FromRequestParts> for AuthUser { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let token = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or(AppError::Unauthorized)?; + + let api_key = state + .api_keys + .validate(token) + .await + .map_err(AppError::Store)? + .ok_or(AppError::Unauthorized)?; + + Ok(AuthUser(api_key.user_id)) + } +} diff --git a/crates/funnel-server/src/error.rs b/crates/funnel-server/src/error.rs index 36ee8cd..574db94 100644 --- a/crates/funnel-server/src/error.rs +++ b/crates/funnel-server/src/error.rs @@ -25,6 +25,9 @@ pub enum AppError { #[error("store error: {0}")] Store(#[from] StoreError), + #[error("unauthorized")] + Unauthorized, + #[error("internal error: {0}")] Internal(#[from] anyhow::Error), } @@ -32,6 +35,7 @@ pub enum AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { + Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), Self::TunnelNotFound(_) | Self::NotFound(_) => { (StatusCode::NOT_FOUND, self.to_string()) } diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index cbf5ae9..a6a138d 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -69,6 +69,10 @@ struct Cli { /// Use Let's Encrypt staging environment #[arg(long)] acme_staging: bool, + + /// Create a seed API key at startup and print it to stdout + #[arg(long)] + seed_api_key: bool, } #[tokio::main] @@ -81,6 +85,7 @@ async fn main() -> anyhow::Result<()> { .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), ) + .with_writer(std::io::stderr) .json() .init(); @@ -148,6 +153,15 @@ async fn run_plain(cli: Cli, pool: Option) -> anyhow::Result<()> { let state = build_state(pool, false); let router = app::build_router(Arc::clone(&state), metrics_handle); + if cli.seed_api_key { + let (plaintext, _) = state + .api_keys + .create(uuid::Uuid::nil(), "seed") + .await + .map_err(|e| anyhow::anyhow!("failed to create seed api key: {e}"))?; + println!("{plaintext}"); + } + let quic_handle = quic::config::spawn_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; let addr: SocketAddr = format!("{}:{}", cli.host, cli.port).parse()?; From 22b72571845ed0ec88ee132466e9200bf0f4aa66 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 10 May 2026 14:41:14 +0200 Subject: [PATCH 23/42] feat(client): add startup banner, spinner, and colored access log for http command --- Cargo.lock | 332 +++++++++++++++++--------- crates/funnel-client/Cargo.toml | 7 +- crates/funnel-client/src/display.rs | 126 ++++++++++ crates/funnel-client/src/forwarder.rs | 5 +- crates/funnel-client/src/main.rs | 332 ++++++++++++++++++-------- crates/funnel-client/src/runner.rs | 16 +- crates/funnel-client/src/tunnel.rs | 41 +++- 7 files changed, 633 insertions(+), 226 deletions(-) create mode 100644 crates/funnel-client/src/display.rs diff --git a/Cargo.lock b/Cargo.lock index e0b7692..14c8eea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,16 +141,6 @@ dependencies = [ "syn", ] -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "async-lock" version = "3.4.2" @@ -218,12 +208,6 @@ dependencies = [ "syn", ] -[[package]] -name = "auto-future" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373" - [[package]] name = "autocfg" version = "1.5.0" @@ -632,47 +616,13 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core 0.4.5", - "bytes", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.9.0", - "hyper-util", - "itoa", - "matchit 0.7.3", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "axum" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "axum-core 0.5.6", + "axum-core", "bytes", "form_urlencoded", "futures-util", @@ -682,7 +632,7 @@ dependencies = [ "hyper 1.9.0", "hyper-util", "itoa", - "matchit 0.8.4", + "matchit", "memchr", "mime", "percent-encoding", @@ -699,27 +649,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "axum-core" version = "0.5.6" @@ -763,17 +692,16 @@ dependencies = [ [[package]] name = "axum-test" -version = "16.4.1" +version = "20.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e3a443d2608936a02a222da7b746eb412fede7225b3030b64fe9be99eab8dc" +checksum = "3a86bfe2ef15bee102ac34912f7f4542b0bb37dc464fa55461763999c4d625e7" dependencies = [ "anyhow", - "assert-json-diff", - "auto-future", - "axum 0.7.9", + "axum", "bytes", "bytesize", "cookie", + "expect-json", "http 1.4.0", "http-body-util", "hyper 1.9.0", @@ -785,7 +713,6 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "smallvec", "tokio", "tower", "url", @@ -870,9 +797,9 @@ dependencies = [ [[package]] name = "bytesize" -version = "1.3.3" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" +checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" [[package]] name = "camino" @@ -910,6 +837,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -1034,6 +972,19 @@ dependencies = [ "winnow 1.0.2", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1372,12 +1323,38 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -1420,6 +1397,35 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "expect-json" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869f97f4abe8e78fc812a94ad6b721d72c4fb5532877c79610f2c238d7ccf6c4" +dependencies = [ + "chrono", + "email_address", + "expect-json-macros", + "num", + "regex", + "serde", + "serde_json", + "thiserror 2.0.18", + "typetag", + "uuid", +] + +[[package]] +name = "expect-json-macros" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6fdf550180a6c29a28cb9aac262dc0064c25735641d2317f670075e9a469d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "fastbloom" version = "0.14.1" @@ -1503,14 +1509,19 @@ name = "funnel-client" version = "0.1.0" dependencies = [ "anyhow", + "axum", "clap", + "config", + "console", "dirs", "funnel-core", "http 1.4.0", + "indicatif", "quinn", "reqwest", "rustls 0.23.40", "serde", + "serde_json", "tokio", "tokio-util", "toml 0.8.23", @@ -1542,7 +1553,7 @@ dependencies = [ name = "funnel-e2e" version = "0.1.0" dependencies = [ - "axum 0.8.9", + "axum", "reqwest", "serde_json", "tokio", @@ -1555,13 +1566,15 @@ dependencies = [ "anyhow", "aws-config", "aws-sdk-route53", - "axum 0.8.9", + "axum", "axum-server", "axum-test", "chrono", "clap", "dashmap", "funnel-core", + "getrandom 0.3.4", + "hex", "http-body-util", "instant-acme", "ipnetwork", @@ -1583,6 +1596,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "urlencoding", "uuid", "x509-parser", ] @@ -1741,6 +1755,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -2259,6 +2274,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "instant-acme" version = "0.8.5" @@ -2291,6 +2319,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2694,12 +2731,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "matchit" version = "0.8.4" @@ -2775,16 +2806,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2836,6 +2857,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2862,6 +2897,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2888,6 +2932,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2898,6 +2953,12 @@ dependencies = [ "libm", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "oco_ref" version = "0.2.1" @@ -3326,6 +3387,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3364,6 +3436,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xoshiro" version = "0.7.0" @@ -3651,18 +3729,17 @@ dependencies = [ [[package]] name = "rust-multipart-rfc7578_2" -version = "0.6.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b748410c0afdef2ebbe3685a6a862e2ee937127cdaae623336a459451c8d57" +checksum = "00bdaa068902270ca7fa8619775e1838e23a63620abac0947ce0f715819b8cec" dependencies = [ "bytes", "futures-core", "futures-util", - "http 0.2.12", + "http 1.4.0", "mime", - "mime_guess", - "rand 0.8.6", - "thiserror 1.0.69", + "rand 0.10.1", + "thiserror 2.0.18", ] [[package]] @@ -4922,6 +4999,12 @@ dependencies = [ "syn", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.0" @@ -4929,10 +5012,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] -name = "unicase" -version = "2.9.0" +name = "typetag" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "unicode-bidi" @@ -4967,6 +5068,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -5395,6 +5502,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/crates/funnel-client/Cargo.toml b/crates/funnel-client/Cargo.toml index a1c605e..ca60912 100644 --- a/crates/funnel-client/Cargo.toml +++ b/crates/funnel-client/Cargo.toml @@ -10,13 +10,18 @@ workspace = true funnel-core.workspace = true anyhow.workspace = true +axum.workspace = true clap = { workspace = true, features = ["derive"] } +config_rs = { package = "config", version = "0.15", default-features = false, features = ["toml"] } dirs = "6" +console = "0.15" http.workspace = true +indicatif = "0.17" quinn.workspace = true -reqwest = { workspace = true, features = ["rustls", "stream"] } +reqwest = { workspace = true, features = ["rustls", "stream", "json"] } rustls = { workspace = true, default-features = false, features = ["ring", "std"] } serde.workspace = true +serde_json.workspace = true tokio.workspace = true tokio-util.workspace = true toml = "0.8" diff --git a/crates/funnel-client/src/display.rs b/crates/funnel-client/src/display.rs new file mode 100644 index 0000000..dd2a230 --- /dev/null +++ b/crates/funnel-client/src/display.rs @@ -0,0 +1,126 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use console::Style; +use indicatif::{ProgressBar, ProgressStyle}; + +const SPINNER_TICK_MS: u64 = 80; +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +pub struct TunnelDisplay { + pb: ProgressBar, + requests: AtomicU64, + started: Instant, +} + +pub struct RequestResult { + pub method: String, + pub path: String, + pub status: u16, + pub duration: Duration, +} + +impl TunnelDisplay { + pub fn new() -> Self { + let pb = ProgressBar::new_spinner(); + let style = ProgressStyle::default_spinner() + .tick_strings(SPINNER_FRAMES) + .template("{spinner} {msg}") + .expect("valid template"); + pb.set_style(style); + pb.enable_steady_tick(Duration::from_millis(SPINNER_TICK_MS)); + pb.set_message("waiting for requests..."); + + Self { + pb, + requests: AtomicU64::new(0), + started: Instant::now(), + } + } + + pub fn set_message(&self, msg: &str) { + self.pb.set_message(msg.to_string()); + } + + pub fn println(&self, msg: &str) { + self.pb.println(msg); + } + + pub fn log_request(&self, result: &RequestResult) { + self.requests.fetch_add(1, Ordering::Relaxed); + + let status_style = match result.status { + 200..=299 => Style::new().green(), + 300..=499 => Style::new().yellow(), + _ => Style::new().red(), + }; + + let duration = format_duration(result.duration); + let status = status_style.apply_to(result.status); + + self.pb.println(format!( + "{} {} {} {}", + result.method, result.path, status, duration + )); + } + + pub fn finish(&self) { + let reqs = self.requests.load(Ordering::Relaxed); + let uptime = self.started.elapsed(); + + self.pb.finish_and_clear(); + + println!(); + println!("session summary"); + println!(" requests {reqs}"); + println!(" uptime {}", format_uptime(uptime)); + } +} + +fn format_duration(d: Duration) -> String { + let ms = d.as_millis(); + if ms < 1000 { + format!("{ms}ms") + } else { + format!("{:.1}s", d.as_secs_f64()) + } +} + +fn format_uptime(d: Duration) -> String { + let total_secs = d.as_secs(); + let hours = total_secs / 3600; + let mins = (total_secs % 3600) / 60; + let secs = total_secs % 60; + + if hours > 0 { + format!("{hours}h {mins}m {secs}s") + } else if mins > 0 { + format!("{mins}m {secs}s") + } else { + format!("{secs}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_millis() { + assert_eq!(format_duration(Duration::from_millis(12)), "12ms"); + assert_eq!(format_duration(Duration::from_millis(999)), "999ms"); + } + + #[test] + fn format_duration_seconds() { + assert_eq!(format_duration(Duration::from_millis(1200)), "1.2s"); + assert_eq!(format_duration(Duration::from_secs(5)), "5.0s"); + } + + #[test] + fn format_uptime_units() { + assert_eq!(format_uptime(Duration::from_secs(45)), "45s"); + assert_eq!(format_uptime(Duration::from_secs(125)), "2m 5s"); + assert_eq!(format_uptime(Duration::from_secs(3661)), "1h 1m 1s"); + } +} diff --git a/crates/funnel-client/src/forwarder.rs b/crates/funnel-client/src/forwarder.rs index 635377d..7ca14f1 100644 --- a/crates/funnel-client/src/forwarder.rs +++ b/crates/funnel-client/src/forwarder.rs @@ -33,10 +33,7 @@ impl Forwarder { pub async fn forward(&self, meta: RequestMeta, body: reqwest::Body) -> (ResponseMeta, Vec) { match self.try_forward(&meta, body).await { Ok(resp) => resp, - Err(e) => { - tracing::error!(error = %e, method = %meta.method, path = %meta.path, "forwarding failed"); - error_response(502, &format!("local service error: {e}")) - } + Err(e) => error_response(502, &format!("local service error: {e}")), } } diff --git a/crates/funnel-client/src/main.rs b/crates/funnel-client/src/main.rs index 85adbe7..ca0c206 100644 --- a/crates/funnel-client/src/main.rs +++ b/crates/funnel-client/src/main.rs @@ -1,7 +1,14 @@ +mod auth; mod config; +mod display; mod forwarder; +mod keys; mod runner; +mod status; mod tunnel; +mod whoami; + +use std::sync::Arc; use clap::{Parser, Subcommand}; use tokio_util::sync::CancellationToken; @@ -10,15 +17,22 @@ use tracing_subscriber::EnvFilter; use funnel_core::tunnel::id::TunnelId; #[derive(Parser)] -#[command(name = "funnel", about = "tunnel client for exposing local services")] +#[command( + name = "funnel", + about = "expose local services through secure tunnels" +)] struct Cli { + /// context to use (overrides current_context in config) + #[arg(long, short, global = true)] + context: Option, + #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { - /// create an http tunnel + /// create an http tunnel to a local service Http { /// local address or port to forward to (e.g. "3000" or "localhost:3000") address: String, @@ -31,46 +45,98 @@ enum Command { #[arg(short, long)] id: Option, - /// inlet configuration to use - #[arg(long, default_value = "default")] - inlet: String, - /// authentication token (overrides config) #[arg(short, long)] token: Option, - /// quic port on the server - #[arg(long, default_value_t = 4433)] - quic_port: u16, + /// quic port on the server (overrides config) + #[arg(long)] + quic_port: Option, - /// skip tls certificate verification (for development with self signed certs) + /// skip tls certificate verification (for development) #[arg(long)] insecure: bool, }, - /// manage client configuration + /// log in via oauth + Login { + /// oauth provider name + #[arg(long, default_value = "github")] + provider: String, + }, + /// log out (clear token for current context) + Logout, + /// show the currently authenticated user + Whoami, + /// show active tunnels on the server + Status, + /// manage api keys + Keys { + #[command(subcommand)] + command: KeysCommand, + }, + /// manage server contexts + Context { + #[command(subcommand)] + command: ContextCommand, + }, + /// view configuration Config { #[command(subcommand)] - action: ConfigAction, + command: ConfigCommand, + }, +} + +#[derive(Subcommand)] +enum KeysCommand { + /// list api keys + List, + /// create a new api key + Create { + /// name for the new key + name: String, + /// comma separated scopes (defaults to management,tunnels) + #[arg(long)] + scopes: Option, + }, + /// revoke an api key + Revoke { + /// key id to revoke + id: String, }, } #[derive(Subcommand)] -enum ConfigAction { - /// save authentication token - SetToken { - token: String, - #[arg(long, default_value = "default")] - inlet: String, +enum ContextCommand { + /// list all contexts + List, + /// switch to a different context + Use { + /// context name to switch to + name: String, }, - /// save server url - SetServer { - url: String, - #[arg(long, default_value = "default")] - inlet: String, + /// create a new context + Create { + /// context name + name: String, + /// server url + #[arg(long)] + server: String, + /// quic port + #[arg(long)] + quic_port: Option, + }, + /// delete a context + Delete { + /// context name to delete + name: String, }, +} + +#[derive(Subcommand)] +enum ConfigCommand { /// show current configuration Show, - /// show config file path + /// print config file path Path, } @@ -80,52 +146,97 @@ async fn main() -> anyhow::Result<()> { .install_default() .map_err(|_| anyhow::anyhow!("failed to install default crypto provider"))?; + let cli = Cli::parse(); + + let default_filter = if matches!(cli.command, Command::Http { .. }) { + "error" + } else { + "info" + }; + tracing_subscriber::fmt() .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter)), ) .init(); - let cli = Cli::parse(); + let ctx_override = cli.context.as_deref(); match cli.command { Command::Http { address, server, id, - inlet, token, quic_port, insecure, - } => run_http(address, server, id, inlet, token, quic_port, insecure).await?, - Command::Config { action } => run_config(action)?, + } => { + run_http( + ctx_override, + address, + server, + id, + token, + quic_port, + insecure, + ) + .await + } + Command::Login { provider } => { + let cfg = config::load()?; + let name = ctx_override.unwrap_or(&cfg.current_context).to_string(); + auth::login(&name, &provider).await + } + Command::Logout => { + let cfg = config::load()?; + let name = ctx_override.unwrap_or(&cfg.current_context).to_string(); + config::clear_token(&name)?; + println!("logged out from context '{name}'"); + Ok(()) + } + Command::Whoami => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; + whoami::run(&resolved.server, &token, &resolved.name).await + } + Command::Status => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; + status::run(&resolved.server, &token).await + } + Command::Keys { command } => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; + match command { + KeysCommand::List => keys::list(&resolved.server, &token).await, + KeysCommand::Create { name, scopes } => { + keys::create(&resolved.server, &token, &name, scopes.as_deref()).await + } + KeysCommand::Revoke { id } => keys::revoke(&resolved.server, &token, &id).await, + } + } + Command::Context { command } => run_context(command), + Command::Config { command } => run_config(&command), } - - Ok(()) } +#[allow(clippy::fn_params_excessive_bools)] async fn run_http( + ctx_override: Option<&str>, address: String, server_flag: Option, id_flag: Option, - inlet_name: String, token_flag: Option, - quic_port: u16, + quic_port_flag: Option, insecure: bool, ) -> anyhow::Result<()> { let local_addr = normalize_address(&address); - let cfg = match config::load() { - Ok(cfg) => cfg, - Err(e) => { - tracing::warn!(error = %e, "failed to load config, using defaults"); - config::Config::default() - } - }; - let inlet = config::get_inlet(&cfg, &inlet_name); + let cfg = config::load().unwrap_or_default(); + let resolved = config::resolve(&cfg, ctx_override).ok(); let server_url = server_flag - .or_else(|| inlet.map(|i| i.server.clone())) + .or_else(|| resolved.as_ref().map(|r| r.server.clone())) .filter(|s| !s.is_empty()) .map(|s| { if s.contains("://") { @@ -136,25 +247,32 @@ async fn run_http( }) .ok_or_else(|| { anyhow::anyhow!( - "no server configured. use --server or run: funnel config set-server " + "no server configured. use --server or run: funnel context create default --server " ) })?; - let token = token_flag.or_else(|| inlet.and_then(|i| i.token.clone())); + let token = token_flag.or_else(|| resolved.as_ref().and_then(|r| r.token.clone())); + let quic_port = quic_port_flag.unwrap_or_else(|| { + resolved + .as_ref() + .map_or(config::DEFAULT_QUIC_PORT, |r| r.quic_port) + }); let tunnel_id = match id_flag { Some(raw) => TunnelId::new(raw)?, None => TunnelId::generate(), }; - if let Some(public_url) = runner::build_public_url(&server_url, &tunnel_id) { - tracing::info!( - tunnel_id = %tunnel_id, - local = %local_addr, - public_url = %public_url, - "starting tunnel" - ); - } + let public_url = + runner::build_public_url(&server_url, &tunnel_id).unwrap_or_else(|| format!("")); + + println!("funnel\n"); + println!(" public url {public_url}"); + println!(" forwarding {local_addr}"); + println!(" tunnel id {tunnel_id}"); + println!(); + + let display = Arc::new(display::TunnelDisplay::new()); let client = tunnel::TunnelClient::new( tunnel_id, @@ -170,62 +288,90 @@ async fn run_http( tokio::spawn(async move { tokio::signal::ctrl_c().await.ok(); - tracing::info!("received shutdown signal"); shutdown_signal.cancel(); }); - runner::run(&client, shutdown).await; + runner::run(&client, shutdown, &display).await; + + display.finish(); Ok(()) } -fn normalize_address(addr: &str) -> String { - if addr.contains(':') { - addr.to_string() - } else { - format!("localhost:{addr}") +fn run_context(command: ContextCommand) -> anyhow::Result<()> { + match command { + ContextCommand::List => { + let cfg = config::load()?; + if cfg.contexts.is_empty() { + println!("no contexts configured"); + println!(" create one with: funnel context create --server "); + return Ok(()); + } + for (name, ctx) in &cfg.contexts { + let marker = if name == &cfg.current_context { + " *" + } else { + "" + }; + let token_status = if ctx.token.is_some() { + "authenticated" + } else { + "no token" + }; + println!("{name}{marker}"); + println!(" server: {}", ctx.server); + println!(" status: {token_status}"); + if ctx.quic_port != config::DEFAULT_QUIC_PORT { + println!(" quic: {}", ctx.quic_port); + } + println!(); + } + Ok(()) + } + ContextCommand::Use { name } => { + config::set_current_context(&name)?; + println!("switched to context '{name}'"); + Ok(()) + } + ContextCommand::Create { + name, + server, + quic_port, + } => { + config::create_context(&name, &server, quic_port)?; + println!("created context '{name}' ({server})"); + Ok(()) + } + ContextCommand::Delete { name } => { + config::delete_context(&name)?; + println!("deleted context '{name}'"); + Ok(()) + } } } -fn run_config(action: ConfigAction) -> anyhow::Result<()> { - match action { - ConfigAction::SetToken { token, inlet } => { - config::set_token(&inlet, &token)?; - println!("token saved to inlet \"{inlet}\""); - println!(" config: {}", config::config_path().display()); - } - ConfigAction::SetServer { url, inlet } => { - config::set_server(&inlet, &url)?; - println!("server saved to inlet \"{inlet}\""); - println!(" config: {}", config::config_path().display()); - } - ConfigAction::Show => { +fn run_config(command: &ConfigCommand) -> anyhow::Result<()> { + match command { + ConfigCommand::Show => { let cfg = config::load()?; - println!("config: {}\n", config::config_path().display()); - if cfg.inlets.is_empty() { - println!("no inlets configured."); - } else { - for (name, inlet) in &cfg.inlets { - println!("[{name}]"); - if !inlet.server.is_empty() { - println!(" server: {}", inlet.server); - } - if let Some(domain) = &inlet.domain { - println!(" domain: {domain}"); - } - if let Some(token) = &inlet.token { - let visible = &token[..token.len().min(10)]; - println!(" token: {visible}..."); - } - println!(); - } - } + let content = toml::to_string_pretty(&cfg)?; + println!("# {}\n", config::config_path().display()); + println!("{content}"); + Ok(()) } - ConfigAction::Path => { + ConfigCommand::Path => { println!("{}", config::config_path().display()); + Ok(()) } } - Ok(()) +} + +fn normalize_address(addr: &str) -> String { + if addr.contains(':') { + addr.to_string() + } else { + format!("localhost:{addr}") + } } #[cfg(test)] diff --git a/crates/funnel-client/src/runner.rs b/crates/funnel-client/src/runner.rs index c186695..25884d7 100644 --- a/crates/funnel-client/src/runner.rs +++ b/crates/funnel-client/src/runner.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use std::time::Duration; use tokio_util::sync::CancellationToken; @@ -5,12 +6,13 @@ use url::Url; use funnel_core::tunnel::id::TunnelId; +use crate::display::TunnelDisplay; use crate::tunnel::TunnelClient; const INITIAL_BACKOFF: Duration = Duration::from_secs(1); const MAX_BACKOFF: Duration = Duration::from_secs(30); -pub async fn run(client: &TunnelClient, shutdown: CancellationToken) { +pub async fn run(client: &TunnelClient, shutdown: CancellationToken, display: &Arc) { let mut attempt: u32 = 0; loop { @@ -18,31 +20,29 @@ pub async fn run(client: &TunnelClient, shutdown: CancellationToken) { break; } - tracing::info!(attempt = attempt + 1, "connecting to server"); + display.set_message("connecting to server..."); let run_cancel = shutdown.child_token(); - match client.run(run_cancel).await { + match client.run(run_cancel, display).await { Ok(()) => { attempt = 0; - tracing::warn!("connection lost, reconnecting"); + display.println("connection lost, reconnecting..."); } Err(e) => { - tracing::error!(error = %e, "connection failed"); + display.println(&format!("connection failed: {e}")); } } let delay = backoff_delay(attempt); attempt = attempt.saturating_add(1); - tracing::info!(delay_secs = delay.as_secs_f64(), "reconnecting"); + display.set_message(&format!("reconnecting in {}s...", delay.as_secs())); tokio::select! { () = tokio::time::sleep(delay) => {}, () = shutdown.cancelled() => break, } } - - tracing::info!("client shut down"); } fn backoff_delay(attempt: u32) -> Duration { diff --git a/crates/funnel-client/src/tunnel.rs b/crates/funnel-client/src/tunnel.rs index 0d268bf..6268059 100644 --- a/crates/funnel-client/src/tunnel.rs +++ b/crates/funnel-client/src/tunnel.rs @@ -1,6 +1,6 @@ use std::net::SocketAddr; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio_util::io::ReaderStream; use tokio_util::sync::CancellationToken; @@ -12,6 +12,7 @@ use funnel_core::protocol::handshake::{Handshake, HandshakeResponse}; use funnel_core::protocol::request::RequestMeta; use funnel_core::tunnel::id::TunnelId; +use crate::display::{RequestResult, TunnelDisplay}; use crate::forwarder::Forwarder; const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); @@ -42,7 +43,8 @@ impl TunnelClient { .to_string(); let quic_addr = resolve_quic_addr(&host, quic_port)?; - let client_config = build_client_config(insecure)?; + let skip_verify = insecure || is_loopback(&host, &quic_addr); + let client_config = build_client_config(skip_verify)?; // bind address must match the remote address family let bind_addr: SocketAddr = if quic_addr.is_ipv6() { @@ -65,11 +67,17 @@ impl TunnelClient { } /// connect to the server via quic and run the tunnel until it disconnects. - pub async fn run(&self, cancel: CancellationToken) -> anyhow::Result<()> { + pub async fn run( + &self, + cancel: CancellationToken, + display: &Arc, + ) -> anyhow::Result<()> { let conn = self.connect().await?; let forwarder = Arc::new(Forwarder::new(self.local_addr.clone())?); let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_REQUESTS)); + display.set_message("waiting for requests..."); + loop { tokio::select! { bi = conn.accept_bi() => { @@ -84,6 +92,7 @@ impl TunnelClient { let fwd = Arc::clone(&forwarder); let sem = Arc::clone(&semaphore); let cancel = cancel.clone(); + let display = Arc::clone(display); tokio::spawn(async move { let _permit = tokio::select! { @@ -94,8 +103,9 @@ impl TunnelClient { () = cancel.cancelled() => return, }; - if let Err(e) = handle_stream(send, recv, &fwd).await { - tracing::debug!(error = %e, "stream handler error"); + match handle_stream(send, recv, &fwd).await { + Ok(result) => display.log_request(&result), + Err(e) => display.println(&format!("stream error: {e}")), } }); } @@ -147,14 +157,12 @@ async fn handle_stream( mut send: quinn::SendStream, mut recv: quinn::RecvStream, forwarder: &Forwarder, -) -> anyhow::Result<()> { +) -> anyhow::Result { + let start = Instant::now(); let meta: RequestMeta = frame::read_meta(&mut recv).await?; - tracing::debug!( - method = %meta.method, - path = %meta.path, - "received request" - ); + let method = meta.method.clone(); + let path = meta.path.clone(); // stream body from quic directly to local service (no buffer cap) let body_stream = ReaderStream::new(recv); @@ -166,7 +174,16 @@ async fn handle_stream( send.write_all(&resp_body).await?; send.finish()?; - Ok(()) + Ok(RequestResult { + method, + path, + status: resp_meta.status, + duration: start.elapsed(), + }) +} + +fn is_loopback(host: &str, addr: &SocketAddr) -> bool { + matches!(host, "localhost" | "127.0.0.1" | "::1") || addr.ip().is_loopback() } fn resolve_quic_addr(host: &str, port: u16) -> anyhow::Result { From c2f95dbe057186a1c16123217bf6ee61717b7442 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 10 May 2026 14:41:25 +0200 Subject: [PATCH 24/42] fix(server): route tunnel subdomain requests to proxy before matching api routes --- crates/funnel-server/src/app.rs | 46 ++++++++++++++++++++++-- crates/funnel-server/src/proxy/router.rs | 2 +- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs index 0b47b15..4fc802b 100644 --- a/crates/funnel-server/src/app.rs +++ b/crates/funnel-server/src/app.rs @@ -1,14 +1,23 @@ use std::sync::Arc; use axum::Router; +use axum::body::Body; +use axum::extract::State; +use axum::http::Request; +use axum::middleware::{self, Next}; +use axum::response::Response; use axum::routing::get; use metrics_exporter_prometheus::PrometheusHandle; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; +use funnel_core::tunnel::id::TunnelId; + use crate::api; +use crate::auth::oauth::OAuthState; use crate::metrics; use crate::proxy; +use crate::store::account_store::AccountStore; use crate::store::api_key_store::ApiKeyStore; use crate::store::health::HealthReporter; use crate::store::session_recorder::SessionRecorder; @@ -18,12 +27,12 @@ use crate::store::user_store::UserStore; pub struct AppState { pub tunnels: Arc, pub api_keys: Arc, - #[allow(dead_code)] pub users: Arc, - #[allow(dead_code)] + pub accounts: Arc, pub sessions: Arc, pub health: Arc, pub is_tls: bool, + pub oauth_state: Option>, } pub fn build_router(state: Arc, metrics_handle: PrometheusHandle) -> Router { @@ -36,12 +45,45 @@ pub fn build_router(state: Arc, metrics_handle: PrometheusHandle) -> R ) .route("/keys", get(api::keys::list).post(api::keys::create)) .route("/keys/{id}", axum::routing::delete(api::keys::revoke)) + .route("/me", get(api::me::handler)) + .route("/accounts", get(api::accounts::list)) .route("/metrics", get(metrics::handler).with_state(metrics_handle)); + let auth_routes = Router::new() + .route("/{provider}/authorize", get(api::oauth::authorize)) + .route("/{provider}/callback", get(api::oauth::callback)); + Router::new() .nest("/api", api_routes) + .nest("/auth", auth_routes) .fallback(proxy::router::handle_tunnel_request) + .layer(middleware::from_fn_with_state( + Arc::clone(&state), + tunnel_routing, + )) .with_state(state) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) } + +/// if the first label of the host header matches a registered tunnel, +/// route directly to the tunnel proxy, bypassing api/auth routes +async fn tunnel_routing( + State(state): State>, + request: Request, + next: Next, +) -> Response { + let is_tunnel = request + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .and_then(proxy::router::extract_subdomain) + .and_then(|sub| TunnelId::new(sub).ok()) + .is_some_and(|id| state.tunnels.get(&id).is_some()); + + if is_tunnel { + proxy::router::handle_tunnel_request(State(state), request).await + } else { + next.run(request).await + } +} diff --git a/crates/funnel-server/src/proxy/router.rs b/crates/funnel-server/src/proxy/router.rs index 2e6cd99..e65d086 100644 --- a/crates/funnel-server/src/proxy/router.rs +++ b/crates/funnel-server/src/proxy/router.rs @@ -70,7 +70,7 @@ pub async fn handle_tunnel_request( } } -fn extract_subdomain(host: &str) -> Option<&str> { +pub fn extract_subdomain(host: &str) -> Option<&str> { let host = host.split(':').next().unwrap_or(host); let dot = host.find('.')?; From c0a8d1e5bf3b6a92b8c923f16d091541e00766d3 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 10 May 2026 16:00:24 +0200 Subject: [PATCH 25/42] style: apply rustfmt formatting --- crates/funnel-core/src/protocol/request.rs | 14 ++++++-------- crates/funnel-server/src/tls/cache.rs | 15 +++------------ crates/funnel-server/src/tls/manager.rs | 3 +-- 3 files changed, 10 insertions(+), 22 deletions(-) diff --git a/crates/funnel-core/src/protocol/request.rs b/crates/funnel-core/src/protocol/request.rs index 7ebc1d5..590d8cf 100644 --- a/crates/funnel-core/src/protocol/request.rs +++ b/crates/funnel-core/src/protocol/request.rs @@ -41,9 +41,7 @@ impl ResponseMeta { } /// convert a protocol header map into an http `HeaderMap`, skipping invalid entries. -pub fn to_header_map( - headers: &HashMap, S>, -) -> http::HeaderMap { +pub fn to_header_map(headers: &HashMap, S>) -> http::HeaderMap { let mut map = http::HeaderMap::new(); for (name, values) in headers { for value in values { @@ -122,10 +120,7 @@ mod tests { status: 404, headers: HashMap::new(), }; - assert_eq!( - meta.http_status().ok(), - Some(http::StatusCode::NOT_FOUND) - ); + assert_eq!(meta.http_status().ok(), Some(http::StatusCode::NOT_FOUND)); } #[test] @@ -144,7 +139,10 @@ mod tests { headers.insert("x-custom".into(), vec!["a".into(), "b".into()]); let map = to_header_map(&headers); - assert_eq!(map.get("content-type").and_then(|v| v.to_str().ok()), Some("text/html")); + assert_eq!( + map.get("content-type").and_then(|v| v.to_str().ok()), + Some("text/html") + ); assert_eq!(map.get_all("x-custom").iter().count(), 2); } diff --git a/crates/funnel-server/src/tls/cache.rs b/crates/funnel-server/src/tls/cache.rs index f4635e2..3bb26dd 100644 --- a/crates/funnel-server/src/tls/cache.rs +++ b/crates/funnel-server/src/tls/cache.rs @@ -27,10 +27,7 @@ impl CertCache { } pub fn get(&self, domain: &str) -> Option<(Arc, SystemTime)> { - let cache = self - .inner - .lock() - .unwrap_or_else(PoisonError::into_inner); + let cache = self.inner.lock().unwrap_or_else(PoisonError::into_inner); let cached = cache.peek(domain)?; let result = (Arc::clone(&cached.certified_key), cached.not_after); drop(cache); @@ -38,10 +35,7 @@ impl CertCache { } pub fn put(&self, domain: String, key: CertifiedKey, not_after: SystemTime) { - let mut cache = self - .inner - .lock() - .unwrap_or_else(PoisonError::into_inner); + let mut cache = self.inner.lock().unwrap_or_else(PoisonError::into_inner); cache.put( domain, CachedCert { @@ -52,10 +46,7 @@ impl CertCache { } pub fn domains_needing_renewal(&self, window: Duration) -> Vec { - let cache = self - .inner - .lock() - .unwrap_or_else(PoisonError::into_inner); + let cache = self.inner.lock().unwrap_or_else(PoisonError::into_inner); cache .iter() .filter(|(_, cert)| { diff --git a/crates/funnel-server/src/tls/manager.rs b/crates/funnel-server/src/tls/manager.rs index d3e72c9..ef149f1 100644 --- a/crates/funnel-server/src/tls/manager.rs +++ b/crates/funnel-server/src/tls/manager.rs @@ -93,8 +93,7 @@ impl CertificateManager { tracing::info!(domain = %domain, "ordering certificate from ACME"); - let (cert_pem, key_pem) = - acme::order_certificate(&self.account, domain, &provider).await?; + let (cert_pem, key_pem) = acme::order_certificate(&self.account, domain, &provider).await?; self.save_to_disk(domain, &cert_pem, &key_pem).await?; From 89d453e6975b0ad439f5c7b02eaf9361b01ae1c4 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 10 May 2026 16:00:50 +0200 Subject: [PATCH 26/42] refactor(server): split provider fields from users into accounts table --- crates/funnel-server/src/db/accounts.rs | 61 ++++++++ crates/funnel-server/src/db/mod.rs | 2 +- .../funnel-server/src/db/tunnel_sessions.rs | 2 - crates/funnel-server/src/db/users.rs | 137 ++---------------- .../funnel-server/src/store/account_store.rs | 14 ++ .../src/store/memory/account_store.rs | 82 +++++++++++ crates/funnel-server/src/store/memory/mod.rs | 1 + .../src/store/memory/user_store.rs | 99 ++++--------- crates/funnel-server/src/store/mod.rs | 2 +- .../src/store/pg/account_store.rs | 38 +++++ crates/funnel-server/src/store/pg/mod.rs | 1 + .../funnel-server/src/store/pg/user_store.rs | 34 ++--- crates/funnel-server/src/store/user_store.rs | 10 +- migrations/001_create_users.sql | 24 ++- 14 files changed, 286 insertions(+), 221 deletions(-) create mode 100644 crates/funnel-server/src/db/accounts.rs create mode 100644 crates/funnel-server/src/store/account_store.rs create mode 100644 crates/funnel-server/src/store/memory/account_store.rs create mode 100644 crates/funnel-server/src/store/pg/account_store.rs diff --git a/crates/funnel-server/src/db/accounts.rs b/crates/funnel-server/src/db/accounts.rs new file mode 100644 index 0000000..f0751b6 --- /dev/null +++ b/crates/funnel-server/src/db/accounts.rs @@ -0,0 +1,61 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct Account { + pub id: Uuid, + pub user_id: Uuid, + pub provider: String, + pub provider_account_id: String, + pub metadata: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub struct NewAccount { + pub user_id: Uuid, + pub provider: String, + pub provider_account_id: String, + pub metadata: serde_json::Value, +} + +pub async fn find_by_provider( + pool: &PgPool, + provider: &str, + provider_account_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Account>( + "SELECT * FROM accounts WHERE provider = $1 AND provider_account_id = $2", + ) + .bind(provider) + .bind(provider_account_id) + .fetch_optional(pool) + .await +} + +pub async fn create(pool: &PgPool, new_account: NewAccount) -> Result { + sqlx::query_as::<_, Account>( + r" + INSERT INTO accounts (user_id, provider, provider_account_id, metadata) + VALUES ($1, $2, $3, $4) + RETURNING * + ", + ) + .bind(new_account.user_id) + .bind(&new_account.provider) + .bind(&new_account.provider_account_id) + .bind(&new_account.metadata) + .fetch_one(pool) + .await +} + +pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Account>( + "SELECT * FROM accounts WHERE user_id = $1 ORDER BY created_at DESC", + ) + .bind(user_id) + .fetch_all(pool) + .await +} diff --git a/crates/funnel-server/src/db/mod.rs b/crates/funnel-server/src/db/mod.rs index 26e4ab3..74cb9a5 100644 --- a/crates/funnel-server/src/db/mod.rs +++ b/crates/funnel-server/src/db/mod.rs @@ -1,5 +1,5 @@ +pub mod accounts; pub mod api_keys; #[allow(dead_code)] pub mod tunnel_sessions; -#[allow(dead_code)] pub mod users; diff --git a/crates/funnel-server/src/db/tunnel_sessions.rs b/crates/funnel-server/src/db/tunnel_sessions.rs index 00ddd9d..b444729 100644 --- a/crates/funnel-server/src/db/tunnel_sessions.rs +++ b/crates/funnel-server/src/db/tunnel_sessions.rs @@ -102,8 +102,6 @@ mod tests { email: format!("session-test-{}@example.com", Uuid::now_v7()), name: Some("Session Test".into()), avatar_url: None, - provider: "github".into(), - provider_id: Uuid::now_v7().to_string(), }, ) .await diff --git a/crates/funnel-server/src/db/users.rs b/crates/funnel-server/src/db/users.rs index def0858..4ba1c0a 100644 --- a/crates/funnel-server/src/db/users.rs +++ b/crates/funnel-server/src/db/users.rs @@ -9,8 +9,8 @@ pub struct User { pub email: String, pub name: Option, pub avatar_url: Option, - pub provider: String, - pub provider_id: String, + pub role: String, + pub metadata: serde_json::Value, pub created_at: DateTime, pub updated_at: DateTime, } @@ -19,8 +19,6 @@ pub struct NewUser { pub email: String, pub name: Option, pub avatar_url: Option, - pub provider: String, - pub provider_id: String, } pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { @@ -37,142 +35,37 @@ pub async fn find_by_email(pool: &PgPool, email: &str) -> Result, s .await } -pub async fn find_by_provider( - pool: &PgPool, - provider: &str, - provider_id: &str, -) -> Result, sqlx::Error> { - sqlx::query_as::<_, User>("SELECT * FROM users WHERE provider = $1 AND provider_id = $2") - .bind(provider) - .bind(provider_id) - .fetch_optional(pool) - .await -} - pub async fn create(pool: &PgPool, new_user: NewUser) -> Result { sqlx::query_as::<_, User>( r" - INSERT INTO users (email, name, avatar_url, provider, provider_id) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO users (email, name, avatar_url) + VALUES ($1, $2, $3) RETURNING * ", ) .bind(&new_user.email) .bind(&new_user.name) .bind(&new_user.avatar_url) - .bind(&new_user.provider) - .bind(&new_user.provider_id) .fetch_one(pool) .await } -/// if a user with the same (provider, provider_id) exists, updates their profile. -/// otherwise creates a new row. -pub async fn upsert_from_oauth(pool: &PgPool, new_user: NewUser) -> Result { +pub async fn update_profile( + pool: &PgPool, + id: Uuid, + name: Option<&str>, + avatar_url: Option<&str>, +) -> Result { sqlx::query_as::<_, User>( r" - INSERT INTO users (email, name, avatar_url, provider, provider_id) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (provider, provider_id) - DO UPDATE SET - email = EXCLUDED.email, - name = EXCLUDED.name, - avatar_url = EXCLUDED.avatar_url, - updated_at = now() + UPDATE users SET name = $2, avatar_url = $3, updated_at = now() + WHERE id = $1 RETURNING * ", ) - .bind(&new_user.email) - .bind(&new_user.name) - .bind(&new_user.avatar_url) - .bind(&new_user.provider) - .bind(&new_user.provider_id) + .bind(id) + .bind(name) + .bind(avatar_url) .fetch_one(pool) .await } - -#[cfg(test)] -mod tests { - use super::*; - - type TestResult = Result<(), Box>; - - fn test_new_user() -> NewUser { - NewUser { - email: format!("test-{}@example.com", Uuid::now_v7()), - name: Some("Test User".into()), - avatar_url: None, - provider: "github".into(), - provider_id: Uuid::now_v7().to_string(), - } - } - - // run with: DATABASE_URL=postgres://... cargo test -- --ignored - #[tokio::test] - #[ignore = "requires database"] - async fn create_and_find_user() -> TestResult { - let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - - let new = test_new_user(); - let email = new.email.clone(); - - let created = create(&pool, new).await?; - assert_eq!(created.email, email); - assert!(created.id != Uuid::nil()); - - let found = find_by_id(&pool, created.id).await?; - assert!(found.is_some()); - assert_eq!(found.ok_or("expected Some")?.email, email); - - Ok(()) - } - - #[tokio::test] - #[ignore = "requires database"] - async fn find_nonexistent_user() -> TestResult { - let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - - let found = find_by_id(&pool, Uuid::now_v7()).await?; - assert!(found.is_none()); - - Ok(()) - } - - #[tokio::test] - #[ignore = "requires database"] - async fn upsert_creates_then_updates() -> TestResult { - let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - - let provider_id = Uuid::now_v7().to_string(); - - let first = upsert_from_oauth( - &pool, - NewUser { - email: format!("upsert-{}@example.com", Uuid::now_v7()), - name: Some("First".into()), - avatar_url: None, - provider: "github".into(), - provider_id: provider_id.clone(), - }, - ) - .await?; - - let second = upsert_from_oauth( - &pool, - NewUser { - email: format!("upsert-updated-{}@example.com", Uuid::now_v7()), - name: Some("Updated".into()), - avatar_url: Some("https://example.com/avatar.png".into()), - provider: "github".into(), - provider_id: provider_id.clone(), - }, - ) - .await?; - - assert_eq!(first.id, second.id); - assert_eq!(second.name.as_deref(), Some("Updated")); - assert!(second.avatar_url.is_some()); - - Ok(()) - } -} diff --git a/crates/funnel-server/src/store/account_store.rs b/crates/funnel-server/src/store/account_store.rs new file mode 100644 index 0000000..05abf0d --- /dev/null +++ b/crates/funnel-server/src/store/account_store.rs @@ -0,0 +1,14 @@ +use uuid::Uuid; + +use super::{BoxFuture, StoreError}; +use crate::db::accounts::{Account, NewAccount}; + +pub trait AccountStore: Send + Sync { + fn find_by_provider( + &self, + provider: &str, + provider_account_id: &str, + ) -> BoxFuture<'_, Result, StoreError>>; + fn create(&self, new_account: NewAccount) -> BoxFuture<'_, Result>; + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>>; +} diff --git a/crates/funnel-server/src/store/memory/account_store.rs b/crates/funnel-server/src/store/memory/account_store.rs new file mode 100644 index 0000000..ba817ea --- /dev/null +++ b/crates/funnel-server/src/store/memory/account_store.rs @@ -0,0 +1,82 @@ +use std::sync::{PoisonError, RwLock}; + +use chrono::Utc; +use uuid::Uuid; + +use crate::db::accounts::{Account, NewAccount}; +use crate::store::account_store::AccountStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct InMemoryAccountStore { + accounts: RwLock>, +} + +impl InMemoryAccountStore { + pub const fn new() -> Self { + Self { + accounts: RwLock::new(Vec::new()), + } + } +} + +impl AccountStore for InMemoryAccountStore { + fn find_by_provider( + &self, + provider: &str, + provider_account_id: &str, + ) -> BoxFuture<'_, Result, StoreError>> { + let provider = provider.to_string(); + let provider_account_id = provider_account_id.to_string(); + Box::pin(async move { + let accounts = self.accounts.read().unwrap_or_else(PoisonError::into_inner); + Ok(accounts + .iter() + .find(|a| a.provider == provider && a.provider_account_id == provider_account_id) + .cloned()) + }) + } + + fn create(&self, new_account: NewAccount) -> BoxFuture<'_, Result> { + Box::pin(async move { + let now = Utc::now(); + let account = Account { + id: Uuid::now_v7(), + user_id: new_account.user_id, + provider: new_account.provider, + provider_account_id: new_account.provider_account_id, + metadata: new_account.metadata, + created_at: now, + updated_at: now, + }; + { + let mut accounts = self + .accounts + .write() + .unwrap_or_else(PoisonError::into_inner); + + if accounts.iter().any(|a| { + a.provider == account.provider + && a.provider_account_id == account.provider_account_id + }) { + return Err(StoreError::Conflict( + "account already exists for this provider".into(), + )); + } + + accounts.push(account.clone()); + } + Ok(account) + }) + } + + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let accounts = self.accounts.read().unwrap_or_else(PoisonError::into_inner); + Ok(accounts + .iter() + .filter(|a| a.user_id == user_id) + .cloned() + .collect()) + }) + } +} diff --git a/crates/funnel-server/src/store/memory/mod.rs b/crates/funnel-server/src/store/memory/mod.rs index c428364..a556f32 100644 --- a/crates/funnel-server/src/store/memory/mod.rs +++ b/crates/funnel-server/src/store/memory/mod.rs @@ -1,3 +1,4 @@ +pub mod account_store; pub mod api_key_store; pub mod session_recorder; pub mod user_store; diff --git a/crates/funnel-server/src/store/memory/user_store.rs b/crates/funnel-server/src/store/memory/user_store.rs index 60f8e70..c2da335 100644 --- a/crates/funnel-server/src/store/memory/user_store.rs +++ b/crates/funnel-server/src/store/memory/user_store.rs @@ -35,15 +35,6 @@ impl UserStore for InMemoryUserStore { }) } - fn find_by_provider(&self, provider: &str, provider_id: &str) -> BoxFuture<'_, Result, StoreError>> { - let provider = provider.to_string(); - let provider_id = provider_id.to_string(); - Box::pin(async move { - let users = self.users.read().unwrap_or_else(PoisonError::into_inner); - Ok(users.iter().find(|u| u.provider == provider && u.provider_id == provider_id).cloned()) - }) - } - fn create(&self, new_user: NewUser) -> BoxFuture<'_, Result> { Box::pin(async move { let now = Utc::now(); @@ -52,8 +43,8 @@ impl UserStore for InMemoryUserStore { email: new_user.email, name: new_user.name, avatar_url: new_user.avatar_url, - provider: new_user.provider, - provider_id: new_user.provider_id, + role: "user".into(), + metadata: serde_json::json!({}), created_at: now, updated_at: now, }; @@ -65,32 +56,26 @@ impl UserStore for InMemoryUserStore { }) } - fn upsert_from_oauth(&self, new_user: NewUser) -> BoxFuture<'_, Result> { + fn update_profile( + &self, + id: Uuid, + name: Option<&str>, + avatar_url: Option<&str>, + ) -> BoxFuture<'_, Result> { + let name = name.map(ToString::to_string); + let avatar_url = avatar_url.map(ToString::to_string); Box::pin(async move { let mut users = self.users.write().unwrap_or_else(PoisonError::into_inner); - let result = if let Some(existing) = users.iter_mut().find(|u| u.provider == new_user.provider && u.provider_id == new_user.provider_id) { - existing.email = new_user.email; - existing.name = new_user.name; - existing.avatar_url = new_user.avatar_url; - existing.updated_at = Utc::now(); - existing.clone() - } else { - let now = Utc::now(); - let user = User { - id: Uuid::now_v7(), - email: new_user.email, - name: new_user.name, - avatar_url: new_user.avatar_url, - provider: new_user.provider, - provider_id: new_user.provider_id, - created_at: now, - updated_at: now, - }; - users.push(user.clone()); - user - }; + let user = users + .iter_mut() + .find(|u| u.id == id) + .ok_or(StoreError::NotFound)?; + user.name = name; + user.avatar_url = avatar_url; + user.updated_at = Utc::now(); + let snapshot = user.clone(); drop(users); - Ok(result) + Ok(snapshot) }) } } @@ -104,8 +89,6 @@ mod tests { email: email.to_string(), name: Some("Test User".to_string()), avatar_url: None, - provider: "github".to_string(), - provider_id: format!("gh_{email}"), } } @@ -138,41 +121,23 @@ mod tests { } #[tokio::test] - async fn find_by_provider() { - let store = InMemoryUserStore::new(); - store.create(make_new_user("c@test.com")).await.unwrap(); - - let found = store.find_by_provider("github", "gh_c@test.com").await.unwrap(); - assert!(found.is_some()); - - let missing = store.find_by_provider("github", "gh_nope").await.unwrap(); - assert!(missing.is_none()); - } - - #[tokio::test] - async fn upsert_creates_new_user() { + async fn update_profile_changes_fields() { let store = InMemoryUserStore::new(); - let user = store.upsert_from_oauth(make_new_user("d@test.com")).await.unwrap(); - assert_eq!(user.email, "d@test.com"); - - let found = store.find_by_id(user.id).await.unwrap(); - assert!(found.is_some()); + let user = store.create(make_new_user("c@test.com")).await.unwrap(); + + let updated = store + .update_profile(user.id, Some("New Name"), Some("https://img.test/1.png")) + .await + .unwrap(); + assert_eq!(updated.name, Some("New Name".into())); + assert_eq!(updated.avatar_url, Some("https://img.test/1.png".into())); + assert!(updated.updated_at > user.updated_at); } #[tokio::test] - async fn upsert_updates_existing_user() { + async fn new_user_gets_default_role() { let store = InMemoryUserStore::new(); - let original = store.upsert_from_oauth(make_new_user("e@test.com")).await.unwrap(); - - let mut updated_input = make_new_user("new_e@test.com"); - updated_input.provider = "github".to_string(); - updated_input.provider_id = "gh_e@test.com".to_string(); - updated_input.name = Some("Updated Name".to_string()); - - let updated = store.upsert_from_oauth(updated_input).await.unwrap(); - assert_eq!(updated.id, original.id); - assert_eq!(updated.email, "new_e@test.com"); - assert_eq!(updated.name, Some("Updated Name".to_string())); - assert!(updated.updated_at > original.updated_at); + let user = store.create(make_new_user("d@test.com")).await.unwrap(); + assert_eq!(user.role, "user"); } } diff --git a/crates/funnel-server/src/store/mod.rs b/crates/funnel-server/src/store/mod.rs index 42f59e1..9968235 100644 --- a/crates/funnel-server/src/store/mod.rs +++ b/crates/funnel-server/src/store/mod.rs @@ -1,3 +1,4 @@ +pub mod account_store; pub mod api_key_store; pub mod health; pub mod memory; @@ -5,7 +6,6 @@ pub mod pg; #[allow(dead_code)] pub mod session_recorder; pub mod tunnel_registry; -#[allow(dead_code)] pub mod user_store; use std::fmt; diff --git a/crates/funnel-server/src/store/pg/account_store.rs b/crates/funnel-server/src/store/pg/account_store.rs new file mode 100644 index 0000000..dd8267a --- /dev/null +++ b/crates/funnel-server/src/store/pg/account_store.rs @@ -0,0 +1,38 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db::accounts::{self, Account, NewAccount}; +use crate::store::account_store::AccountStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct PgAccountStore { + pool: PgPool, +} + +impl PgAccountStore { + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl AccountStore for PgAccountStore { + fn find_by_provider( + &self, + provider: &str, + provider_account_id: &str, + ) -> BoxFuture<'_, Result, StoreError>> { + let provider = provider.to_string(); + let provider_account_id = provider_account_id.to_string(); + Box::pin(async move { + Ok(accounts::find_by_provider(&self.pool, &provider, &provider_account_id).await?) + }) + } + + fn create(&self, new_account: NewAccount) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(accounts::create(&self.pool, new_account).await?) }) + } + + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(accounts::list_for_user(&self.pool, user_id).await?) }) + } +} diff --git a/crates/funnel-server/src/store/pg/mod.rs b/crates/funnel-server/src/store/pg/mod.rs index c428364..a556f32 100644 --- a/crates/funnel-server/src/store/pg/mod.rs +++ b/crates/funnel-server/src/store/pg/mod.rs @@ -1,3 +1,4 @@ +pub mod account_store; pub mod api_key_store; pub mod session_recorder; pub mod user_store; diff --git a/crates/funnel-server/src/store/pg/user_store.rs b/crates/funnel-server/src/store/pg/user_store.rs index 5f0bd42..48307c5 100644 --- a/crates/funnel-server/src/store/pg/user_store.rs +++ b/crates/funnel-server/src/store/pg/user_store.rs @@ -17,35 +17,31 @@ impl PgUserStore { impl UserStore for PgUserStore { fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>> { - Box::pin(async move { - Ok(users::find_by_id(&self.pool, id).await?) - }) + Box::pin(async move { Ok(users::find_by_id(&self.pool, id).await?) }) } fn find_by_email(&self, email: &str) -> BoxFuture<'_, Result, StoreError>> { let email = email.to_string(); - Box::pin(async move { - Ok(users::find_by_email(&self.pool, &email).await?) - }) - } - - fn find_by_provider(&self, provider: &str, provider_id: &str) -> BoxFuture<'_, Result, StoreError>> { - let provider = provider.to_string(); - let provider_id = provider_id.to_string(); - Box::pin(async move { - Ok(users::find_by_provider(&self.pool, &provider, &provider_id).await?) - }) + Box::pin(async move { Ok(users::find_by_email(&self.pool, &email).await?) }) } fn create(&self, new_user: NewUser) -> BoxFuture<'_, Result> { - Box::pin(async move { - Ok(users::create(&self.pool, new_user).await?) - }) + Box::pin(async move { Ok(users::create(&self.pool, new_user).await?) }) } - fn upsert_from_oauth(&self, new_user: NewUser) -> BoxFuture<'_, Result> { + fn update_profile( + &self, + id: Uuid, + name: Option<&str>, + avatar_url: Option<&str>, + ) -> BoxFuture<'_, Result> { + let name = name.map(ToString::to_string); + let avatar_url = avatar_url.map(ToString::to_string); Box::pin(async move { - Ok(users::upsert_from_oauth(&self.pool, new_user).await?) + Ok( + users::update_profile(&self.pool, id, name.as_deref(), avatar_url.as_deref()) + .await?, + ) }) } } diff --git a/crates/funnel-server/src/store/user_store.rs b/crates/funnel-server/src/store/user_store.rs index 6ad2897..388e5f4 100644 --- a/crates/funnel-server/src/store/user_store.rs +++ b/crates/funnel-server/src/store/user_store.rs @@ -1,12 +1,16 @@ use uuid::Uuid; -use crate::db::users::{NewUser, User}; use super::{BoxFuture, StoreError}; +use crate::db::users::{NewUser, User}; pub trait UserStore: Send + Sync { fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>>; fn find_by_email(&self, email: &str) -> BoxFuture<'_, Result, StoreError>>; - fn find_by_provider(&self, provider: &str, provider_id: &str) -> BoxFuture<'_, Result, StoreError>>; fn create(&self, new_user: NewUser) -> BoxFuture<'_, Result>; - fn upsert_from_oauth(&self, new_user: NewUser) -> BoxFuture<'_, Result>; + fn update_profile( + &self, + id: Uuid, + name: Option<&str>, + avatar_url: Option<&str>, + ) -> BoxFuture<'_, Result>; } diff --git a/migrations/001_create_users.sql b/migrations/001_create_users.sql index a8810cd..97ab884 100644 --- a/migrations/001_create_users.sql +++ b/migrations/001_create_users.sql @@ -3,13 +3,25 @@ CREATE TABLE users ( email TEXT NOT NULL UNIQUE, name TEXT, avatar_url TEXT, - provider TEXT NOT NULL, - provider_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - UNIQUE(provider, provider_id) + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_users_email ON users(email); -CREATE INDEX idx_users_provider ON users(provider, provider_id); + +CREATE TABLE accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_account_id TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE(provider, provider_account_id) +); + +CREATE INDEX idx_accounts_user ON accounts(user_id); +CREATE INDEX idx_accounts_provider ON accounts(provider, provider_account_id); From 6fb4c0d9a3e586170d3ac0ec210b1a0e76518e9f Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 10 May 2026 16:00:57 +0200 Subject: [PATCH 27/42] feat(server): add scope based permissions to api keys --- crates/funnel-server/src/api/keys.rs | 24 ++-- crates/funnel-server/src/api/tunnels.rs | 10 +- crates/funnel-server/src/auth/mod.rs | 77 ++++++++++- crates/funnel-server/src/db/api_keys.rs | 120 +++--------------- crates/funnel-server/src/error.rs | 8 +- .../funnel-server/src/store/api_key_store.rs | 10 +- .../src/store/memory/api_key_store.rs | 69 +++++++--- .../src/store/pg/api_key_store.rs | 18 +-- migrations/002_create_api_keys.sql | 1 + 9 files changed, 195 insertions(+), 142 deletions(-) diff --git a/crates/funnel-server/src/api/keys.rs b/crates/funnel-server/src/api/keys.rs index f88e24d..e69b64e 100644 --- a/crates/funnel-server/src/api/keys.rs +++ b/crates/funnel-server/src/api/keys.rs @@ -6,13 +6,14 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::app::AppState; -use crate::auth::AuthUser; -use crate::db::api_keys::ApiKeyView; +use crate::auth::{Management, Scoped}; +use crate::db::api_keys::{ApiKeyView, default_scopes}; use crate::error::AppError; #[derive(Deserialize)] pub struct CreateKeyRequest { pub name: String, + pub scopes: Option>, } #[derive(Serialize)] @@ -23,18 +24,25 @@ pub struct CreateKeyResponse { pub async fn list( State(state): State>, - AuthUser(user_id): AuthUser, + auth: Scoped, ) -> Result>, AppError> { - let keys = state.api_keys.list_for_user(user_id).await?; + let keys = state.api_keys.list_for_user(auth.user_id).await?; Ok(Json(keys)) } pub async fn create( State(state): State>, - AuthUser(user_id): AuthUser, + auth: Scoped, Json(req): Json, ) -> Result, AppError> { - let (plaintext, info) = state.api_keys.create(user_id, &req.name).await?; + let scopes = req.scopes.map_or_else(default_scopes, |s| { + serde_json::Value::Array(s.into_iter().map(serde_json::Value::String).collect()) + }); + + let (plaintext, info) = state + .api_keys + .create(auth.user_id, &req.name, &scopes) + .await?; Ok(Json(CreateKeyResponse { key: plaintext, @@ -44,10 +52,10 @@ pub async fn create( pub async fn revoke( State(state): State>, - AuthUser(user_id): AuthUser, + auth: Scoped, Path(id): Path, ) -> Result, AppError> { - let revoked = state.api_keys.revoke(id, user_id).await?; + let revoked = state.api_keys.revoke(id, auth.user_id).await?; if !revoked { return Err(AppError::NotFound("api key not found".into())); } diff --git a/crates/funnel-server/src/api/tunnels.rs b/crates/funnel-server/src/api/tunnels.rs index f925ba5..1cb9bab 100644 --- a/crates/funnel-server/src/api/tunnels.rs +++ b/crates/funnel-server/src/api/tunnels.rs @@ -6,15 +6,20 @@ use axum::extract::{Path, State}; use funnel_core::tunnel::id::TunnelId; use crate::app::AppState; +use crate::auth::{Management, Scoped}; use crate::error::AppError; use crate::tunnel::manager::TunnelInfo; -pub async fn list(State(state): State>) -> Json> { - Json(state.tunnels.list()) +pub async fn list( + State(state): State>, + _auth: Scoped, +) -> Result>, AppError> { + Ok(Json(state.tunnels.list())) } pub async fn get_tunnel( State(state): State>, + _auth: Scoped, Path(id): Path, ) -> Result, AppError> { let tunnel_id = TunnelId::new(id.clone())?; @@ -33,6 +38,7 @@ pub async fn get_tunnel( pub async fn delete( State(state): State>, + _auth: Scoped, Path(id): Path, ) -> Result, AppError> { let tunnel_id = TunnelId::new(id.clone())?; diff --git a/crates/funnel-server/src/auth/mod.rs b/crates/funnel-server/src/auth/mod.rs index 15c4285..0ddafdc 100644 --- a/crates/funnel-server/src/auth/mod.rs +++ b/crates/funnel-server/src/auth/mod.rs @@ -1,3 +1,7 @@ +pub mod generic; +pub mod github; +pub mod oauth; + use std::sync::Arc; use axum::extract::FromRequestParts; @@ -7,7 +11,16 @@ use uuid::Uuid; use crate::app::AppState; use crate::error::AppError; -pub struct AuthUser(pub Uuid); +pub struct AuthUser { + pub user_id: Uuid, + pub scopes: Vec, +} + +impl AuthUser { + fn has_scope(&self, scope: &str) -> bool { + self.scopes.iter().any(|s| s == scope) + } +} impl FromRequestParts> for AuthUser { type Rejection = AppError; @@ -30,6 +43,66 @@ impl FromRequestParts> for AuthUser { .map_err(AppError::Store)? .ok_or(AppError::Unauthorized)?; - Ok(AuthUser(api_key.user_id)) + let scopes = api_key + .scopes + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(ToString::to_string)) + .collect() + }) + .unwrap_or_default(); + + Ok(Self { + user_id: api_key.user_id, + scopes, + }) + } +} + +// scope aware extractors using the type system +// +// handlers declare their required scope in the type signature: +// async fn list(auth: Scoped) -> ... +// +// the extractor validates auth and checks the scope, returning 403 if missing. + +pub trait Scope: Send + Sync + 'static { + const NAME: &'static str; +} + +pub struct Management; +impl Scope for Management { + const NAME: &'static str = "management"; +} + +#[allow(dead_code)] +pub struct Tunnels; +impl Scope for Tunnels { + const NAME: &'static str = "tunnels"; +} + +pub struct Scoped { + pub user_id: Uuid, + _scope: std::marker::PhantomData, +} + +impl FromRequestParts> for Scoped { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let auth = AuthUser::from_request_parts(parts, state).await?; + + if !auth.has_scope(S::NAME) { + return Err(AppError::Forbidden); + } + + Ok(Self { + user_id: auth.user_id, + _scope: std::marker::PhantomData, + }) } } diff --git a/crates/funnel-server/src/db/api_keys.rs b/crates/funnel-server/src/db/api_keys.rs index a8f5b76..40fabb9 100644 --- a/crates/funnel-server/src/db/api_keys.rs +++ b/crates/funnel-server/src/db/api_keys.rs @@ -12,15 +12,25 @@ pub struct ApiKey { pub name: String, pub key_hash: String, pub key_prefix: String, + pub scopes: serde_json::Value, pub created_at: DateTime, pub revoked_at: Option>, } +impl ApiKey { + pub fn has_scope(&self, scope: &str) -> bool { + self.scopes + .as_array() + .is_some_and(|arr| arr.iter().any(|s| s.as_str() == Some(scope))) + } +} + #[derive(Debug, Clone, Serialize)] pub struct ApiKeyView { pub id: Uuid, pub name: String, pub key_prefix: String, + pub scopes: serde_json::Value, pub created_at: DateTime, pub revoked_at: Option>, } @@ -31,16 +41,22 @@ impl From for ApiKeyView { id: key.id, name: key.name, key_prefix: key.key_prefix, + scopes: key.scopes, created_at: key.created_at, revoked_at: key.revoked_at, } } } +pub fn default_scopes() -> serde_json::Value { + serde_json::json!(["management", "tunnels"]) +} + pub async fn create( pool: &PgPool, user_id: Uuid, name: &str, + scopes: &serde_json::Value, ) -> Result<(String, ApiKey), sqlx::Error> { let plaintext = auth::generate_api_key() .map_err(|e| sqlx::Error::Protocol(format!("failed to generate api key: {e}")))?; @@ -49,8 +65,8 @@ pub async fn create( let key = sqlx::query_as::<_, ApiKey>( r" - INSERT INTO api_keys (user_id, name, key_hash, key_prefix) - VALUES ($1, $2, $3, $4) + INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes) + VALUES ($1, $2, $3, $4, $5) RETURNING * ", ) @@ -58,6 +74,7 @@ pub async fn create( .bind(name) .bind(&hash) .bind(prefix.as_ref()) + .bind(scopes) .fetch_one(pool) .await?; @@ -93,102 +110,3 @@ pub async fn revoke(pool: &PgPool, key_id: Uuid, user_id: Uuid) -> Result 0) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::db::users; - - type TestResult = Result<(), Box>; - - async fn setup_user(pool: &PgPool) -> Result { - users::create( - pool, - users::NewUser { - email: format!("apikey-test-{}@example.com", Uuid::now_v7()), - name: Some("API Key Test".into()), - avatar_url: None, - provider: "github".into(), - provider_id: Uuid::now_v7().to_string(), - }, - ) - .await - } - - #[tokio::test] - #[ignore = "requires database"] - async fn create_and_validate_key() -> TestResult { - let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - - let user = setup_user(&pool).await?; - let (plaintext, key) = create(&pool, user.id, "test-key").await?; - - assert!(plaintext.starts_with("sk_")); - assert_eq!(key.name, "test-key"); - assert_eq!(key.user_id, user.id); - assert!(key.revoked_at.is_none()); - - let validated = validate(&pool, &plaintext).await?; - assert!(validated.is_some()); - assert_eq!(validated.ok_or("expected Some")?.id, key.id); - - Ok(()) - } - - #[tokio::test] - #[ignore = "requires database"] - async fn validate_invalid_key() -> TestResult { - let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - - let result = validate(&pool, "sk_totally_bogus_key").await?; - assert!(result.is_none()); - - Ok(()) - } - - #[tokio::test] - #[ignore = "requires database"] - async fn revoke_key_prevents_validation() -> TestResult { - let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - - let user = setup_user(&pool).await?; - let (plaintext, key) = create(&pool, user.id, "revoke-test").await?; - - let revoked = revoke(&pool, key.id, user.id).await?; - assert!(revoked); - - let validated = validate(&pool, &plaintext).await?; - assert!(validated.is_none()); - - Ok(()) - } - - #[tokio::test] - #[ignore = "requires database"] - async fn list_keys_for_user() -> TestResult { - let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - - let user = setup_user(&pool).await?; - create(&pool, user.id, "key-1").await?; - create(&pool, user.id, "key-2").await?; - - let keys = list_for_user(&pool, user.id).await?; - assert_eq!(keys.len(), 2); - - Ok(()) - } - - #[tokio::test] - #[ignore = "requires database"] - async fn duplicate_key_name_rejected() -> TestResult { - let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?; - - let user = setup_user(&pool).await?; - create(&pool, user.id, "dupe-name").await?; - - let result = create(&pool, user.id, "dupe-name").await; - assert!(result.is_err()); - - Ok(()) - } -} diff --git a/crates/funnel-server/src/error.rs b/crates/funnel-server/src/error.rs index 574db94..74195f1 100644 --- a/crates/funnel-server/src/error.rs +++ b/crates/funnel-server/src/error.rs @@ -28,6 +28,9 @@ pub enum AppError { #[error("unauthorized")] Unauthorized, + #[error("forbidden")] + Forbidden, + #[error("internal error: {0}")] Internal(#[from] anyhow::Error), } @@ -36,12 +39,11 @@ impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), + Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()), Self::TunnelNotFound(_) | Self::NotFound(_) => { (StatusCode::NOT_FOUND, self.to_string()) } - Self::InvalidTunnelId(_) => { - (StatusCode::BAD_REQUEST, self.to_string()) - } + Self::InvalidTunnelId(_) => (StatusCode::BAD_REQUEST, self.to_string()), Self::Store(e) => match e { StoreError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), StoreError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()), diff --git a/crates/funnel-server/src/store/api_key_store.rs b/crates/funnel-server/src/store/api_key_store.rs index 3959caf..9054c8c 100644 --- a/crates/funnel-server/src/store/api_key_store.rs +++ b/crates/funnel-server/src/store/api_key_store.rs @@ -1,11 +1,15 @@ use uuid::Uuid; -use crate::db::api_keys::{ApiKey, ApiKeyView}; use super::{BoxFuture, StoreError}; +use crate::db::api_keys::{ApiKey, ApiKeyView}; -#[allow(dead_code)] pub trait ApiKeyStore: Send + Sync { - fn create(&self, user_id: Uuid, name: &str) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>>; + fn create( + &self, + user_id: Uuid, + name: &str, + scopes: &serde_json::Value, + ) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>>; fn validate(&self, plaintext: &str) -> BoxFuture<'_, Result, StoreError>>; fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>>; fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result>; diff --git a/crates/funnel-server/src/store/memory/api_key_store.rs b/crates/funnel-server/src/store/memory/api_key_store.rs index 664f60a..ba8b35a 100644 --- a/crates/funnel-server/src/store/memory/api_key_store.rs +++ b/crates/funnel-server/src/store/memory/api_key_store.rs @@ -21,8 +21,14 @@ impl InMemoryApiKeyStore { } impl ApiKeyStore for InMemoryApiKeyStore { - fn create(&self, user_id: Uuid, name: &str) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>> { + fn create( + &self, + user_id: Uuid, + name: &str, + scopes: &serde_json::Value, + ) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>> { let name = name.to_string(); + let scopes = scopes.clone(); Box::pin(async move { let plaintext = auth::generate_api_key() .map_err(|e| StoreError::Other(format!("failed to generate api key: {e}")))?; @@ -35,6 +41,7 @@ impl ApiKeyStore for InMemoryApiKeyStore { name: name.clone(), key_hash: hash, key_prefix: prefix.as_ref().to_string(), + scopes, created_at: Utc::now(), revoked_at: None, }; @@ -42,8 +49,13 @@ impl ApiKeyStore for InMemoryApiKeyStore { let view = { let mut keys = self.keys.write().unwrap_or_else(PoisonError::into_inner); - if keys.iter().any(|k| k.user_id == user_id && k.name == name && k.revoked_at.is_none()) { - return Err(StoreError::Conflict(format!("api key name already exists: {name}"))); + if keys + .iter() + .any(|k| k.user_id == user_id && k.name == name && k.revoked_at.is_none()) + { + return Err(StoreError::Conflict(format!( + "api key name already exists: {name}" + ))); } let view = ApiKeyView::from(key.clone()); @@ -59,7 +71,10 @@ impl ApiKeyStore for InMemoryApiKeyStore { let hash = auth::hash_token(plaintext); Box::pin(async move { let keys = self.keys.read().unwrap_or_else(PoisonError::into_inner); - Ok(keys.iter().find(|k| k.key_hash == hash && k.revoked_at.is_none()).cloned()) + Ok(keys + .iter() + .find(|k| k.key_hash == hash && k.revoked_at.is_none()) + .cloned()) }) } @@ -78,7 +93,10 @@ impl ApiKeyStore for InMemoryApiKeyStore { fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { Box::pin(async move { let mut keys = self.keys.write().unwrap_or_else(PoisonError::into_inner); - if let Some(key) = keys.iter_mut().find(|k| k.id == key_id && k.user_id == user_id && k.revoked_at.is_none()) { + if let Some(key) = keys + .iter_mut() + .find(|k| k.id == key_id && k.user_id == user_id && k.revoked_at.is_none()) + { key.revoked_at = Some(Utc::now()); Ok(true) } else { @@ -91,6 +109,7 @@ impl ApiKeyStore for InMemoryApiKeyStore { #[cfg(test)] mod tests { use super::*; + use crate::db::api_keys::default_scopes; fn user_id() -> Uuid { Uuid::now_v7() @@ -101,7 +120,10 @@ mod tests { let store = InMemoryApiKeyStore::new(); let uid = user_id(); - let (plaintext, view) = store.create(uid, "test-key").await.unwrap(); + let (plaintext, view) = store + .create(uid, "test-key", &default_scopes()) + .await + .unwrap(); assert_eq!(view.name, "test-key"); let found = store.validate(&plaintext).await.unwrap(); @@ -121,8 +143,8 @@ mod tests { let store = InMemoryApiKeyStore::new(); let uid = user_id(); - store.create(uid, "dup").await.unwrap(); - let result = store.create(uid, "dup").await; + store.create(uid, "dup", &default_scopes()).await.unwrap(); + let result = store.create(uid, "dup", &default_scopes()).await; assert!(matches!(result, Err(StoreError::Conflict(_)))); } @@ -132,9 +154,9 @@ mod tests { let uid1 = user_id(); let uid2 = user_id(); - store.create(uid1, "a").await.unwrap(); - store.create(uid1, "b").await.unwrap(); - store.create(uid2, "c").await.unwrap(); + store.create(uid1, "a", &default_scopes()).await.unwrap(); + store.create(uid1, "b", &default_scopes()).await.unwrap(); + store.create(uid2, "c", &default_scopes()).await.unwrap(); let list = store.list_for_user(uid1).await.unwrap(); assert_eq!(list.len(), 2); @@ -146,7 +168,10 @@ mod tests { let store = InMemoryApiKeyStore::new(); let uid = user_id(); - let (plaintext, view) = store.create(uid, "to-revoke").await.unwrap(); + let (plaintext, view) = store + .create(uid, "to-revoke", &default_scopes()) + .await + .unwrap(); let revoked = store.revoke(view.id, uid).await.unwrap(); assert!(revoked); @@ -163,7 +188,7 @@ mod tests { let uid = user_id(); let other = user_id(); - let (_plaintext, view) = store.create(uid, "key").await.unwrap(); + let (_plaintext, view) = store.create(uid, "key", &default_scopes()).await.unwrap(); let revoked = store.revoke(view.id, other).await.unwrap(); assert!(!revoked); } @@ -173,10 +198,24 @@ mod tests { let store = InMemoryApiKeyStore::new(); let uid = user_id(); - let (_plaintext, view) = store.create(uid, "reuse").await.unwrap(); + let (_plaintext, view) = store.create(uid, "reuse", &default_scopes()).await.unwrap(); store.revoke(view.id, uid).await.unwrap(); - let result = store.create(uid, "reuse").await; + let result = store.create(uid, "reuse", &default_scopes()).await; assert!(result.is_ok()); } + + #[tokio::test] + async fn scopes_are_preserved() { + let store = InMemoryApiKeyStore::new(); + let uid = user_id(); + let scopes = serde_json::json!(["tunnels"]); + + let (plaintext, view) = store.create(uid, "tunnel-only", &scopes).await.unwrap(); + assert_eq!(view.scopes, scopes); + + let key = store.validate(&plaintext).await.unwrap().unwrap(); + assert!(key.has_scope("tunnels")); + assert!(!key.has_scope("management")); + } } diff --git a/crates/funnel-server/src/store/pg/api_key_store.rs b/crates/funnel-server/src/store/pg/api_key_store.rs index 88cb5e7..c1cdeaf 100644 --- a/crates/funnel-server/src/store/pg/api_key_store.rs +++ b/crates/funnel-server/src/store/pg/api_key_store.rs @@ -16,19 +16,23 @@ impl PgApiKeyStore { } impl ApiKeyStore for PgApiKeyStore { - fn create(&self, user_id: Uuid, name: &str) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>> { + fn create( + &self, + user_id: Uuid, + name: &str, + scopes: &serde_json::Value, + ) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>> { let name = name.to_string(); + let scopes = scopes.clone(); Box::pin(async move { - let (plaintext, key) = api_keys::create(&self.pool, user_id, &name).await?; + let (plaintext, key) = api_keys::create(&self.pool, user_id, &name, &scopes).await?; Ok((plaintext, key.into())) }) } fn validate(&self, plaintext: &str) -> BoxFuture<'_, Result, StoreError>> { let plaintext = plaintext.to_string(); - Box::pin(async move { - Ok(api_keys::validate(&self.pool, &plaintext).await?) - }) + Box::pin(async move { Ok(api_keys::validate(&self.pool, &plaintext).await?) }) } fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { @@ -39,8 +43,6 @@ impl ApiKeyStore for PgApiKeyStore { } fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { - Box::pin(async move { - Ok(api_keys::revoke(&self.pool, key_id, user_id).await?) - }) + Box::pin(async move { Ok(api_keys::revoke(&self.pool, key_id, user_id).await?) }) } } diff --git a/migrations/002_create_api_keys.sql b/migrations/002_create_api_keys.sql index 7346561..0dd0507 100644 --- a/migrations/002_create_api_keys.sql +++ b/migrations/002_create_api_keys.sql @@ -4,6 +4,7 @@ CREATE TABLE api_keys ( name TEXT NOT NULL, key_hash TEXT NOT NULL, key_prefix TEXT NOT NULL, + scopes JSONB NOT NULL DEFAULT '["management", "tunnels"]', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ, From c9037e0b220fc7a892d397bcb0353c43339db477 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 10 May 2026 16:01:03 +0200 Subject: [PATCH 28/42] feat(server): authenticate quic tunnel connections and record sessions --- crates/funnel-server/src/quic/handler.rs | 48 +++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/funnel-server/src/quic/handler.rs b/crates/funnel-server/src/quic/handler.rs index e4a1a71..8d248d9 100644 --- a/crates/funnel-server/src/quic/handler.rs +++ b/crates/funnel-server/src/quic/handler.rs @@ -15,6 +15,9 @@ pub enum ConnectionError { #[error("tunnel id conflict: {0}")] Conflict(String), + + #[error("auth error: {0}")] + Auth(String), } pub async fn handle_connection( @@ -29,6 +32,27 @@ pub async fn handle_connection( let handshake: Handshake = frame::read_meta(&mut recv).await?; let tunnel_id = handshake.tunnel_id; + // validate auth token + let token = handshake + .token + .as_deref() + .ok_or_else(|| ConnectionError::Auth("missing token".into()))?; + + let api_key = state + .api_keys + .validate(token) + .await + .map_err(|e| ConnectionError::Auth(format!("validation failed: {e}")))? + .ok_or_else(|| ConnectionError::Auth("invalid token".into()))?; + + if !api_key.has_scope("tunnels") { + let resp = HandshakeResponse::rejected("token missing tunnels scope"); + let _ = frame::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Auth("missing tunnels scope".into())); + } + + let user_id = api_key.user_id; + let tunnel = Arc::new(ActiveTunnel::new(tunnel_id.clone(), conn.clone())); if state @@ -43,11 +67,20 @@ pub async fn handle_connection( frame::write_meta(&mut send, &HandshakeResponse::ok()).await?; - tracing::info!(tunnel_id = %tunnel_id, "tunnel connected via quic"); + tracing::info!(tunnel_id = %tunnel_id, user_id = %user_id, "tunnel connected via quic"); metrics::gauge!("funnel_tunnels_active").increment(1.0); metrics::counter!("funnel_tunnels_total").increment(1); + let client_ip = conn.remote_address(); + let ip_network: Option = Some(client_ip.ip().into()); + + let session = state + .sessions + .record_connect(user_id, tunnel_id.as_ref(), ip_network) + .await + .ok(); + // wait until the connection closes (control stream EOF or transport error) let mut buf = [0u8; 1]; tokio::select! { @@ -58,6 +91,18 @@ pub async fn handle_connection( let stats = tunnel.stats(); state.tunnels.remove(&tunnel_id); + if let Some(session) = session { + let _ = state + .sessions + .record_disconnect( + session.id, + i64::try_from(stats.bytes_in).unwrap_or(i64::MAX), + i64::try_from(stats.bytes_out).unwrap_or(i64::MAX), + i64::try_from(stats.requests).unwrap_or(i64::MAX), + ) + .await; + } + let session_secs = tunnel.connected_at().elapsed().as_secs_f64(); metrics::gauge!("funnel_tunnels_active").decrement(1.0); @@ -65,6 +110,7 @@ pub async fn handle_connection( tracing::info!( tunnel_id = %tunnel_id, + user_id = %user_id, bytes_in = stats.bytes_in, bytes_out = stats.bytes_out, requests = stats.requests, From 099142448d26f870dfb45012e626c511a0752473 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 10 May 2026 16:01:10 +0200 Subject: [PATCH 29/42] feat(server): add oauth login with github and generic provider support --- crates/funnel-e2e/tests/e2e/auth.rs | 57 ++ crates/funnel-server/Cargo.toml | 7 +- crates/funnel-server/src/api/accounts.rs | 35 ++ crates/funnel-server/src/api/me.rs | 21 + crates/funnel-server/src/api/mod.rs | 3 + crates/funnel-server/src/api/oauth.rs | 510 ++++++++++++++++++ crates/funnel-server/src/auth/generic.rs | 267 +++++++++ crates/funnel-server/src/auth/github.rs | 143 +++++ crates/funnel-server/src/auth/oauth.rs | 163 ++++++ crates/funnel-server/src/main.rs | 191 +++++-- .../src/store/memory/session_recorder.rs | 41 +- .../src/store/pg/session_recorder.rs | 15 +- .../src/store/session_recorder.rs | 2 +- 13 files changed, 1403 insertions(+), 52 deletions(-) create mode 100644 crates/funnel-server/src/api/accounts.rs create mode 100644 crates/funnel-server/src/api/me.rs create mode 100644 crates/funnel-server/src/api/oauth.rs create mode 100644 crates/funnel-server/src/auth/generic.rs create mode 100644 crates/funnel-server/src/auth/github.rs create mode 100644 crates/funnel-server/src/auth/oauth.rs diff --git a/crates/funnel-e2e/tests/e2e/auth.rs b/crates/funnel-e2e/tests/e2e/auth.rs index fc73007..2e3d699 100644 --- a/crates/funnel-e2e/tests/e2e/auth.rs +++ b/crates/funnel-e2e/tests/e2e/auth.rs @@ -233,3 +233,60 @@ async fn revoked_key_cannot_authenticate() -> TestResult { Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn me_requires_auth() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env.client.get(env.url("/api/me")).send().await?; + assert_eq!(resp.status(), 401); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn me_returns_404_for_seed_key_user() -> TestResult { + let env = AuthTestEnv::start().await?; + + // seed key is created with nil UUID, no matching user exists + let resp = env + .client + .get(env.url("/api/me")) + .header("authorization", env.auth_header()) + .send() + .await?; + assert_eq!(resp.status(), 404); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn oauth_authorize_returns_404_when_not_configured() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env + .client + .get(env.url("/auth/github/authorize?cli_port=9999")) + .send() + .await?; + assert_eq!(resp.status(), 404); + + let body = resp.text().await?; + assert!(body.contains("oauth not configured")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn oauth_callback_returns_404_when_not_configured() -> TestResult { + let env = AuthTestEnv::start().await?; + + let resp = env + .client + .get(env.url("/auth/github/callback?code=test&state=test")) + .send() + .await?; + assert_eq!(resp.status(), 404); + + Ok(()) +} diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index 3e6ac8c..f9860a8 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -14,6 +14,8 @@ axum.workspace = true chrono.workspace = true clap.workspace = true dashmap = "6" +getrandom = "0.3" +hex = "0.4" http-body-util = "0.1" ipnetwork = "0.20" metrics = "0.24" @@ -29,6 +31,7 @@ tokio-util.workspace = true tower-http.workspace = true tracing.workspace = true tracing-subscriber = { workspace = true, features = ["json"] } +urlencoding = "2" uuid.workspace = true # tls @@ -37,12 +40,12 @@ aws-sdk-route53 = "1" axum-server = { version = "0.8", features = ["tls-rustls"] } instant-acme = { version = "0.8", features = ["rcgen"] } lru = "0.16" -reqwest = { workspace = true, features = ["rustls", "json", "query"] } +reqwest = { workspace = true, features = ["rustls", "json", "query", "form"] } rustls = { workspace = true, features = ["ring", "logging", "std", "tls12"] } rustls-pemfile = "2" x509-parser = "0.18" [dev-dependencies] -axum-test = "16" +axum-test = "20" pretty_assertions.workspace = true sqlx.workspace = true diff --git a/crates/funnel-server/src/api/accounts.rs b/crates/funnel-server/src/api/accounts.rs new file mode 100644 index 0000000..ce64b20 --- /dev/null +++ b/crates/funnel-server/src/api/accounts.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; +use serde::Serialize; +use uuid::Uuid; + +use crate::app::AppState; +use crate::auth::{Management, Scoped}; +use crate::error::AppError; + +#[derive(Serialize)] +pub struct AccountView { + pub id: Uuid, + pub provider: String, + pub created_at: chrono::DateTime, +} + +pub async fn list( + State(state): State>, + auth: Scoped, +) -> Result>, AppError> { + let accounts = state.accounts.list_for_user(auth.user_id).await?; + + let views: Vec = accounts + .into_iter() + .map(|a| AccountView { + id: a.id, + provider: a.provider, + created_at: a.created_at, + }) + .collect(); + + Ok(Json(views)) +} diff --git a/crates/funnel-server/src/api/me.rs b/crates/funnel-server/src/api/me.rs new file mode 100644 index 0000000..3c0700e --- /dev/null +++ b/crates/funnel-server/src/api/me.rs @@ -0,0 +1,21 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; + +use crate::app::AppState; +use crate::auth::{Management, Scoped}; +use crate::db::users::User; +use crate::error::AppError; + +pub async fn handler( + State(state): State>, + auth: Scoped, +) -> Result, AppError> { + state + .users + .find_by_id(auth.user_id) + .await? + .ok_or(AppError::NotFound("user not found".into())) + .map(Json) +} diff --git a/crates/funnel-server/src/api/mod.rs b/crates/funnel-server/src/api/mod.rs index 0b28d39..69b401d 100644 --- a/crates/funnel-server/src/api/mod.rs +++ b/crates/funnel-server/src/api/mod.rs @@ -1,3 +1,6 @@ +pub mod accounts; pub mod health; pub mod keys; +pub mod me; +pub mod oauth; pub mod tunnels; diff --git a/crates/funnel-server/src/api/oauth.rs b/crates/funnel-server/src/api/oauth.rs new file mode 100644 index 0000000..cf5391e --- /dev/null +++ b/crates/funnel-server/src/api/oauth.rs @@ -0,0 +1,510 @@ +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use serde::Deserialize; + +use crate::app::AppState; +use crate::db::accounts::NewAccount; +use crate::db::api_keys::default_scopes; +use crate::db::users::NewUser; + +#[derive(Deserialize)] +pub struct AuthorizeParams { + cli_port: u16, +} + +#[derive(Deserialize)] +pub struct CallbackParams { + code: String, + state: String, +} + +fn html_error(status: StatusCode, message: &str) -> Response { + ( + status, + Html(format!( + r"

Error

{message}

", + )), + ) + .into_response() +} + +pub async fn authorize( + State(state): State>, + Path(provider): Path, + Query(params): Query, +) -> Response { + let Some(oauth) = &state.oauth_state else { + return html_error(StatusCode::NOT_FOUND, "oauth not configured"); + }; + + let Some(p) = oauth.providers.get(&provider) else { + return html_error( + StatusCode::NOT_FOUND, + &format!("unknown provider: {provider}"), + ); + }; + + let token = match crate::auth::oauth::OAuthState::generate_state_token() { + Ok(t) => t, + Err(e) => { + tracing::error!(error = %e, "failed to generate state token"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + }; + + oauth.insert_pending(token.clone(), params.cli_port); + + let redirect_uri = format!("{}/auth/{}/callback", oauth.base_url, provider); + let url = p.authorize_url(&redirect_uri, &token); + + Redirect::temporary(&url).into_response() +} + +pub async fn callback( + State(state): State>, + Path(provider): Path, + Query(params): Query, +) -> Response { + let Some(oauth) = &state.oauth_state else { + return html_error(StatusCode::NOT_FOUND, "oauth not configured"); + }; + + let Some(p) = oauth.providers.get(&provider) else { + return html_error( + StatusCode::NOT_FOUND, + &format!("unknown provider: {provider}"), + ); + }; + + let Some(pending) = oauth.take_pending(¶ms.state) else { + return html_error(StatusCode::BAD_REQUEST, "invalid or expired state"); + }; + + let redirect_uri = format!("{}/auth/{}/callback", oauth.base_url, provider); + + let access_token = match p.exchange_code(¶ms.code, &redirect_uri).await { + Ok(t) => t, + Err(e) => { + tracing::error!(error = %e, "oauth code exchange failed"); + return html_error( + StatusCode::BAD_GATEWAY, + "failed to exchange authorization code", + ); + } + }; + + let info = match p.fetch_user_info(&access_token).await { + Ok(i) => i, + Err(e) => { + tracing::error!(error = %e, "oauth user info fetch failed"); + return html_error(StatusCode::BAD_GATEWAY, "failed to fetch user info"); + } + }; + + // look up existing account by (provider, provider_account_id) + let existing_account = match state + .accounts + .find_by_provider(&info.provider, &info.provider_id) + .await + { + Ok(a) => a, + Err(e) => { + tracing::error!(error = %e, "failed to look up account"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + }; + + let user = if let Some(account) = existing_account { + // account exists, update linked user profile + match state + .users + .update_profile( + account.user_id, + info.name.as_deref(), + info.avatar_url.as_deref(), + ) + .await + { + Ok(u) => u, + Err(e) => { + tracing::error!(error = %e, "failed to update user profile"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + } + } else { + // no account exists, create new user then link account + let user = match state + .users + .create(NewUser { + email: info.email, + name: info.name, + avatar_url: info.avatar_url, + }) + .await + { + Ok(u) => u, + Err(e) => { + tracing::error!(error = %e, "failed to create user"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + }; + + if let Err(e) = state + .accounts + .create(NewAccount { + user_id: user.id, + provider: info.provider, + provider_account_id: info.provider_id, + metadata: serde_json::json!({}), + }) + .await + { + tracing::error!(error = %e, "failed to create account link"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + + user + }; + + let key_name = format!("cli-{}", uuid::Uuid::now_v7()); + let (plaintext, _) = match state + .api_keys + .create(user.id, &key_name, &default_scopes()) + .await + { + Ok(k) => k, + Err(e) => { + tracing::error!(error = %e, "failed to create api key"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + }; + + let cli_redirect = format!( + "http://127.0.0.1:{}/callback?token={}", + pending.cli_port, plaintext + ); + + Html(format!( + r#" + +

login successful, redirecting...

+"#, + )) + .into_response() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use axum::Router; + use axum::routing::get; + + use crate::api; + use crate::app::AppState; + use crate::auth::oauth::{OAuthError, OAuthProvider, OAuthState, OAuthUserInfo}; + use crate::store::BoxFuture; + use crate::store::health::UptimeHealthReporter; + use crate::store::memory::account_store::InMemoryAccountStore; + use crate::store::memory::api_key_store::InMemoryApiKeyStore; + use crate::store::memory::session_recorder::InMemorySessionRecorder; + use crate::store::memory::user_store::InMemoryUserStore; + use crate::tunnel::manager::TunnelManager; + + struct MockProvider; + + impl OAuthProvider for MockProvider { + fn name(&self) -> &'static str { + "mock" + } + + fn authorize_url(&self, redirect_uri: &str, state: &str) -> String { + format!("https://mock.example.com/auth?redirect_uri={redirect_uri}&state={state}") + } + + fn exchange_code( + &self, + code: &str, + _redirect_uri: &str, + ) -> BoxFuture<'_, Result> { + let code = code.to_string(); + Box::pin(async move { + if code == "valid_code" { + Ok("mock_access_token".into()) + } else { + Err(OAuthError::Provider("bad code".into())) + } + }) + } + + fn fetch_user_info( + &self, + _access_token: &str, + ) -> BoxFuture<'_, Result> { + Box::pin(async { + Ok(OAuthUserInfo { + email: "test@example.com".into(), + name: Some("Test User".into()), + avatar_url: None, + provider: "mock".into(), + provider_id: "12345".into(), + }) + }) + } + } + + fn test_state(with_oauth: bool) -> Arc { + let oauth_state = if with_oauth { + let mut providers: HashMap> = HashMap::new(); + providers.insert("mock".into(), Arc::new(MockProvider)); + Some(Arc::new(OAuthState::new( + providers, + "http://localhost:8080".into(), + ))) + } else { + None + }; + + Arc::new(AppState { + tunnels: Arc::new(TunnelManager::new()), + api_keys: Arc::new(InMemoryApiKeyStore::new()), + users: Arc::new(InMemoryUserStore::new()), + accounts: Arc::new(InMemoryAccountStore::new()), + sessions: Arc::new(InMemorySessionRecorder::new()), + health: Arc::new(UptimeHealthReporter::new()), + is_tls: false, + oauth_state, + }) + } + + fn test_router(state: Arc) -> Router { + Router::new() + .route("/auth/{provider}/authorize", get(super::authorize)) + .route("/auth/{provider}/callback", get(super::callback)) + .route("/api/me", get(api::me::handler)) + .route("/api/keys", get(api::keys::list).post(api::keys::create)) + .with_state(state) + } + + #[tokio::test] + async fn authorize_redirects_to_provider() { + let state = test_state(true); + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/mock/authorize") + .add_query_param("cli_port", 9999) + .await; + + assert_eq!(resp.status_code(), 307); + let location = resp.header("location").to_str().unwrap().to_string(); + assert!(location.starts_with("https://mock.example.com/auth?")); + assert!(location.contains("redirect_uri=")); + assert!(location.contains("state=")); + } + + #[tokio::test] + async fn authorize_unknown_provider_returns_404() { + let state = test_state(true); + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/nonexistent/authorize") + .add_query_param("cli_port", 9999) + .await; + + assert_eq!(resp.status_code(), 404); + let body = resp.text(); + assert!(body.contains("unknown provider")); + } + + #[tokio::test] + async fn authorize_without_oauth_returns_404() { + let state = test_state(false); + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/mock/authorize") + .add_query_param("cli_port", 9999) + .await; + + assert_eq!(resp.status_code(), 404); + let body = resp.text(); + assert!(body.contains("oauth not configured")); + } + + #[tokio::test] + async fn callback_with_invalid_state_returns_400() { + let state = test_state(true); + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "bogus_state") + .await; + + assert_eq!(resp.status_code(), 400); + let body = resp.text(); + assert!(body.contains("invalid or expired state")); + } + + #[tokio::test] + async fn callback_with_bad_code_returns_502() { + let state = test_state(true); + let oauth = state.oauth_state.as_ref().unwrap(); + oauth.insert_pending("test_state_token".into(), 9999); + + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "invalid_code") + .add_query_param("state", "test_state_token") + .await; + + assert_eq!(resp.status_code(), 502); + } + + #[tokio::test] + async fn callback_full_flow_creates_user_and_returns_token() { + let state = test_state(true); + let oauth = state.oauth_state.as_ref().unwrap(); + oauth.insert_pending("valid_state".into(), 7777); + + let server = axum_test::TestServer::new(test_router(Arc::clone(&state))); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "valid_state") + .await; + + assert_eq!(resp.status_code(), 200); + let body = resp.text(); + assert!(body.contains("login successful")); + assert!(body.contains("127.0.0.1:7777/callback?token=sk_")); + + // verify account was created + let accounts = state + .accounts + .list_for_user( + state + .users + .find_by_email("test@example.com") + .await + .unwrap() + .unwrap() + .id, + ) + .await + .unwrap(); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].provider, "mock"); + assert_eq!(accounts[0].provider_account_id, "12345"); + } + + #[tokio::test] + async fn callback_state_is_consumed() { + let state = test_state(true); + let oauth = state.oauth_state.as_ref().unwrap(); + oauth.insert_pending("one_time".into(), 7777); + + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "one_time") + .await; + assert_eq!(resp.status_code(), 200); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "one_time") + .await; + assert_eq!(resp.status_code(), 400); + } + + #[tokio::test] + async fn callback_second_login_updates_profile() { + let state = test_state(true); + let oauth = state.oauth_state.as_ref().unwrap(); + + // first login + oauth.insert_pending("first".into(), 7777); + let server = axum_test::TestServer::new(test_router(Arc::clone(&state))); + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "first") + .await; + assert_eq!(resp.status_code(), 200); + + let user = state + .users + .find_by_email("test@example.com") + .await + .unwrap() + .unwrap(); + + // second login with same provider reuses user + oauth.insert_pending("second".into(), 7777); + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "second") + .await; + assert_eq!(resp.status_code(), 200); + + // still only one account + let accounts = state.accounts.list_for_user(user.id).await.unwrap(); + assert_eq!(accounts.len(), 1); + } + + #[tokio::test] + async fn me_requires_auth() { + let state = test_state(false); + let server = axum_test::TestServer::new(test_router(state)); + + let resp = server.get("/api/me").await; + assert_eq!(resp.status_code(), 401); + } + + #[tokio::test] + async fn me_returns_user_after_oauth_login() { + let state = test_state(true); + let oauth = state.oauth_state.as_ref().unwrap(); + oauth.insert_pending("login_state".into(), 7777); + + let server = axum_test::TestServer::new(test_router(Arc::clone(&state))); + + let resp = server + .get("/auth/mock/callback") + .add_query_param("code", "valid_code") + .add_query_param("state", "login_state") + .await; + assert_eq!(resp.status_code(), 200); + + let body = resp.text(); + let token_start = body.find("token=sk_").unwrap() + "token=".len(); + let token_end = body[token_start..].find('"').unwrap() + token_start; + let token = &body[token_start..token_end]; + + let resp = server + .get("/api/me") + .add_header("authorization", format!("Bearer {token}")) + .await; + assert_eq!(resp.status_code(), 200); + + let user: serde_json::Value = resp.json(); + assert_eq!(user["email"], "test@example.com"); + assert_eq!(user["name"], "Test User"); + assert_eq!(user["role"], "user"); + } +} diff --git a/crates/funnel-server/src/auth/generic.rs b/crates/funnel-server/src/auth/generic.rs new file mode 100644 index 0000000..0dfead2 --- /dev/null +++ b/crates/funnel-server/src/auth/generic.rs @@ -0,0 +1,267 @@ +use serde::Deserialize; + +use crate::store::BoxFuture; + +use super::oauth::{OAuthError, OAuthProvider, OAuthUserInfo}; + +pub struct GenericProviderConfig { + pub name: String, + pub client_id: String, + pub client_secret: String, + pub authorize_url: String, + pub token_url: String, + pub userinfo_url: String, + pub scopes: String, + /// json field name for the user's unique id (default: "sub") + pub id_field: String, + /// json field name for email (default: "email") + pub email_field: String, + /// json field name for display name (default: "name") + pub name_field: String, + /// json field name for avatar url (default: "picture") + pub avatar_field: String, +} + +impl GenericProviderConfig { + #[cfg(test)] + pub fn with_defaults( + name: String, + client_id: String, + client_secret: String, + authorize_url: String, + token_url: String, + userinfo_url: String, + scopes: String, + ) -> Self { + Self { + name, + client_id, + client_secret, + authorize_url, + token_url, + userinfo_url, + scopes, + id_field: "sub".into(), + email_field: "email".into(), + name_field: "name".into(), + avatar_field: "picture".into(), + } + } +} + +pub struct GenericProvider { + config: GenericProviderConfig, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: Option, + error: Option, + error_description: Option, +} + +impl GenericProvider { + pub fn new(config: GenericProviderConfig) -> Self { + let client = reqwest::Client::builder() + .user_agent("funnel-server") + .build() + .unwrap_or_default(); + + Self { config, client } + } + + fn extract_string(obj: &serde_json::Value, field: &str) -> Option { + let val = obj.get(field)?; + // handle both string and numeric ids + val.as_str() + .map(ToString::to_string) + .or_else(|| val.as_u64().map(|n| n.to_string())) + .or_else(|| val.as_i64().map(|n| n.to_string())) + } +} + +impl OAuthProvider for GenericProvider { + fn name(&self) -> &'static str { + // leak is fine here, provider names are static for the process lifetime + Box::leak(self.config.name.clone().into_boxed_str()) + } + + fn authorize_url(&self, redirect_uri: &str, state: &str) -> String { + format!( + "{}?client_id={}&redirect_uri={}&state={}&scope={}&response_type=code", + self.config.authorize_url, + urlencoding::encode(&self.config.client_id), + urlencoding::encode(redirect_uri), + urlencoding::encode(state), + urlencoding::encode(&self.config.scopes), + ) + } + + fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + ) -> BoxFuture<'_, Result> { + let code = code.to_string(); + let redirect_uri = redirect_uri.to_string(); + + Box::pin(async move { + let resp: TokenResponse = self + .client + .post(&self.config.token_url) + .header("accept", "application/json") + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", &self.config.client_id), + ("client_secret", &self.config.client_secret), + ("code", &code), + ("redirect_uri", &redirect_uri), + ]) + .send() + .await? + .json() + .await?; + + if let Some(err) = resp.error { + let detail = resp.error_description.unwrap_or_default(); + return Err(OAuthError::Provider(format!("{err}: {detail}"))); + } + + resp.access_token + .ok_or_else(|| OAuthError::MissingField("access_token".into())) + }) + } + + fn fetch_user_info( + &self, + access_token: &str, + ) -> BoxFuture<'_, Result> { + let token = access_token.to_string(); + + Box::pin(async move { + let user: serde_json::Value = self + .client + .get(&self.config.userinfo_url) + .bearer_auth(&token) + .send() + .await? + .json() + .await?; + + let provider_id = Self::extract_string(&user, &self.config.id_field) + .ok_or_else(|| OAuthError::MissingField(self.config.id_field.clone()))?; + + let email = Self::extract_string(&user, &self.config.email_field) + .ok_or_else(|| OAuthError::MissingField(self.config.email_field.clone()))?; + + let name = Self::extract_string(&user, &self.config.name_field); + let avatar_url = Self::extract_string(&user, &self.config.avatar_field); + + Ok(OAuthUserInfo { + email, + name, + avatar_url, + provider: self.config.name.clone(), + provider_id, + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> GenericProviderConfig { + GenericProviderConfig::with_defaults( + "test".into(), + "client123".into(), + "secret456".into(), + "https://auth.example.com/authorize".into(), + "https://auth.example.com/token".into(), + "https://auth.example.com/userinfo".into(), + "openid email profile".into(), + ) + } + + #[test] + fn authorize_url_includes_all_params() { + let provider = GenericProvider::new(test_config()); + let url = provider.authorize_url("https://myapp.com/callback", "state123"); + + assert!(url.starts_with("https://auth.example.com/authorize?")); + assert!(url.contains("client_id=client123")); + assert!(url.contains("state=state123")); + assert!(url.contains("response_type=code")); + assert!(url.contains("scope=openid")); + } + + #[test] + fn authorize_url_encodes_special_chars() { + let mut config = test_config(); + config.scopes = "openid email profile".into(); + let provider = GenericProvider::new(config); + let url = provider.authorize_url("https://myapp.com/cb?a=1", "s&t"); + + assert!(url.contains("redirect_uri=https%3A%2F%2Fmyapp.com%2Fcb%3Fa%3D1")); + assert!(url.contains("state=s%26t")); + } + + #[test] + fn extract_string_from_string_field() { + let json = serde_json::json!({"sub": "user-123"}); + assert_eq!( + GenericProvider::extract_string(&json, "sub"), + Some("user-123".into()) + ); + } + + #[test] + fn extract_string_from_numeric_field() { + let json = serde_json::json!({"id": 42}); + assert_eq!( + GenericProvider::extract_string(&json, "id"), + Some("42".into()) + ); + } + + #[test] + fn extract_string_returns_none_for_missing() { + let json = serde_json::json!({"foo": "bar"}); + assert!(GenericProvider::extract_string(&json, "missing").is_none()); + } + + #[test] + fn custom_field_mapping() { + let mut config = test_config(); + config.id_field = "user_id".into(); + config.email_field = "mail".into(); + config.name_field = "display_name".into(); + config.avatar_field = "avatar".into(); + + let json = serde_json::json!({ + "user_id": "u-1", + "mail": "user@corp.com", + "display_name": "User One", + "avatar": "https://img.example.com/1.png" + }); + + assert_eq!( + GenericProvider::extract_string(&json, &config.id_field), + Some("u-1".into()) + ); + assert_eq!( + GenericProvider::extract_string(&json, &config.email_field), + Some("user@corp.com".into()) + ); + assert_eq!( + GenericProvider::extract_string(&json, &config.name_field), + Some("User One".into()) + ); + assert_eq!( + GenericProvider::extract_string(&json, &config.avatar_field), + Some("https://img.example.com/1.png".into()) + ); + } +} diff --git a/crates/funnel-server/src/auth/github.rs b/crates/funnel-server/src/auth/github.rs new file mode 100644 index 0000000..39e9564 --- /dev/null +++ b/crates/funnel-server/src/auth/github.rs @@ -0,0 +1,143 @@ +use serde::Deserialize; + +use crate::store::BoxFuture; + +use super::oauth::{OAuthError, OAuthProvider, OAuthUserInfo}; + +pub struct OAuthConfig { + pub client_id: String, + pub client_secret: String, +} + +pub struct GitHubProvider { + config: OAuthConfig, + client: reqwest::Client, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: Option, + error: Option, + error_description: Option, +} + +#[derive(Deserialize)] +struct GitHubUser { + id: u64, + email: Option, + name: Option, + avatar_url: Option, +} + +#[derive(Deserialize)] +struct GitHubEmail { + email: String, + primary: bool, + verified: bool, +} + +impl GitHubProvider { + pub fn new(config: OAuthConfig) -> Self { + let client = reqwest::Client::builder() + .user_agent("funnel-server") + .build() + .unwrap_or_default(); + + Self { config, client } + } +} + +impl OAuthProvider for GitHubProvider { + fn name(&self) -> &'static str { + "github" + } + + fn authorize_url(&self, redirect_uri: &str, state: &str) -> String { + format!( + "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&state={}&scope=user:email", + urlencoding::encode(&self.config.client_id), + urlencoding::encode(redirect_uri), + urlencoding::encode(state), + ) + } + + fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + ) -> BoxFuture<'_, Result> { + let code = code.to_string(); + let redirect_uri = redirect_uri.to_string(); + + Box::pin(async move { + let resp: TokenResponse = self + .client + .post("https://github.com/login/oauth/access_token") + .header("accept", "application/json") + .form(&[ + ("client_id", self.config.client_id.as_str()), + ("client_secret", self.config.client_secret.as_str()), + ("code", &code), + ("redirect_uri", &redirect_uri), + ]) + .send() + .await? + .json() + .await?; + + if let Some(err) = resp.error { + let detail = resp.error_description.unwrap_or_default(); + return Err(OAuthError::Provider(format!("{err}: {detail}"))); + } + + resp.access_token + .ok_or_else(|| OAuthError::MissingField("access_token".into())) + }) + } + + fn fetch_user_info( + &self, + access_token: &str, + ) -> BoxFuture<'_, Result> { + let token = access_token.to_string(); + + Box::pin(async move { + let user: GitHubUser = self + .client + .get("https://api.github.com/user") + .bearer_auth(&token) + .send() + .await? + .json() + .await?; + + let email = if let Some(email) = user.email { + email + } else { + // fetch from /user/emails for users with private emails + let emails: Vec = self + .client + .get("https://api.github.com/user/emails") + .bearer_auth(&token) + .send() + .await? + .json() + .await?; + + emails + .into_iter() + .find(|e| e.primary && e.verified) + .map(|e| e.email) + .ok_or_else(|| OAuthError::MissingField("email".into()))? + }; + + Ok(OAuthUserInfo { + email, + name: user.name, + avatar_url: user.avatar_url, + provider: "github".into(), + provider_id: user.id.to_string(), + }) + }) + } +} diff --git a/crates/funnel-server/src/auth/oauth.rs b/crates/funnel-server/src/auth/oauth.rs new file mode 100644 index 0000000..dfc47a1 --- /dev/null +++ b/crates/funnel-server/src/auth/oauth.rs @@ -0,0 +1,163 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; + +use dashmap::DashMap; + +use crate::store::BoxFuture; + +#[derive(Debug, Clone)] +pub struct OAuthUserInfo { + pub email: String, + pub name: Option, + pub avatar_url: Option, + pub provider: String, + pub provider_id: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum OAuthError { + #[error("http request failed: {0}")] + Request(#[from] reqwest::Error), + + #[error("provider returned an error: {0}")] + Provider(String), + + #[error("missing required field: {0}")] + MissingField(String), +} + +#[allow(dead_code)] +pub trait OAuthProvider: Send + Sync { + fn name(&self) -> &'static str; + fn authorize_url(&self, redirect_uri: &str, state: &str) -> String; + fn exchange_code( + &self, + code: &str, + redirect_uri: &str, + ) -> BoxFuture<'_, Result>; + fn fetch_user_info( + &self, + access_token: &str, + ) -> BoxFuture<'_, Result>; +} + +pub struct PendingAuth { + pub cli_port: u16, + pub created_at: Instant, +} + +const STATE_TOKEN_BYTES: usize = 32; +const PENDING_TTL_SECS: u64 = 600; + +pub struct OAuthState { + pub providers: HashMap>, + pub pending: DashMap, + pub base_url: String, +} + +impl OAuthState { + pub fn new(providers: HashMap>, base_url: String) -> Self { + Self { + providers, + pending: DashMap::new(), + base_url, + } + } + + pub fn generate_state_token() -> Result { + let mut bytes = [0u8; STATE_TOKEN_BYTES]; + getrandom::fill(&mut bytes)?; + Ok(hex::encode(bytes)) + } + + pub fn insert_pending(&self, state: String, cli_port: u16) { + self.cleanup_expired(); + self.pending.insert( + state, + PendingAuth { + cli_port, + created_at: Instant::now(), + }, + ); + } + + pub fn take_pending(&self, state: &str) -> Option { + self.pending.remove(state).map(|(_, v)| v) + } + + fn cleanup_expired(&self) { + self.pending + .retain(|_, v| v.created_at.elapsed().as_secs() < PENDING_TTL_SECS); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_state() -> OAuthState { + OAuthState::new(HashMap::new(), "http://localhost".into()) + } + + #[test] + fn generate_state_token_produces_64_hex_chars() { + let token = OAuthState::generate_state_token().unwrap(); + assert_eq!(token.len(), STATE_TOKEN_BYTES * 2); + assert!(token.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn generated_tokens_are_unique() { + let a = OAuthState::generate_state_token().unwrap(); + let b = OAuthState::generate_state_token().unwrap(); + assert_ne!(a, b); + } + + #[test] + fn insert_and_take_pending() { + let state = empty_state(); + + state.insert_pending("tok_1".into(), 9000); + + let pending = state.take_pending("tok_1"); + assert!(pending.is_some()); + assert_eq!(pending.unwrap().cli_port, 9000); + } + + #[test] + fn take_pending_removes_entry() { + let state = empty_state(); + + state.insert_pending("tok_2".into(), 9001); + let _ = state.take_pending("tok_2"); + + assert!(state.take_pending("tok_2").is_none()); + } + + #[test] + fn take_pending_returns_none_for_unknown() { + let state = empty_state(); + assert!(state.take_pending("nonexistent").is_none()); + } + + #[test] + fn expired_entries_are_cleaned_up() { + let state = empty_state(); + + // insert an already expired entry by manipulating directly + state.pending.insert( + "old".into(), + PendingAuth { + cli_port: 1234, + created_at: Instant::now() - std::time::Duration::from_secs(PENDING_TTL_SECS + 1), + }, + ); + + // inserting a new one triggers cleanup + state.insert_pending("fresh".into(), 5678); + + assert!(state.take_pending("old").is_none()); + assert!(state.take_pending("fresh").is_some()); + } +} diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index a6a138d..2762964 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -10,6 +10,7 @@ mod store; mod tls; mod tunnel; +use std::collections::HashMap; use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; @@ -18,10 +19,11 @@ use clap::Parser; use sqlx::postgres::PgPoolOptions; use tracing_subscriber::EnvFilter; -use store::api_key_store::ApiKeyStore; +use auth::generic::{GenericProvider, GenericProviderConfig}; +use auth::github::{GitHubProvider, OAuthConfig}; +use auth::oauth::{OAuthProvider, OAuthState}; use store::health::UptimeHealthReporter; -use store::session_recorder::SessionRecorder; -use store::user_store::UserStore; +use tunnel::manager::TunnelManager; #[derive(Parser)] #[command(name = "funnel-server", about = "Funnel tunnel server")] @@ -73,6 +75,62 @@ struct Cli { /// Create a seed API key at startup and print it to stdout #[arg(long)] seed_api_key: bool, + + /// GitHub OAuth client ID + #[arg(long, env = "GITHUB_CLIENT_ID")] + github_client_id: Option, + + /// GitHub OAuth client secret + #[arg(long, env = "GITHUB_CLIENT_SECRET")] + github_client_secret: Option, + + /// Base URL of this server (required when OAuth is configured) + #[arg(long, env = "BASE_URL")] + base_url: Option, + + /// Generic OAuth provider name (e.g. "gitlab", "google") + #[arg(long, env = "OAUTH_PROVIDER_NAME")] + oauth_provider_name: Option, + + /// Generic OAuth client ID + #[arg(long, env = "OAUTH_CLIENT_ID")] + oauth_client_id: Option, + + /// Generic OAuth client secret + #[arg(long, env = "OAUTH_CLIENT_SECRET")] + oauth_client_secret: Option, + + /// Generic OAuth authorize URL + #[arg(long, env = "OAUTH_AUTHORIZE_URL")] + oauth_authorize_url: Option, + + /// Generic OAuth token exchange URL + #[arg(long, env = "OAUTH_TOKEN_URL")] + oauth_token_url: Option, + + /// Generic OAuth user info URL + #[arg(long, env = "OAUTH_USERINFO_URL")] + oauth_userinfo_url: Option, + + /// Generic OAuth scopes (space separated) + #[arg(long, env = "OAUTH_SCOPES", default_value = "openid email profile")] + oauth_scopes: String, + + /// JSON field for user ID in userinfo response + #[arg(long, env = "OAUTH_ID_FIELD", default_value = "sub")] + oauth_id_field: String, + + /// JSON field for email in userinfo response + #[arg(long, env = "OAUTH_EMAIL_FIELD", default_value = "email")] + oauth_email_field: String, + + /// JSON field for display name in userinfo response + #[arg(long, env = "OAUTH_NAME_FIELD", default_value = "name")] + oauth_name_field: String, + + /// JSON field for avatar URL in userinfo response + #[arg(long, env = "OAUTH_AVATAR_FIELD", default_value = "picture")] + oauth_avatar_field: String, } #[tokio::main] @@ -117,46 +175,110 @@ async fn main() -> anyhow::Result<()> { } } -fn build_state(pool: Option, is_tls: bool) -> Arc { - let tunnels = Arc::new(tunnel::manager::TunnelManager::new()); - - let (api_keys, users, sessions): ( - Arc, - Arc, - Arc, - ) = if let Some(pool) = pool { - ( - Arc::new(store::pg::api_key_store::PgApiKeyStore::new(pool.clone())), - Arc::new(store::pg::user_store::PgUserStore::new(pool.clone())), - Arc::new(store::pg::session_recorder::PgSessionRecorder::new(pool)), - ) - } else { - ( - Arc::new(store::memory::api_key_store::InMemoryApiKeyStore::new()), - Arc::new(store::memory::user_store::InMemoryUserStore::new()), - Arc::new(store::memory::session_recorder::InMemorySessionRecorder::new()), - ) - }; +fn build_oauth_state(cli: &Cli) -> anyhow::Result>> { + let mut providers: HashMap> = HashMap::new(); + + if let (Some(client_id), Some(client_secret)) = + (&cli.github_client_id, &cli.github_client_secret) + { + let provider: Arc = Arc::new(GitHubProvider::new(OAuthConfig { + client_id: client_id.clone(), + client_secret: client_secret.clone(), + })); + providers.insert("github".to_string(), provider); + tracing::info!("github oauth configured"); + } + + if let ( + Some(name), + Some(client_id), + Some(client_secret), + Some(authorize_url), + Some(token_url), + Some(userinfo_url), + ) = ( + &cli.oauth_provider_name, + &cli.oauth_client_id, + &cli.oauth_client_secret, + &cli.oauth_authorize_url, + &cli.oauth_token_url, + &cli.oauth_userinfo_url, + ) { + let provider: Arc = + Arc::new(GenericProvider::new(GenericProviderConfig { + name: name.clone(), + client_id: client_id.clone(), + client_secret: client_secret.clone(), + authorize_url: authorize_url.clone(), + token_url: token_url.clone(), + userinfo_url: userinfo_url.clone(), + scopes: cli.oauth_scopes.clone(), + id_field: cli.oauth_id_field.clone(), + email_field: cli.oauth_email_field.clone(), + name_field: cli.oauth_name_field.clone(), + avatar_field: cli.oauth_avatar_field.clone(), + })); + providers.insert(name.clone(), provider); + tracing::info!(provider = %name, "generic oauth provider configured"); + } - Arc::new(app::AppState { - tunnels, - api_keys, - users, - sessions, - health: Arc::new(UptimeHealthReporter::new()), - is_tls, - }) + if providers.is_empty() { + return Ok(None); + } + + let base_url = cli + .base_url + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--base-url is required when OAuth is configured"))? + .trim_end_matches('/') + .to_string(); + + Ok(Some(Arc::new(OAuthState::new(providers, base_url)))) +} + +fn build_state( + pool: Option, + is_tls: bool, + oauth_state: Option>, +) -> Arc { + let tunnels = Arc::new(TunnelManager::new()); + + if let Some(pool) = pool { + Arc::new(app::AppState { + tunnels, + api_keys: Arc::new(store::pg::api_key_store::PgApiKeyStore::new(pool.clone())), + users: Arc::new(store::pg::user_store::PgUserStore::new(pool.clone())), + accounts: Arc::new(store::pg::account_store::PgAccountStore::new(pool.clone())), + sessions: Arc::new(store::pg::session_recorder::PgSessionRecorder::new(pool)), + health: Arc::new(UptimeHealthReporter::new()), + is_tls, + oauth_state, + }) + } else { + Arc::new(app::AppState { + tunnels, + api_keys: Arc::new(store::memory::api_key_store::InMemoryApiKeyStore::new()), + users: Arc::new(store::memory::user_store::InMemoryUserStore::new()), + accounts: Arc::new(store::memory::account_store::InMemoryAccountStore::new()), + sessions: Arc::new(store::memory::session_recorder::InMemorySessionRecorder::new()), + health: Arc::new(UptimeHealthReporter::new()), + is_tls, + oauth_state, + }) + } } async fn run_plain(cli: Cli, pool: Option) -> anyhow::Result<()> { let metrics_handle = metrics::setup()?; - let state = build_state(pool, false); + let oauth_state = build_oauth_state(&cli)?; + let state = build_state(pool, false, oauth_state); let router = app::build_router(Arc::clone(&state), metrics_handle); if cli.seed_api_key { + let scopes = db::api_keys::default_scopes(); let (plaintext, _) = state .api_keys - .create(uuid::Uuid::nil(), "seed") + .create(uuid::Uuid::nil(), "seed", &scopes) .await .map_err(|e| anyhow::anyhow!("failed to create seed api key: {e}"))?; println!("{plaintext}"); @@ -201,7 +323,8 @@ async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<() .await?; let metrics_handle = metrics::setup()?; - let state = build_state(pool, true); + let oauth_state = build_oauth_state(&cli)?; + let state = build_state(pool, true, oauth_state); let router = app::build_router(Arc::clone(&state), metrics_handle); let quic_handle = quic::config::spawn_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; diff --git a/crates/funnel-server/src/store/memory/session_recorder.rs b/crates/funnel-server/src/store/memory/session_recorder.rs index 6813fee..8eaa8c5 100644 --- a/crates/funnel-server/src/store/memory/session_recorder.rs +++ b/crates/funnel-server/src/store/memory/session_recorder.rs @@ -41,7 +41,10 @@ impl SessionRecorder for InMemorySessionRecorder { requests: 0, }; { - let mut sessions = self.sessions.write().unwrap_or_else(PoisonError::into_inner); + let mut sessions = self + .sessions + .write() + .unwrap_or_else(PoisonError::into_inner); sessions.push(session.clone()); } Ok(session) @@ -56,8 +59,14 @@ impl SessionRecorder for InMemorySessionRecorder { requests: i64, ) -> BoxFuture<'_, Result> { Box::pin(async move { - let mut sessions = self.sessions.write().unwrap_or_else(PoisonError::into_inner); - if let Some(session) = sessions.iter_mut().find(|s| s.id == session_id && s.disconnected_at.is_none()) { + let mut sessions = self + .sessions + .write() + .unwrap_or_else(PoisonError::into_inner); + if let Some(session) = sessions + .iter_mut() + .find(|s| s.id == session_id && s.disconnected_at.is_none()) + { session.disconnected_at = Some(Utc::now()); session.bytes_in = bytes_in; session.bytes_out = bytes_out; @@ -72,7 +81,11 @@ impl SessionRecorder for InMemorySessionRecorder { fn list_active(&self) -> BoxFuture<'_, Result, StoreError>> { Box::pin(async move { let sessions = self.sessions.read().unwrap_or_else(PoisonError::into_inner); - Ok(sessions.iter().filter(|s| s.disconnected_at.is_none()).cloned().collect()) + Ok(sessions + .iter() + .filter(|s| s.disconnected_at.is_none()) + .cloned() + .collect()) }) } @@ -124,7 +137,10 @@ mod tests { let uid = user_id(); let session = store.record_connect(uid, "tunnel-2", None).await.unwrap(); - let disconnected = store.record_disconnect(session.id, 100, 200, 5).await.unwrap(); + let disconnected = store + .record_disconnect(session.id, 100, 200, 5) + .await + .unwrap(); assert!(disconnected); let active = store.list_active().await.unwrap(); @@ -137,7 +153,10 @@ mod tests { let uid = user_id(); let session = store.record_connect(uid, "tunnel-3", None).await.unwrap(); - store.record_disconnect(session.id, 1024, 2048, 10).await.unwrap(); + store + .record_disconnect(session.id, 1024, 2048, 10) + .await + .unwrap(); let all = store.list_for_user(uid, 100).await.unwrap(); assert_eq!(all.len(), 1); @@ -150,7 +169,10 @@ mod tests { #[tokio::test] async fn disconnect_nonexistent_returns_false() { let store = InMemorySessionRecorder::new(); - let result = store.record_disconnect(Uuid::now_v7(), 0, 0, 0).await.unwrap(); + let result = store + .record_disconnect(Uuid::now_v7(), 0, 0, 0) + .await + .unwrap(); assert!(!result); } @@ -172,7 +194,10 @@ mod tests { let uid = user_id(); for i in 0..5 { - store.record_connect(uid, &format!("tunnel-{i}"), None).await.unwrap(); + store + .record_connect(uid, &format!("tunnel-{i}"), None) + .await + .unwrap(); } let limited = store.list_for_user(uid, 3).await.unwrap(); diff --git a/crates/funnel-server/src/store/pg/session_recorder.rs b/crates/funnel-server/src/store/pg/session_recorder.rs index c5d399c..c5134c5 100644 --- a/crates/funnel-server/src/store/pg/session_recorder.rs +++ b/crates/funnel-server/src/store/pg/session_recorder.rs @@ -37,14 +37,15 @@ impl SessionRecorder for PgSessionRecorder { requests: i64, ) -> BoxFuture<'_, Result> { Box::pin(async move { - Ok(tunnel_sessions::disconnect(&self.pool, session_id, bytes_in, bytes_out, requests).await?) + Ok( + tunnel_sessions::disconnect(&self.pool, session_id, bytes_in, bytes_out, requests) + .await?, + ) }) } fn list_active(&self) -> BoxFuture<'_, Result, StoreError>> { - Box::pin(async move { - Ok(tunnel_sessions::list_active(&self.pool).await?) - }) + Box::pin(async move { Ok(tunnel_sessions::list_active(&self.pool).await?) }) } fn list_for_user( @@ -52,8 +53,8 @@ impl SessionRecorder for PgSessionRecorder { user_id: Uuid, limit: i64, ) -> BoxFuture<'_, Result, StoreError>> { - Box::pin(async move { - Ok(tunnel_sessions::list_for_user(&self.pool, user_id, limit).await?) - }) + Box::pin( + async move { Ok(tunnel_sessions::list_for_user(&self.pool, user_id, limit).await?) }, + ) } } diff --git a/crates/funnel-server/src/store/session_recorder.rs b/crates/funnel-server/src/store/session_recorder.rs index 43f1b98..c53bb98 100644 --- a/crates/funnel-server/src/store/session_recorder.rs +++ b/crates/funnel-server/src/store/session_recorder.rs @@ -1,8 +1,8 @@ use ipnetwork::IpNetwork; use uuid::Uuid; -use crate::db::tunnel_sessions::TunnelSession; use super::{BoxFuture, StoreError}; +use crate::db::tunnel_sessions::TunnelSession; pub trait SessionRecorder: Send + Sync { fn record_connect( From 624f04c660c27f38713d635ffcc058aedb603530 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 10 May 2026 16:01:15 +0200 Subject: [PATCH 30/42] refactor(client): replace inlet config with named context system --- crates/funnel-client/src/config.rs | 274 ++++++++++++++++++++++++----- 1 file changed, 226 insertions(+), 48 deletions(-) diff --git a/crates/funnel-client/src/config.rs b/crates/funnel-client/src/config.rs index 5b709a7..e27044e 100644 --- a/crates/funnel-client/src/config.rs +++ b/crates/funnel-client/src/config.rs @@ -5,21 +5,61 @@ use serde::{Deserialize, Serialize}; const CONFIG_DIR: &str = "funnel"; const CONFIG_FILE: &str = "config.toml"; +pub const DEFAULT_QUIC_PORT: u16 = 4433; -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Config { +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunnelConfig { + #[serde(default = "default_context_name")] + pub current_context: String, #[serde(default)] - pub inlets: HashMap, + pub contexts: HashMap, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Inlet { +impl Default for FunnelConfig { + fn default() -> Self { + Self { + current_context: default_context_name(), + contexts: HashMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Context { #[serde(default)] pub server: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub domain: Option, + pub token: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(default = "default_quic_port")] + pub quic_port: u16, +} + +impl Default for Context { + fn default() -> Self { + Self { + server: String::new(), + token: None, + domain: None, + quic_port: DEFAULT_QUIC_PORT, + } + } +} + +fn default_context_name() -> String { + "default".into() +} + +const fn default_quic_port() -> u16 { + DEFAULT_QUIC_PORT +} + +pub struct ResolvedContext { + pub name: String, + pub server: String, pub token: Option, + pub quic_port: u16, } pub fn config_path() -> PathBuf { @@ -29,17 +69,25 @@ pub fn config_path() -> PathBuf { PathBuf::from(CONFIG_FILE) } -pub fn load() -> anyhow::Result { +pub fn load() -> anyhow::Result { let path = config_path(); - if !path.exists() { - return Ok(Config::default()); + + let mut builder = config_rs::Config::builder().set_default("current_context", "default")?; + + if path.exists() { + builder = builder.add_source(config_rs::File::from(path)); } - let content = std::fs::read_to_string(&path)?; - let config: Config = toml::from_str(&content)?; + + if let Ok(ctx) = std::env::var("FUNNEL_CONTEXT") { + builder = builder.set_override("current_context", ctx)?; + } + + let settings = builder.build()?; + let config: FunnelConfig = settings.try_deserialize()?; Ok(config) } -pub fn save(config: &Config) -> anyhow::Result<()> { +pub fn save(config: &FunnelConfig) -> anyhow::Result<()> { let path = config_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -47,7 +95,6 @@ pub fn save(config: &Config) -> anyhow::Result<()> { let content = toml::to_string_pretty(config)?; std::fs::write(&path, content)?; - // restrict permissions on unix (file contains tokens) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -57,21 +104,111 @@ pub fn save(config: &Config) -> anyhow::Result<()> { Ok(()) } -pub fn get_inlet<'a>(config: &'a Config, name: &str) -> Option<&'a Inlet> { - config.inlets.get(name) +/// resolve the active context, applying env var overrides for server and token +pub fn resolve( + config: &FunnelConfig, + context_override: Option<&str>, +) -> anyhow::Result { + let name = context_override.unwrap_or(&config.current_context); + + let ctx = config.contexts.get(name).ok_or_else(|| { + anyhow::anyhow!( + "context '{name}' not found, run: funnel context create {name} --server " + ) + })?; + + let server = std::env::var("FUNNEL_SERVER").unwrap_or_else(|_| ctx.server.clone()); + + let token = std::env::var("FUNNEL_TOKEN") + .ok() + .or_else(|| ctx.token.clone()); + + if server.is_empty() { + anyhow::bail!("server not configured for context '{name}'"); + } + + Ok(ResolvedContext { + name: name.to_string(), + server, + token, + quic_port: ctx.quic_port, + }) +} + +/// resolve context and require a valid token +pub fn resolve_authenticated( + config: &FunnelConfig, + context_override: Option<&str>, +) -> anyhow::Result<(ResolvedContext, String)> { + let resolved = resolve(config, context_override)?; + let token = resolved.token.clone().ok_or_else(|| { + anyhow::anyhow!( + "not logged in for context '{}', run: funnel login", + resolved.name + ) + })?; + Ok((resolved, token)) +} + +pub fn set_token(context_name: &str, token: &str) -> anyhow::Result<()> { + let mut config = load()?; + let ctx = config.contexts.entry(context_name.to_string()).or_default(); + ctx.token = Some(token.to_string()); + save(&config) +} + +pub fn clear_token(context_name: &str) -> anyhow::Result<()> { + let mut config = load()?; + let ctx = config + .contexts + .get_mut(context_name) + .ok_or_else(|| anyhow::anyhow!("context '{context_name}' not found"))?; + ctx.token = None; + save(&config) +} + +pub fn set_current_context(name: &str) -> anyhow::Result<()> { + let mut config = load()?; + if !config.contexts.contains_key(name) { + anyhow::bail!("context '{name}' does not exist"); + } + config.current_context = name.to_string(); + save(&config) } -pub fn set_token(inlet_name: &str, token: &str) -> anyhow::Result<()> { +pub fn create_context(name: &str, server: &str, quic_port: Option) -> anyhow::Result<()> { let mut config = load()?; - let inlet = config.inlets.entry(inlet_name.to_string()).or_default(); - inlet.token = Some(token.to_string()); + if config.contexts.contains_key(name) { + anyhow::bail!("context '{name}' already exists"); + } + config.contexts.insert( + name.to_string(), + Context { + server: server.to_string(), + quic_port: quic_port.unwrap_or(DEFAULT_QUIC_PORT), + ..Default::default() + }, + ); + // first context becomes the current one automatically + if config.contexts.len() == 1 { + config.current_context = name.to_string(); + } save(&config) } -pub fn set_server(inlet_name: &str, server: &str) -> anyhow::Result<()> { +pub fn delete_context(name: &str) -> anyhow::Result<()> { let mut config = load()?; - let inlet = config.inlets.entry(inlet_name.to_string()).or_default(); - inlet.server = server.to_string(); + if config.contexts.remove(name).is_none() { + anyhow::bail!("context '{name}' does not exist"); + } + if config.current_context == name { + config.current_context = config + .contexts + .keys() + .next() + .cloned() + .unwrap_or_else(default_context_name); + } save(&config) } @@ -80,42 +217,31 @@ mod tests { use super::*; #[test] - fn default_config_has_no_inlets() { - let config = Config::default(); - assert!(config.inlets.is_empty()); + fn default_config_has_no_contexts() { + let config = FunnelConfig::default(); + assert!(config.contexts.is_empty()); + assert_eq!(config.current_context, "default"); } - type TestResult = Result<(), Box>; - #[test] - fn roundtrip_toml() -> TestResult { - let mut config = Config::default(); - config.inlets.insert( + fn roundtrip_toml() { + let mut config = FunnelConfig::default(); + config.contexts.insert( "default".to_string(), - Inlet { + Context { server: "https://tunnel.example.com".to_string(), - domain: None, - token: Some("sk_test123".to_string()), + token: Some("fnl_test123".to_string()), + ..Default::default() }, ); - let serialized = toml::to_string_pretty(&config)?; - let parsed: Config = toml::from_str(&serialized)?; + let serialized = toml::to_string_pretty(&config).expect("serialize"); + let parsed: FunnelConfig = toml::from_str(&serialized).expect("deserialize"); - let inlet = parsed - .inlets - .get("default") - .ok_or("expected default inlet")?; - assert_eq!(inlet.server, "https://tunnel.example.com"); - assert_eq!(inlet.token.as_deref(), Some("sk_test123")); - assert!(inlet.domain.is_none()); - Ok(()) - } - - #[test] - fn get_inlet_returns_none_for_missing() { - let config = Config::default(); - assert!(get_inlet(&config, "default").is_none()); + let ctx = parsed.contexts.get("default").expect("default context"); + assert_eq!(ctx.server, "https://tunnel.example.com"); + assert_eq!(ctx.token.as_deref(), Some("fnl_test123")); + assert_eq!(ctx.quic_port, DEFAULT_QUIC_PORT); } #[test] @@ -123,4 +249,56 @@ mod tests { let path = config_path(); assert!(!path.as_os_str().is_empty()); } + + #[test] + fn resolve_missing_context_errors() { + let config = FunnelConfig::default(); + assert!(resolve(&config, None).is_err()); + } + + #[test] + fn resolve_uses_current_context() { + let mut config = FunnelConfig::default(); + config.contexts.insert( + "default".to_string(), + Context { + server: "https://example.com".to_string(), + token: Some("tok".to_string()), + ..Default::default() + }, + ); + + let resolved = resolve(&config, None).expect("resolve"); + assert_eq!(resolved.name, "default"); + assert_eq!(resolved.server, "https://example.com"); + } + + #[test] + fn resolve_override_context() { + let mut config = FunnelConfig::default(); + config.contexts.insert( + "staging".to_string(), + Context { + server: "https://staging.example.com".to_string(), + ..Default::default() + }, + ); + + let resolved = resolve(&config, Some("staging")).expect("resolve"); + assert_eq!(resolved.name, "staging"); + assert_eq!(resolved.server, "https://staging.example.com"); + } + + #[test] + fn resolve_authenticated_requires_token() { + let mut config = FunnelConfig::default(); + config.contexts.insert( + "default".to_string(), + Context { + server: "https://example.com".to_string(), + ..Default::default() + }, + ); + assert!(resolve_authenticated(&config, None).is_err()); + } } From 6b8ab69c94cfde12e2f66669d8bfce157cec2fe6 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Sun, 10 May 2026 16:01:19 +0200 Subject: [PATCH 31/42] feat(client): add login, keys, status, and whoami commands --- crates/funnel-client/src/auth.rs | 112 ++++++++++++++++++ crates/funnel-client/src/keys.rs | 178 +++++++++++++++++++++++++++++ crates/funnel-client/src/status.rs | 127 ++++++++++++++++++++ crates/funnel-client/src/whoami.rs | 40 +++++++ 4 files changed, 457 insertions(+) create mode 100644 crates/funnel-client/src/auth.rs create mode 100644 crates/funnel-client/src/keys.rs create mode 100644 crates/funnel-client/src/status.rs create mode 100644 crates/funnel-client/src/whoami.rs diff --git a/crates/funnel-client/src/auth.rs b/crates/funnel-client/src/auth.rs new file mode 100644 index 0000000..7e19add --- /dev/null +++ b/crates/funnel-client/src/auth.rs @@ -0,0 +1,112 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::Router; +use axum::extract::Query; +use axum::response::Html; +use axum::routing::get; +use serde::Deserialize; +use tokio::sync::Notify; + +use crate::config; + +#[derive(Deserialize)] +struct CallbackParams { + token: String, +} + +pub async fn login(context_name: &str, provider: &str) -> anyhow::Result<()> { + let cfg = config::load()?; + let resolved = config::resolve(&cfg, Some(context_name))?; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let local_addr = listener.local_addr()?; + let port = local_addr.port(); + + let login_url = format!( + "{}/auth/{provider}/authorize?cli_port={port}", + resolved.server + ); + + println!("opening browser to log in..."); + if !open_browser(&login_url) { + println!("could not open browser, visit this URL manually:"); + println!(" {login_url}"); + } + + let shutdown = Arc::new(Notify::new()); + let shutdown_signal = Arc::clone(&shutdown); + let context = context_name.to_string(); + + let app = Router::new().route( + "/callback", + get(move |Query(params): Query| { + let shutdown = shutdown_signal.clone(); + let context = context.clone(); + async move { + let result = config::set_token(&context, ¶ms.token); + shutdown.notify_one(); + match result { + Ok(()) => Html( + r#" + +

Login successful

+

You can close this tab and return to the app.

+ + "#.to_string(), + ), + Err(e) => Html(format!( + r#" + +

Error

+

Failed to save token: {e}

+ + "# + )), + } + } + }), + ); + + let server = axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ); + + let shutdown_wait = shutdown.clone(); + tokio::select! { + res = server => { res?; } + () = shutdown_wait.notified() => { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + }; + + println!("logged in successfully, token saved to context '{context_name}'"); + Ok(()) +} + +fn open_browser(url: &str) -> bool { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open").arg(url).spawn().is_ok() + } + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(url) + .spawn() + .is_ok() + } + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/c", "start", url]) + .spawn() + .is_ok() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = url; + false + } +} diff --git a/crates/funnel-client/src/keys.rs b/crates/funnel-client/src/keys.rs new file mode 100644 index 0000000..26a355b --- /dev/null +++ b/crates/funnel-client/src/keys.rs @@ -0,0 +1,178 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +struct ApiKeyView { + id: String, + name: String, + key_prefix: String, + scopes: serde_json::Value, + created_at: String, +} + +#[derive(Deserialize)] +struct CreateKeyResponse { + key: String, + info: ApiKeyView, +} + +#[derive(Serialize)] +struct CreateKeyRequest { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + scopes: Option>, +} + +fn client(token: &str) -> reqwest::Client { + reqwest::Client::builder() + .default_headers({ + let mut h = reqwest::header::HeaderMap::new(); + if let Ok(v) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) { + h.insert(reqwest::header::AUTHORIZATION, v); + } + h + }) + .build() + .unwrap_or_default() +} + +fn format_scopes(scopes: &serde_json::Value) -> String { + scopes + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default() +} + +fn format_timestamp(ts: &str) -> &str { + // trim sub-second precision and timezone suffix for readability + // "2025-01-15T10:30:00.123456Z" -> "2025-01-15T10:30:00" + let without_frac = ts.split('.').next().unwrap_or(ts); + without_frac.strip_suffix('Z').unwrap_or(without_frac) +} + +pub async fn list(server: &str, token: &str) -> anyhow::Result<()> { + let resp = client(token) + .get(format!("{server}/api/keys")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let keys: Vec = resp.json().await?; + + if keys.is_empty() { + println!("no api keys"); + return Ok(()); + } + + for (i, key) in keys.iter().enumerate() { + println!("{}", key.name); + println!(" id: {}", key.id); + println!(" prefix: {}...", key.key_prefix); + println!(" scopes: {}", format_scopes(&key.scopes)); + println!(" created: {}", format_timestamp(&key.created_at)); + if i < keys.len() - 1 { + println!(); + } + } + + if keys.len() > 1 { + println!("\n{} keys", keys.len()); + } + + Ok(()) +} + +pub async fn create( + server: &str, + token: &str, + name: &str, + scopes: Option<&str>, +) -> anyhow::Result<()> { + let scopes = scopes.map(|s| s.split(',').map(|s| s.trim().to_string()).collect()); + + let resp = client(token) + .post(format!("{server}/api/keys")) + .json(&CreateKeyRequest { + name: name.to_string(), + scopes, + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let result: CreateKeyResponse = resp.json().await?; + println!("created key '{}'", result.info.name); + println!(" token: {}", result.key); + println!(" scopes: {}", format_scopes(&result.info.scopes)); + println!("\nsave this token, it will not be shown again."); + + Ok(()) +} + +pub async fn revoke(server: &str, token: &str, id: &str) -> anyhow::Result<()> { + let resp = client(token) + .delete(format!("{server}/api/keys/{id}")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + println!("revoked key {id}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_timestamp_trims_fractional() { + assert_eq!( + format_timestamp("2025-01-15T10:30:00.123456Z"), + "2025-01-15T10:30:00" + ); + } + + #[test] + fn format_timestamp_handles_no_fractional() { + assert_eq!( + format_timestamp("2025-01-15T10:30:00Z"), + "2025-01-15T10:30:00" + ); + } + + #[test] + fn format_timestamp_passthrough() { + assert_eq!(format_timestamp("unknown"), "unknown"); + } + + #[test] + fn format_scopes_array() { + let scopes = serde_json::json!(["management", "tunnels"]); + assert_eq!(format_scopes(&scopes), "management, tunnels"); + } + + #[test] + fn format_scopes_empty() { + let scopes = serde_json::json!([]); + assert_eq!(format_scopes(&scopes), ""); + } +} diff --git a/crates/funnel-client/src/status.rs b/crates/funnel-client/src/status.rs new file mode 100644 index 0000000..3458af0 --- /dev/null +++ b/crates/funnel-client/src/status.rs @@ -0,0 +1,127 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +struct TunnelInfo { + id: String, + uptime_secs: f64, + stats: TunnelStats, +} + +#[derive(Deserialize)] +struct TunnelStats { + bytes_in: u64, + bytes_out: u64, + requests: u64, +} + +pub async fn run(server: &str, token: &str) -> anyhow::Result<()> { + let resp = reqwest::Client::new() + .get(format!("{server}/api/tunnels")) + .header("authorization", format!("Bearer {token}")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let tunnels: Vec = resp.json().await?; + + if tunnels.is_empty() { + println!("no active tunnels"); + return Ok(()); + } + + for (i, t) in tunnels.iter().enumerate() { + println!("{}", t.id); + println!(" uptime: {}", format_duration(t.uptime_secs)); + println!(" requests: {}", t.stats.requests); + println!( + " traffic: {} in / {} out", + format_bytes(t.stats.bytes_in), + format_bytes(t.stats.bytes_out) + ); + if i < tunnels.len() - 1 { + println!(); + } + } + + if tunnels.len() > 1 { + println!("\n{} tunnels active", tunnels.len()); + } + + Ok(()) +} + +fn format_duration(secs: f64) -> String { + let total = secs as u64; + let hours = total / 3600; + let minutes = (total % 3600) / 60; + let seconds = total % 60; + + if hours > 0 { + format!("{hours}h{minutes:02}m{seconds:02}s") + } else if minutes > 0 { + format!("{minutes}m{seconds:02}s") + } else { + format!("{seconds}s") + } +} + +const KIB: u64 = 1024; +const MIB: u64 = 1024 * KIB; +const GIB: u64 = 1024 * MIB; + +fn format_bytes(bytes: u64) -> String { + if bytes >= GIB { + format!("{:.1} GiB", bytes as f64 / GIB as f64) + } else if bytes >= MIB { + format!("{:.1} MiB", bytes as f64 / MIB as f64) + } else if bytes >= KIB { + format!("{:.1} KiB", bytes as f64 / KIB as f64) + } else { + format!("{bytes} B") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_seconds() { + assert_eq!(format_duration(45.0), "45s"); + } + + #[test] + fn format_duration_minutes() { + assert_eq!(format_duration(125.0), "2m05s"); + } + + #[test] + fn format_duration_hours() { + assert_eq!(format_duration(3661.0), "1h01m01s"); + } + + #[test] + fn format_bytes_small() { + assert_eq!(format_bytes(500), "500 B"); + } + + #[test] + fn format_bytes_kib() { + assert_eq!(format_bytes(2048), "2.0 KiB"); + } + + #[test] + fn format_bytes_mib() { + assert_eq!(format_bytes(5 * 1024 * 1024), "5.0 MiB"); + } + + #[test] + fn format_bytes_gib() { + assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB"); + } +} diff --git a/crates/funnel-client/src/whoami.rs b/crates/funnel-client/src/whoami.rs new file mode 100644 index 0000000..3b8a522 --- /dev/null +++ b/crates/funnel-client/src/whoami.rs @@ -0,0 +1,40 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +struct UserInfo { + email: String, + name: Option, + role: String, +} + +pub async fn run(server: &str, token: &str, context_name: &str) -> anyhow::Result<()> { + let resp = reqwest::Client::new() + .get(format!("{server}/api/me")) + .header("authorization", format!("Bearer {token}")) + .send() + .await?; + + if resp.status().as_u16() == 404 { + println!("no user profile found (api key without linked user)"); + println!(" context: {context_name} ({server})"); + return Ok(()); + } + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let user: UserInfo = resp.json().await?; + + if let Some(name) = &user.name { + println!("{name} <{}>", user.email); + } else { + println!("{}", user.email); + } + println!(" role: {}", user.role); + println!(" context: {context_name} ({server})"); + + Ok(()) +} From 1890324ead63e0c9106231bd9c7b9b6e274e4bd2 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Mon, 11 May 2026 00:30:26 +0200 Subject: [PATCH 32/42] wip: add turso backend, teams, and user/session management --- Cargo.lock | 900 +++++++++++++++++- Cargo.toml | 2 + crates/funnel-client/Cargo.toml | 9 + crates/funnel-client/src/auth.rs | 4 +- crates/funnel-client/src/config.rs | 3 - crates/funnel-client/src/forwarder.rs | 497 +++++++++- crates/funnel-client/src/keys.rs | 7 +- crates/funnel-client/src/main.rs | 161 +++- crates/funnel-client/src/sessions.rs | 89 ++ crates/funnel-client/src/status.rs | 13 +- crates/funnel-client/src/teams.rs | 211 ++++ crates/funnel-client/src/tunnel.rs | 138 ++- crates/funnel-client/src/users.rs | 124 +++ crates/funnel-client/src/whoami.rs | 3 +- crates/funnel-core/src/protocol/handshake.rs | 4 + crates/funnel-core/src/protocol/mod.rs | 6 +- crates/funnel-core/src/protocol/request.rs | 5 + crates/funnel-e2e/Cargo.toml | 4 +- crates/funnel-e2e/funnel.db | Bin 0 -> 4096 bytes crates/funnel-e2e/funnel.db-wal | Bin 0 -> 234872 bytes crates/funnel-e2e/tests/e2e/auth.rs | 9 +- crates/funnel-e2e/tests/e2e/harness.rs | 60 +- crates/funnel-e2e/tests/e2e/main.rs | 76 ++ crates/funnel-server/Cargo.toml | 5 +- crates/funnel-server/src/api/keys.rs | 4 +- crates/funnel-server/src/api/mod.rs | 3 + crates/funnel-server/src/api/oauth.rs | 142 ++- crates/funnel-server/src/api/sessions.rs | 39 + crates/funnel-server/src/api/teams.rs | 188 ++++ crates/funnel-server/src/api/tunnels.rs | 80 +- crates/funnel-server/src/api/users.rs | 103 ++ crates/funnel-server/src/app.rs | 51 +- crates/funnel-server/src/auth/mod.rs | 50 + crates/funnel-server/src/db/api_keys.rs | 31 +- crates/funnel-server/src/db/mod.rs | 2 +- crates/funnel-server/src/db/teams.rs | 199 ++++ .../funnel-server/src/db/tunnel_sessions.rs | 10 + crates/funnel-server/src/db/users.rs | 79 ++ crates/funnel-server/src/error.rs | 7 +- crates/funnel-server/src/main.rs | 151 ++- crates/funnel-server/src/proxy/router.rs | 153 ++- crates/funnel-server/src/quic/handler.rs | 81 +- .../funnel-server/src/store/api_key_store.rs | 3 + .../src/store/memory/account_store.rs | 82 -- .../src/store/memory/api_key_store.rs | 221 ----- crates/funnel-server/src/store/memory/mod.rs | 4 - .../src/store/memory/session_recorder.rs | 221 ----- .../src/store/memory/user_store.rs | 143 --- crates/funnel-server/src/store/mod.rs | 10 +- .../src/store/pg/api_key_store.rs | 10 +- crates/funnel-server/src/store/pg/mod.rs | 1 + .../src/store/pg/session_recorder.rs | 4 + .../funnel-server/src/store/pg/team_store.rs | 101 ++ .../funnel-server/src/store/pg/user_store.rs | 25 + .../src/store/session_recorder.rs | 3 + crates/funnel-server/src/store/team_store.rs | 42 + .../src/store/turso/account_store.rs | 112 +++ .../src/store/turso/api_key_store.rs | 319 +++++++ crates/funnel-server/src/store/turso/mod.rs | 55 ++ .../src/store/turso/session_recorder.rs | 293 ++++++ .../src/store/turso/team_store.rs | 322 +++++++ .../src/store/turso/user_store.rs | 301 ++++++ crates/funnel-server/src/store/user_store.rs | 6 + crates/funnel-server/src/tunnel/connection.rs | 60 +- crates/funnel-server/src/tunnel/manager.rs | 6 + migrations/001_create_users.sql | 27 - migrations/001_initial.sql | 88 ++ migrations/001_turso.sql | 91 ++ migrations/002_create_api_keys.sql | 15 - migrations/003_create_tunnel_sessions.sql | 16 - 70 files changed, 5364 insertions(+), 920 deletions(-) create mode 100644 crates/funnel-client/src/sessions.rs create mode 100644 crates/funnel-client/src/teams.rs create mode 100644 crates/funnel-client/src/users.rs create mode 100644 crates/funnel-e2e/funnel.db create mode 100644 crates/funnel-e2e/funnel.db-wal create mode 100644 crates/funnel-server/src/api/sessions.rs create mode 100644 crates/funnel-server/src/api/teams.rs create mode 100644 crates/funnel-server/src/api/users.rs create mode 100644 crates/funnel-server/src/db/teams.rs delete mode 100644 crates/funnel-server/src/store/memory/account_store.rs delete mode 100644 crates/funnel-server/src/store/memory/api_key_store.rs delete mode 100644 crates/funnel-server/src/store/memory/mod.rs delete mode 100644 crates/funnel-server/src/store/memory/session_recorder.rs delete mode 100644 crates/funnel-server/src/store/memory/user_store.rs create mode 100644 crates/funnel-server/src/store/pg/team_store.rs create mode 100644 crates/funnel-server/src/store/team_store.rs create mode 100644 crates/funnel-server/src/store/turso/account_store.rs create mode 100644 crates/funnel-server/src/store/turso/api_key_store.rs create mode 100644 crates/funnel-server/src/store/turso/mod.rs create mode 100644 crates/funnel-server/src/store/turso/session_recorder.rs create mode 100644 crates/funnel-server/src/store/turso/team_store.rs create mode 100644 crates/funnel-server/src/store/turso/user_store.rs delete mode 100644 migrations/001_create_users.sql create mode 100644 migrations/001_initial.sql create mode 100644 migrations/001_turso.sql delete mode 100644 migrations/002_create_api_keys.sql delete mode 100644 migrations/003_create_tunnel_sessions.sql diff --git a/Cargo.lock b/Cargo.lock index 14c8eea..7b008d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,51 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aegis" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78412fa53e6da95324e8902c3641b3ff32ab45258582ea997eb9169c68ffa219" +dependencies = [ + "cc", + "softaes", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -623,6 +668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -641,8 +687,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -740,6 +788,29 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn", + "which", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -767,12 +838,51 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "branches" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e426eb5cc1900033930ec955317b302e68f19f326cc7bb0c8a86865a826cdf0c" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" +dependencies = [ + "chrono", + "git2", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -825,6 +935,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -837,6 +956,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cfg_block" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" + [[package]] name = "chacha20" version = "0.10.0" @@ -862,6 +987,27 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.1" @@ -981,7 +1127,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.59.0", ] @@ -1101,6 +1247,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -1119,6 +1274,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1132,6 +1297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1144,6 +1310,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1338,6 +1513,25 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "env_filter", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1426,6 +1620,12 @@ dependencies = [ "syn", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + [[package]] name = "fastbloom" version = "0.14.1" @@ -1510,12 +1710,17 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "bytes", "clap", "config", "console", "dirs", "funnel-core", + "futures-util", "http 1.4.0", + "http-body-util", + "hyper 1.9.0", + "hyper-util", "indicatif", "quinn", "reqwest", @@ -1523,6 +1728,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tokio-tungstenite 0.26.2", "tokio-util", "toml 0.8.23", "tracing", @@ -1554,9 +1760,11 @@ name = "funnel-e2e" version = "0.1.0" dependencies = [ "axum", + "futures-util", "reqwest", "serde_json", "tokio", + "tokio-tungstenite 0.26.2", ] [[package]] @@ -1576,6 +1784,8 @@ dependencies = [ "getrandom 0.3.4", "hex", "http-body-util", + "hyper 1.9.0", + "hyper-util", "instant-acme", "ipnetwork", "lru", @@ -1596,6 +1806,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "turso", "urlencoding", "uuid", "x509-parser", @@ -1709,6 +1920,21 @@ dependencies = [ "slab", ] +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "genawaiter-macro", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + [[package]] name = "generic-array" version = "0.14.7" @@ -1760,6 +1986,35 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gloo-net" version = "0.6.0" @@ -1887,6 +2142,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2283,10 +2544,19 @@ dependencies = [ "console", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.2.2", "web-time", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instant-acme" version = "0.8.5" @@ -2319,6 +2589,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" +[[package]] +name = "intrusive-collections" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" +dependencies = [ + "memoffset", +] + [[package]] name = "inventory" version = "0.3.24" @@ -2328,6 +2607,17 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io-uring" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2359,6 +2649,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2494,6 +2793,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2520,7 +2825,7 @@ dependencies = [ "or_poisoned", "paste", "reactive_graph", - "rustc-hash", + "rustc-hash 2.1.2", "send_wrapper", "serde", "serde_qs", @@ -2591,7 +2896,7 @@ dependencies = [ "cfg-if", "convert_case 0.7.1", "html-escape", - "itertools", + "itertools 0.14.0", "leptos_hot_reload", "prettyplease", "proc-macro-error2", @@ -2629,12 +2934,43 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libgit2-sys" +version = "0.18.4+1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b26f66f35e1871b22efcf7191564123d2a446ca0538cde63c23adfefa9b15b7" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmimalloc-sys" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1eacfa31c33ec25e873c136ba5669f00f9866d0688bea7be4d3f7e43067df6" +dependencies = [ + "cc", +] + [[package]] name = "libredox" version = "0.1.16" @@ -2657,12 +2993,36 @@ dependencies = [ "vcpkg", ] +[[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 = "linear-map" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2753,6 +3113,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metrics" version = "0.24.5" @@ -2801,17 +3170,48 @@ dependencies = [ ] [[package]] -name = "mime" -version = "0.3.17" +name = "miette" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] [[package]] -name = "minimal-lexical" -version = "0.2.1" +name = "miette-derive" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mimalloc" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3627c4272df786b9260cabaa46aec1d59c93ede723d4c3ef646c503816b0640" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -2990,6 +3390,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -3014,6 +3420,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "pack1" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b7bb0ecf2e447b1f20ee94ee79ef6eed1e9d4b3c36ce1903b9dea3bf205523" +dependencies = [ + "bytemuck", +] + [[package]] name = "parking" version = "2.2.1" @@ -3145,6 +3560,32 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3250,6 +3691,29 @@ dependencies = [ "yansi", ] +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quanta" version = "0.12.6" @@ -3276,7 +3740,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls 0.23.40", "socket2 0.6.3", "thiserror 2.0.18", @@ -3298,7 +3762,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls 0.23.40", "rustls-pki-types", "rustls-platform-verifier 0.6.2", @@ -3509,7 +3973,7 @@ dependencies = [ "hydration_context", "or_poisoned", "pin-project-lite", - "rustc-hash", + "rustc-hash 2.1.2", "send_wrapper", "serde", "slotmap", @@ -3524,12 +3988,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aadc7c19e3a360bf19cd595d2dc8b58ce67b9240b95a103fbc1317a8ff194237" dependencies = [ "guardian", - "itertools", + "itertools 0.14.0", "or_poisoned", "paste", "reactive_graph", "reactive_stores_macro", - "rustc-hash", + "rustc-hash 2.1.2", ] [[package]] @@ -3692,6 +4156,16 @@ dependencies = [ "serde", ] +[[package]] +name = "roaring" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" +dependencies = [ + "bytemuck", + "byteorder", +] + [[package]] name = "rsa" version = "0.9.10" @@ -3742,6 +4216,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3766,6 +4246,32 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.21.12" @@ -4139,6 +4645,12 @@ dependencies = [ "digest 0.10.7", ] +[[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" @@ -4212,6 +4724,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simsimd" +version = "6.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fb3bc3cdce07a7d7d4caa4c54f8aa967f6be41690482b54b24100a2253fa70" +dependencies = [ + "cc", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -4268,6 +4789,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "softaes" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef461faaeb36c340b6c887167a9054a034f6acfc50a014ead26a02b4356b3de" + [[package]] name = "spin" version = "0.9.8" @@ -4510,12 +5037,40 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "2.0.117" @@ -4574,7 +5129,7 @@ dependencies = [ "futures", "html-escape", "indexmap", - "itertools", + "itertools 0.14.0", "js-sys", "linear-map", "next_tuple", @@ -4585,7 +5140,7 @@ dependencies = [ "paste", "reactive_graph", "reactive_stores", - "rustc-hash", + "rustc-hash 2.1.2", "send_wrapper", "slotmap", "throw_error", @@ -4593,6 +5148,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4766,6 +5334,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.26.2", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4910,6 +5502,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -4979,6 +5584,221 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + +[[package]] +name = "turso" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2fe423c2c954948babb36edda12b737e321d8541d4eae519694f7d512ecab6" +dependencies = [ + "mimalloc", + "thiserror 2.0.18", + "tracing", + "tracing-subscriber", + "turso_sdk_kit", + "turso_sync_sdk_kit", +] + +[[package]] +name = "turso_core" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a8b54994ee025964459322bcdb4f6f78c5dba82643863dabfac680f16c8afa8" +dependencies = [ + "aegis", + "aes", + "aes-gcm", + "arc-swap", + "bitflags", + "branches", + "built", + "bumpalo", + "bytemuck", + "cfg_block", + "chrono", + "crossbeam-skiplist", + "either", + "fallible-iterator", + "fastbloom", + "hex", + "intrusive-collections", + "io-uring", + "libc", + "libloading", + "libm", + "miette", + "pack1", + "parking_lot", + "paste", + "polling", + "rand 0.9.4", + "rapidhash", + "regex", + "regex-syntax", + "roaring", + "rustc-hash 2.1.2", + "rustix 1.1.4", + "ryu", + "simsimd", + "strum", + "strum_macros", + "tempfile", + "thiserror 2.0.18", + "tracing", + "tracing-subscriber", + "turso_ext", + "turso_macros", + "turso_parser", + "twox-hash", + "uncased", + "uuid", +] + +[[package]] +name = "turso_ext" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de917b4c5881bfb34ccbb1dcf4992773bc39853eacf248955f2ece7e3cb3049" +dependencies = [ + "chrono", + "getrandom 0.3.4", + "turso_macros", +] + +[[package]] +name = "turso_macros" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2f62bb271d4cf202bc2acbeb8e2c3f764ec754924f144e704cdcba2e5b0c84" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "turso_parser" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ad89caa1c4888756bd027485499d1dc4c8420d15887ab32aa28b707c411221" +dependencies = [ + "bitflags", + "memchr", + "miette", + "strum", + "strum_macros", + "thiserror 2.0.18", + "turso_macros", +] + +[[package]] +name = "turso_sdk_kit" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00ff5b2cadd6c8b749511648d50c95f69bfa52efc5d88cc2e2deedd0beeb6c89" +dependencies = [ + "bindgen", + "env_logger", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit_macros", +] + +[[package]] +name = "turso_sdk_kit_macros" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "289f7ea7499419e6670363ca18e954ed53397bb1e03ab7eabbb267d9b05ab836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "turso_sync_engine" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea9860c615a7d8df43fc6ac4293636e9d743c1693513c81be22f0e9388624f58" +dependencies = [ + "base64", + "bytes", + "genawaiter", + "http 1.4.0", + "libc", + "prost", + "roaring", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", + "turso_core", + "turso_parser", + "uuid", +] + +[[package]] +name = "turso_sync_sdk_kit" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b669b19a5f4bfa7cfdf5045af36ca4a2087431c0d2844ec539ddcf951b5c9d2" +dependencies = [ + "bindgen", + "env_logger", + "genawaiter", + "parking_lot", + "tracing", + "tracing-appender", + "tracing-subscriber", + "turso_core", + "turso_sdk_kit", + "turso_sdk_kit_macros", + "turso_sync_engine", +] + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +dependencies = [ + "rand 0.9.4", +] + [[package]] name = "typed-builder" version = "0.20.1" @@ -5035,6 +5855,15 @@ dependencies = [ "syn", ] +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5068,6 +5897,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -5080,6 +5915,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -5110,6 +5955,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-width" version = "0.1.8" @@ -5137,6 +5988,7 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] @@ -5375,6 +6227,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "whoami" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index 76dd736..81e235b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,8 +49,10 @@ clap = { version = "4", features = ["derive", "env"] } # database sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "migrate", "ipnetwork"] } +turso = "0.4" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v7", "serde"] } +ipnetwork = "0.20" # misc pretty_assertions = "1" diff --git a/crates/funnel-client/Cargo.toml b/crates/funnel-client/Cargo.toml index ca60912..244a3b8 100644 --- a/crates/funnel-client/Cargo.toml +++ b/crates/funnel-client/Cargo.toml @@ -15,7 +15,11 @@ clap = { workspace = true, features = ["derive"] } config_rs = { package = "config", version = "0.15", default-features = false, features = ["toml"] } dirs = "6" console = "0.15" +bytes = "1" http.workspace = true +http-body-util = "0.1" +hyper = { version = "1", features = ["client", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } indicatif = "0.17" quinn.workspace = true reqwest = { workspace = true, features = ["rustls", "stream", "json"] } @@ -29,3 +33,8 @@ tracing.workspace = true tracing-subscriber.workspace = true url = "2" webpki-roots = "1" + +[dev-dependencies] +axum = { workspace = true, features = ["ws"] } +futures-util = "0.3" +tokio-tungstenite = "0.26" diff --git a/crates/funnel-client/src/auth.rs b/crates/funnel-client/src/auth.rs index 7e19add..68c6d53 100644 --- a/crates/funnel-client/src/auth.rs +++ b/crates/funnel-client/src/auth.rs @@ -8,6 +8,8 @@ use axum::routing::get; use serde::Deserialize; use tokio::sync::Notify; +use funnel_core::protocol::PROTOCOL_VERSION; + use crate::config; #[derive(Deserialize)] @@ -24,7 +26,7 @@ pub async fn login(context_name: &str, provider: &str) -> anyhow::Result<()> { let port = local_addr.port(); let login_url = format!( - "{}/auth/{provider}/authorize?cli_port={port}", + "{}/auth/v{PROTOCOL_VERSION}/{provider}/authorize?cli_port={port}", resolved.server ); diff --git a/crates/funnel-client/src/config.rs b/crates/funnel-client/src/config.rs index e27044e..fcaa088 100644 --- a/crates/funnel-client/src/config.rs +++ b/crates/funnel-client/src/config.rs @@ -30,8 +30,6 @@ pub struct Context { pub server: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub token: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub domain: Option, #[serde(default = "default_quic_port")] pub quic_port: u16, } @@ -41,7 +39,6 @@ impl Default for Context { Self { server: String::new(), token: None, - domain: None, quic_port: DEFAULT_QUIC_PORT, } } diff --git a/crates/funnel-client/src/forwarder.rs b/crates/funnel-client/src/forwarder.rs index 7ca14f1..efbf29a 100644 --- a/crates/funnel-client/src/forwarder.rs +++ b/crates/funnel-client/src/forwarder.rs @@ -1,4 +1,11 @@ use std::collections::HashMap; +use std::sync::{Mutex, PoisonError}; + +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper_util::rt::TokioIo; +use tokio::net::TcpStream; use funnel_core::protocol::request::{RequestMeta, ResponseMeta}; @@ -14,59 +21,187 @@ const HOP_BY_HOP_HEADERS: &[&str] = &[ "proxy-connection", ]; +const MAX_IDLE_CONNECTIONS: usize = 8; + +pub struct PooledConnection { + sender: hyper::client::conn::http1::SendRequest>, +} + pub struct Forwarder { - client: reqwest::Client, local_addr: String, + pool: Mutex>, +} + +pub struct UpgradeResult { + pub meta: ResponseMeta, + pub upgraded: hyper::upgrade::Upgraded, +} + +pub enum ForwardUpgradeResult { + Upgraded(UpgradeResult), + Rejected(ResponseMeta, Vec), +} + +pub enum ForwardResult { + Success { + meta: ResponseMeta, + body: Incoming, + conn: PooledConnection, + }, + LocalError { + meta: ResponseMeta, + body: Vec, + }, } impl Forwarder { - pub fn new(local_addr: String) -> Result { - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(std::time::Duration::from_secs(30)) - .pool_max_idle_per_host(10) - .build()?; + pub const fn new(local_addr: String) -> Self { + Self { + local_addr, + pool: Mutex::new(Vec::new()), + } + } + + async fn acquire(&self) -> anyhow::Result { + loop { + let candidate = { + let mut pool = self.pool.lock().unwrap_or_else(PoisonError::into_inner); + pool.pop() + }; + match candidate { + Some(conn) if conn.sender.is_ready() => return Ok(conn), + Some(_) => {} // stale, discard and try next + None => break, + } + } + + let stream = TcpStream::connect(&self.local_addr).await?; + let io = TokioIo::new(stream); + let (sender, conn) = hyper::client::conn::http1::handshake(io).await?; + tokio::spawn(async move { + if let Err(e) = conn.await { + tracing::debug!(error = %e, "connection task ended"); + } + }); + + Ok(PooledConnection { sender }) + } - Ok(Self { client, local_addr }) + pub fn release(&self, conn: PooledConnection) { + if !conn.sender.is_ready() { + return; + } + let mut pool = self.pool.lock().unwrap_or_else(PoisonError::into_inner); + if pool.len() < MAX_IDLE_CONNECTIONS { + pool.push(conn); + } } - pub async fn forward(&self, meta: RequestMeta, body: reqwest::Body) -> (ResponseMeta, Vec) { + pub async fn forward(&self, meta: RequestMeta, body: Bytes) -> ForwardResult { match self.try_forward(&meta, body).await { - Ok(resp) => resp, - Err(e) => error_response(502, &format!("local service error: {e}")), + Ok((resp_meta, incoming, conn)) => ForwardResult::Success { + meta: resp_meta, + body: incoming, + conn, + }, + Err(e) => { + let (resp_meta, resp_body) = + error_response(502, &format!("local service error: {e}")); + ForwardResult::LocalError { + meta: resp_meta, + body: resp_body, + } + } } } async fn try_forward( &self, meta: &RequestMeta, - body: reqwest::Body, - ) -> anyhow::Result<(ResponseMeta, Vec)> { - let url = format!("http://{}{}", self.local_addr, meta.path); + body: Bytes, + ) -> anyhow::Result<(ResponseMeta, Incoming, PooledConnection)> { + let mut conn = self.acquire().await?; + + let req = build_request(meta, &self.local_addr, body, false)?; + let resp = conn.sender.send_request(req).await?; + + let status = resp.status().as_u16(); + let headers = collect_response_headers(resp.headers(), false); + let resp_meta = ResponseMeta { status, headers }; + + Ok((resp_meta, resp.into_body(), conn)) + } + + pub async fn forward_upgrade( + &self, + meta: &RequestMeta, + ) -> anyhow::Result { + // upgrades are one shot, no pool reuse + let stream = TcpStream::connect(&self.local_addr).await?; + let io = TokioIo::new(stream); + + let (mut sender, conn) = + hyper::client::conn::http1::handshake(io).await?; + tokio::spawn(async move { + if let Err(e) = conn.with_upgrades().await { + tracing::debug!(error = %e, "upgrade connection task ended"); + } + }); - let method: http::Method = meta.method.parse()?; - let mut builder = self.client.request(method, &url); + let req = build_request(meta, &self.local_addr, Bytes::new(), true)?; + let resp = sender.send_request(req).await?; + let status = resp.status().as_u16(); + let is_101 = resp.status() == hyper::StatusCode::SWITCHING_PROTOCOLS; + let headers = collect_response_headers(resp.headers(), is_101); + let resp_meta = ResponseMeta { status, headers }; + + if is_101 { + let upgraded = hyper::upgrade::on(resp).await?; + Ok(ForwardUpgradeResult::Upgraded(UpgradeResult { + meta: resp_meta, + upgraded, + })) + } else { + let resp_body = resp.into_body().collect().await?.to_bytes().to_vec(); + Ok(ForwardUpgradeResult::Rejected(resp_meta, resp_body)) + } + } +} + +fn build_request( + meta: &RequestMeta, + local_addr: &str, + body: Bytes, + is_upgrade: bool, +) -> anyhow::Result>> { + let method: http::Method = meta.method.parse()?; + let mut builder = hyper::Request::builder().method(method).uri(&meta.path); + + if let Some(headers) = builder.headers_mut() { for (name, values) in &meta.headers { - if is_hop_by_hop(name) || name.eq_ignore_ascii_case("host") { + if !is_upgrade && is_hop_by_hop(name) { + continue; + } + if name.eq_ignore_ascii_case("host") { continue; } for value in values { - builder = builder.header(name.as_str(), value.as_str()); + if let (Ok(n), Ok(v)) = ( + http::HeaderName::try_from(name.as_str()), + http::HeaderValue::from_str(value), + ) { + headers.append(n, v); + } } } - - builder = builder.header("host", &self.local_addr); - builder = builder.body(body); - - let resp = builder.send().await?; - - let status = resp.status().as_u16(); - let headers = collect_response_headers(resp.headers()); - let body = resp.bytes().await?.to_vec(); - - Ok((ResponseMeta { status, headers }, body)) + headers.insert( + http::header::HOST, + http::HeaderValue::from_str(local_addr)?, + ); } + + Ok(builder.body(Full::new(body))?) } fn is_hop_by_hop(name: &str) -> bool { @@ -75,10 +210,13 @@ fn is_hop_by_hop(name: &str) -> bool { .any(|h| h.eq_ignore_ascii_case(name)) } -fn collect_response_headers(headers: &reqwest::header::HeaderMap) -> HashMap> { +fn collect_response_headers( + headers: &http::HeaderMap, + keep_hop_by_hop: bool, +) -> HashMap> { let mut map: HashMap> = HashMap::new(); for (name, value) in headers { - if is_hop_by_hop(name.as_str()) { + if !keep_hop_by_hop && is_hop_by_hop(name.as_str()) { continue; } let val = value.to_str().unwrap_or("").to_string(); @@ -97,6 +235,77 @@ fn error_response(status: u16, msg: &str) -> (ResponseMeta, Vec) { mod tests { use super::*; + use std::net::TcpListener; + + use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; + use axum::response::IntoResponse; + use axum::routing::{get, post}; + use axum::Router; + use futures_util::StreamExt; + use http_body_util::BodyExt; + + type TestResult = Result<(), Box>; + + fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .and_then(|l| l.local_addr()) + .map(|a| a.port()) + .unwrap_or(0) + } + + async fn start_test_server(app: Router) -> String { + let port = free_port(); + let addr = format!("127.0.0.1:{port}"); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .expect("bind test server"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("test server crashed"); + }); + addr + } + + fn test_meta(method: &str, path: &str) -> RequestMeta { + RequestMeta { + method: method.into(), + path: path.into(), + headers: HashMap::new(), + upgrade: false, + } + } + + fn upgrade_meta(path: &str) -> RequestMeta { + let mut headers = HashMap::new(); + headers.insert("connection".into(), vec!["Upgrade".into()]); + headers.insert("upgrade".into(), vec!["websocket".into()]); + headers.insert( + "sec-websocket-key".into(), + vec!["dGhlIHNhbXBsZSBub25jZQ==".into()], + ); + headers.insert("sec-websocket-version".into(), vec!["13".into()]); + RequestMeta { + method: "GET".into(), + path: path.into(), + headers, + upgrade: true, + } + } + + /// helper to drain an Incoming body from a ForwardResult::Success + async fn collect_success(result: ForwardResult) -> (ResponseMeta, Vec, PooledConnection) { + match result { + ForwardResult::Success { meta, body, conn } => { + let bytes = body.collect().await.unwrap().to_bytes().to_vec(); + (meta, bytes, conn) + } + ForwardResult::LocalError { meta, .. } => { + panic!("expected Success, got LocalError with status {}", meta.status); + } + } + } + #[test] fn hop_by_hop_detection() { assert!(is_hop_by_hop("Connection")); @@ -113,4 +322,230 @@ mod tests { assert_eq!(body, b"test error"); assert!(meta.headers.contains_key("content-type")); } + + #[tokio::test] + async fn forward_get_request() -> TestResult { + let app = Router::new().route("/hello", get(|| async { "hello" })); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let result = fwd.forward(test_meta("GET", "/hello"), Bytes::new()).await; + let (meta, body, _conn) = collect_success(result).await; + + assert_eq!(meta.status, 200); + assert_eq!(body, b"hello"); + Ok(()) + } + + #[tokio::test] + async fn forward_post_with_body() -> TestResult { + let app = Router::new().route( + "/echo", + post(|body: String| async move { format!("got: {body}") }), + ); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let result = fwd + .forward(test_meta("POST", "/echo"), Bytes::from("payload")) + .await; + let (meta, body, _conn) = collect_success(result).await; + + assert_eq!(meta.status, 200); + assert_eq!(body, b"got: payload"); + Ok(()) + } + + #[tokio::test] + async fn forward_preserves_response_headers() -> TestResult { + let app = Router::new().route( + "/headers", + get(|| async { + ( + [("x-custom", "test-val"), ("content-type", "text/plain")], + "ok", + ) + }), + ); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let result = fwd.forward(test_meta("GET", "/headers"), Bytes::new()).await; + let (meta, _, _conn) = collect_success(result).await; + + assert_eq!(meta.status, 200); + assert_eq!(meta.headers.get("x-custom").map(|v| v.as_slice()), Some(["test-val".to_string()].as_slice())); + Ok(()) + } + + #[tokio::test] + async fn forward_strips_hop_by_hop_response_headers() -> TestResult { + let app = Router::new().route( + "/hop", + get(|| async { + ([("connection", "keep-alive"), ("x-real", "value")], "ok") + }), + ); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let result = fwd.forward(test_meta("GET", "/hop"), Bytes::new()).await; + let (meta, _, _conn) = collect_success(result).await; + + assert!(!meta.headers.contains_key("connection")); + assert!(meta.headers.contains_key("x-real")); + Ok(()) + } + + #[tokio::test] + async fn forward_returns_404() -> TestResult { + let app = Router::new(); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let result = fwd.forward(test_meta("GET", "/missing"), Bytes::new()).await; + let (meta, _, _conn) = collect_success(result).await; + + assert_eq!(meta.status, 404); + Ok(()) + } + + #[tokio::test] + async fn forward_unreachable_returns_502() -> TestResult { + let fwd = Forwarder::new("127.0.0.1:1".to_string()); + + let result = fwd.forward(test_meta("GET", "/"), Bytes::new()).await; + + match result { + ForwardResult::LocalError { meta, body } => { + assert_eq!(meta.status, 502); + assert!(String::from_utf8_lossy(&body).contains("local service error")); + } + ForwardResult::Success { .. } => { + panic!("expected LocalError for unreachable host"); + } + } + Ok(()) + } + + #[tokio::test] + async fn forward_sends_request_headers() -> TestResult { + let app = Router::new().route( + "/check", + get(|headers: axum::http::HeaderMap| async move { + headers + .get("x-test") + .and_then(|v| v.to_str().ok()) + .unwrap_or("missing") + .to_string() + }), + ); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let mut meta = test_meta("GET", "/check"); + meta.headers + .insert("x-test".into(), vec!["present".into()]); + + let result = fwd.forward(meta, Bytes::new()).await; + let (resp, body, _conn) = collect_success(result).await; + + assert_eq!(resp.status, 200); + assert_eq!(body, b"present"); + Ok(()) + } + + #[tokio::test] + async fn connection_pool_reuses_connections() -> TestResult { + let app = Router::new().route("/ok", get(|| async { "ok" })); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + // first request + let result = fwd.forward(test_meta("GET", "/ok"), Bytes::new()).await; + let (meta, _, conn) = collect_success(result).await; + assert_eq!(meta.status, 200); + fwd.release(conn); + + // pool should have one connection now + assert_eq!(fwd.pool.lock().unwrap().len(), 1); + + // second request should reuse the pooled connection + let result = fwd.forward(test_meta("GET", "/ok"), Bytes::new()).await; + let (meta, _, _conn) = collect_success(result).await; + assert_eq!(meta.status, 200); + + // pool should be empty since the connection is in use + assert_eq!(fwd.pool.lock().unwrap().len(), 0); + Ok(()) + } + + async fn ws_echo_handler(ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(ws_echo) + } + + async fn ws_echo(mut socket: WebSocket) { + while let Some(Ok(msg)) = socket.next().await { + if matches!(msg, Message::Close(_)) { + break; + } + if matches!(msg, Message::Text(_) | Message::Binary(_)) { + if socket.send(msg).await.is_err() { + break; + } + } + } + } + + #[tokio::test] + async fn forward_upgrade_websocket() -> TestResult { + let app = Router::new().route("/ws", get(ws_echo_handler)); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let meta = upgrade_meta("/ws"); + let result = fwd.forward_upgrade(&meta).await?; + + match result { + ForwardUpgradeResult::Upgraded(u) => { + assert_eq!(u.meta.status, 101); + // verify upgrade headers are preserved + assert!(u.meta.headers.contains_key("upgrade")); + assert!(u.meta.headers.contains_key("connection")); + } + ForwardUpgradeResult::Rejected(meta, _) => { + panic!("expected upgrade, got rejection with status {}", meta.status); + } + } + Ok(()) + } + + #[tokio::test] + async fn forward_upgrade_rejected_by_non_ws_endpoint() -> TestResult { + let app = Router::new().route("/not-ws", get(|| async { "not a websocket" })); + let addr = start_test_server(app).await; + let fwd = Forwarder::new(addr); + + let meta = upgrade_meta("/not-ws"); + let result = fwd.forward_upgrade(&meta).await?; + + match result { + ForwardUpgradeResult::Rejected(meta, _) => { + assert_ne!(meta.status, 101); + } + ForwardUpgradeResult::Upgraded(_) => { + panic!("expected rejection from non-ws endpoint"); + } + } + Ok(()) + } + + #[tokio::test] + async fn forward_upgrade_unreachable_returns_error() { + let fwd = Forwarder::new("127.0.0.1:1".to_string()); + let meta = upgrade_meta("/ws"); + + let result = fwd.forward_upgrade(&meta).await; + assert!(result.is_err()); + } } diff --git a/crates/funnel-client/src/keys.rs b/crates/funnel-client/src/keys.rs index 26a355b..2e12925 100644 --- a/crates/funnel-client/src/keys.rs +++ b/crates/funnel-client/src/keys.rs @@ -1,3 +1,4 @@ +use funnel_core::protocol::PROTOCOL_VERSION; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] @@ -56,7 +57,7 @@ fn format_timestamp(ts: &str) -> &str { pub async fn list(server: &str, token: &str) -> anyhow::Result<()> { let resp = client(token) - .get(format!("{server}/api/keys")) + .get(format!("{server}/api/v{PROTOCOL_VERSION}/keys")) .send() .await?; @@ -100,7 +101,7 @@ pub async fn create( let scopes = scopes.map(|s| s.split(',').map(|s| s.trim().to_string()).collect()); let resp = client(token) - .post(format!("{server}/api/keys")) + .post(format!("{server}/api/v{PROTOCOL_VERSION}/keys")) .json(&CreateKeyRequest { name: name.to_string(), scopes, @@ -125,7 +126,7 @@ pub async fn create( pub async fn revoke(server: &str, token: &str, id: &str) -> anyhow::Result<()> { let resp = client(token) - .delete(format!("{server}/api/keys/{id}")) + .delete(format!("{server}/api/v{PROTOCOL_VERSION}/keys/{id}")) .send() .await?; diff --git a/crates/funnel-client/src/main.rs b/crates/funnel-client/src/main.rs index ca0c206..c5ab48f 100644 --- a/crates/funnel-client/src/main.rs +++ b/crates/funnel-client/src/main.rs @@ -4,8 +4,11 @@ mod display; mod forwarder; mod keys; mod runner; +mod sessions; mod status; +mod teams; mod tunnel; +mod users; mod whoami; use std::sync::Arc; @@ -56,6 +59,10 @@ enum Command { /// skip tls certificate verification (for development) #[arg(long)] insecure: bool, + + /// associate tunnel with a team + #[arg(long)] + team: Option, }, /// log in via oauth Login { @@ -74,6 +81,26 @@ enum Command { #[command(subcommand)] command: KeysCommand, }, + /// view tunnel sessions + Sessions { + /// show all sessions (admin only) + #[arg(long)] + all: bool, + + /// maximum number of sessions to show + #[arg(long, default_value_t = 50)] + limit: u32, + }, + /// manage users (admin only) + Users { + #[command(subcommand)] + command: UsersCommand, + }, + /// manage teams + Teams { + #[command(subcommand)] + command: TeamsCommand, + }, /// manage server contexts Context { #[command(subcommand)] @@ -105,6 +132,77 @@ enum KeysCommand { }, } +#[derive(Subcommand)] +enum UsersCommand { + /// list all users + List { + /// maximum number of users to show + #[arg(long, default_value_t = 50)] + limit: u32, + }, + /// set a user's role + SetRole { + /// user id + id: String, + /// new role (admin or member) + role: String, + }, + /// deactivate a user + Deactivate { + /// user id + id: String, + }, + /// reactivate a user + Reactivate { + /// user id + id: String, + }, +} + +#[derive(Subcommand)] +enum TeamsCommand { + /// list teams + List, + /// create a new team + Create { + /// team name + name: String, + }, + /// delete a team + Delete { + /// team id + id: String, + }, + /// list team members + Members { + /// team id + id: String, + }, + /// add a member to a team + AddMember { + /// team id + team_id: String, + /// user id to add + user_id: String, + }, + /// remove a member from a team + RemoveMember { + /// team id + team_id: String, + /// user id to remove + user_id: String, + }, + /// set a member's role in a team + SetRole { + /// team id + team_id: String, + /// user id + user_id: String, + /// role (owner or member) + role: String, + }, +} + #[derive(Subcommand)] enum ContextCommand { /// list all contexts @@ -170,6 +268,7 @@ async fn main() -> anyhow::Result<()> { token, quic_port, insecure, + team, } => { run_http( ctx_override, @@ -179,6 +278,7 @@ async fn main() -> anyhow::Result<()> { token, quic_port, insecure, + team, ) .await } @@ -215,12 +315,64 @@ async fn main() -> anyhow::Result<()> { KeysCommand::Revoke { id } => keys::revoke(&resolved.server, &token, &id).await, } } + Command::Sessions { all, limit } => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; + sessions::list(&resolved.server, &token, all, limit).await + } + Command::Users { command } => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; + match command { + UsersCommand::List { limit } => { + users::list(&resolved.server, &token, limit).await + } + UsersCommand::SetRole { id, role } => { + users::set_role(&resolved.server, &token, &id, &role).await + } + UsersCommand::Deactivate { id } => { + users::deactivate(&resolved.server, &token, &id).await + } + UsersCommand::Reactivate { id } => { + users::reactivate(&resolved.server, &token, &id).await + } + } + } + Command::Teams { command } => { + let cfg = config::load()?; + let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; + match command { + TeamsCommand::List => teams::list(&resolved.server, &token).await, + TeamsCommand::Create { name } => { + teams::create(&resolved.server, &token, &name).await + } + TeamsCommand::Delete { id } => { + teams::delete(&resolved.server, &token, &id).await + } + TeamsCommand::Members { id } => { + teams::members(&resolved.server, &token, &id).await + } + TeamsCommand::AddMember { team_id, user_id } => { + teams::add_member(&resolved.server, &token, &team_id, &user_id).await + } + TeamsCommand::RemoveMember { team_id, user_id } => { + teams::remove_member(&resolved.server, &token, &team_id, &user_id).await + } + TeamsCommand::SetRole { + team_id, + user_id, + role, + } => { + teams::set_role(&resolved.server, &token, &team_id, &user_id, &role).await + } + } + } Command::Context { command } => run_context(command), Command::Config { command } => run_config(&command), } } -#[allow(clippy::fn_params_excessive_bools)] +#[allow(clippy::fn_params_excessive_bools, clippy::too_many_arguments)] async fn run_http( ctx_override: Option<&str>, address: String, @@ -229,6 +381,7 @@ async fn run_http( token_flag: Option, quic_port_flag: Option, insecure: bool, + team: Option, ) -> anyhow::Result<()> { let local_addr = normalize_address(&address); @@ -264,12 +417,15 @@ async fn run_http( }; let public_url = - runner::build_public_url(&server_url, &tunnel_id).unwrap_or_else(|| format!("")); + runner::build_public_url(&server_url, &tunnel_id).unwrap_or_else(|| "".to_string()); println!("funnel\n"); println!(" public url {public_url}"); println!(" forwarding {local_addr}"); println!(" tunnel id {tunnel_id}"); + if let Some(ref team_name) = team { + println!(" team {team_name}"); + } println!(); let display = Arc::new(display::TunnelDisplay::new()); @@ -281,6 +437,7 @@ async fn run_http( token, quic_port, insecure, + team, )?; let shutdown = CancellationToken::new(); diff --git a/crates/funnel-client/src/sessions.rs b/crates/funnel-client/src/sessions.rs new file mode 100644 index 0000000..eb24639 --- /dev/null +++ b/crates/funnel-client/src/sessions.rs @@ -0,0 +1,89 @@ +use funnel_core::protocol::PROTOCOL_VERSION; +use serde::Deserialize; + +#[derive(Deserialize)] +struct TunnelSession { + id: String, + tunnel_id: String, + connected_at: String, + disconnected_at: Option, + bytes_in: i64, + bytes_out: i64, + requests: i64, +} + +pub async fn list(server: &str, token: &str, all: bool, limit: u32) -> anyhow::Result<()> { + let mut url = format!("{server}/api/v{PROTOCOL_VERSION}/sessions?limit={limit}"); + if all { + url.push_str("&all=true"); + } + + let resp = reqwest::Client::new() + .get(&url) + .header("authorization", format!("Bearer {token}")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let sessions: Vec = resp.json().await?; + + if sessions.is_empty() { + println!("no sessions"); + return Ok(()); + } + + for (i, s) in sessions.iter().enumerate() { + let status = if s.disconnected_at.is_some() { + "closed" + } else { + "active" + }; + println!("{} ({})", s.tunnel_id, status); + println!(" id: {}", s.id); + println!(" started: {}", format_timestamp(&s.connected_at)); + if let Some(ref end) = s.disconnected_at { + println!(" ended: {}", format_timestamp(end)); + } + println!(" requests: {}", s.requests); + println!( + " traffic: {} in / {} out", + format_bytes(s.bytes_in), + format_bytes(s.bytes_out) + ); + if i < sessions.len() - 1 { + println!(); + } + } + + if sessions.len() > 1 { + println!("\n{} sessions", sessions.len()); + } + + Ok(()) +} + +fn format_timestamp(ts: &str) -> &str { + let without_frac = ts.split('.').next().unwrap_or(ts); + without_frac.strip_suffix('Z').unwrap_or(without_frac) +} + +const KIB: i64 = 1024; +const MIB: i64 = 1024 * KIB; +const GIB: i64 = 1024 * MIB; + +fn format_bytes(bytes: i64) -> String { + if bytes >= GIB { + format!("{:.1} GiB", bytes as f64 / GIB as f64) + } else if bytes >= MIB { + format!("{:.1} MiB", bytes as f64 / MIB as f64) + } else if bytes >= KIB { + format!("{:.1} KiB", bytes as f64 / KIB as f64) + } else { + format!("{bytes} B") + } +} diff --git a/crates/funnel-client/src/status.rs b/crates/funnel-client/src/status.rs index 3458af0..fded933 100644 --- a/crates/funnel-client/src/status.rs +++ b/crates/funnel-client/src/status.rs @@ -1,3 +1,4 @@ +use funnel_core::protocol::PROTOCOL_VERSION; use serde::Deserialize; #[derive(Deserialize)] @@ -5,6 +6,10 @@ struct TunnelInfo { id: String, uptime_secs: f64, stats: TunnelStats, + #[serde(default)] + owner_id: Option, + #[serde(default)] + team_id: Option, } #[derive(Deserialize)] @@ -16,7 +21,7 @@ struct TunnelStats { pub async fn run(server: &str, token: &str) -> anyhow::Result<()> { let resp = reqwest::Client::new() - .get(format!("{server}/api/tunnels")) + .get(format!("{server}/api/v{PROTOCOL_VERSION}/tunnels")) .header("authorization", format!("Bearer {token}")) .send() .await?; @@ -43,6 +48,12 @@ pub async fn run(server: &str, token: &str) -> anyhow::Result<()> { format_bytes(t.stats.bytes_in), format_bytes(t.stats.bytes_out) ); + if let Some(ref owner) = t.owner_id { + println!(" owner: {owner}"); + } + if let Some(ref team) = t.team_id { + println!(" team: {team}"); + } if i < tunnels.len() - 1 { println!(); } diff --git a/crates/funnel-client/src/teams.rs b/crates/funnel-client/src/teams.rs new file mode 100644 index 0000000..248adb0 --- /dev/null +++ b/crates/funnel-client/src/teams.rs @@ -0,0 +1,211 @@ +use funnel_core::protocol::PROTOCOL_VERSION; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +struct Team { + id: String, + name: String, +} + +#[derive(Deserialize)] +struct TeamMembership { + user_id: String, + role: String, + created_at: String, +} + +fn client(token: &str) -> reqwest::Client { + reqwest::Client::builder() + .default_headers({ + let mut h = reqwest::header::HeaderMap::new(); + if let Ok(v) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) { + h.insert(reqwest::header::AUTHORIZATION, v); + } + h + }) + .build() + .unwrap_or_default() +} + +pub async fn list(server: &str, token: &str) -> anyhow::Result<()> { + let resp = client(token) + .get(format!("{server}/api/v{PROTOCOL_VERSION}/teams")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let teams: Vec = resp.json().await?; + + if teams.is_empty() { + println!("no teams"); + return Ok(()); + } + + for (i, t) in teams.iter().enumerate() { + println!("{}", t.name); + println!(" id: {}", t.id); + if i < teams.len() - 1 { + println!(); + } + } + + if teams.len() > 1 { + println!("\n{} teams", teams.len()); + } + + Ok(()) +} + +#[derive(Serialize)] +struct CreateTeamRequest { + name: String, +} + +pub async fn create(server: &str, token: &str, name: &str) -> anyhow::Result<()> { + let resp = client(token) + .post(format!("{server}/api/v{PROTOCOL_VERSION}/teams")) + .json(&CreateTeamRequest { + name: name.to_string(), + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let team: Team = resp.json().await?; + println!("created team '{}' ({})", team.name, team.id); + Ok(()) +} + +pub async fn delete(server: &str, token: &str, id: &str) -> anyhow::Result<()> { + let resp = client(token) + .delete(format!("{server}/api/v{PROTOCOL_VERSION}/teams/{id}")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + println!("deleted team {id}"); + Ok(()) +} + +pub async fn members(server: &str, token: &str, id: &str) -> anyhow::Result<()> { + let resp = client(token) + .get(format!("{server}/api/v{PROTOCOL_VERSION}/teams/{id}/members")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let members: Vec = resp.json().await?; + + if members.is_empty() { + println!("no members"); + return Ok(()); + } + + for m in &members { + println!("{} ({})", m.user_id, m.role); + println!(" joined: {}", format_timestamp(&m.created_at)); + } + + if members.len() > 1 { + println!("\n{} members", members.len()); + } + + Ok(()) +} + +fn format_timestamp(ts: &str) -> &str { + let without_frac = ts.split('.').next().unwrap_or(ts); + without_frac.strip_suffix('Z').unwrap_or(without_frac) +} + +#[derive(Serialize)] +struct AddMemberRequest { + user_id: String, +} + +pub async fn add_member(server: &str, token: &str, team_id: &str, user_id: &str) -> anyhow::Result<()> { + let resp = client(token) + .post(format!("{server}/api/v{PROTOCOL_VERSION}/teams/{team_id}/members")) + .json(&AddMemberRequest { + user_id: user_id.to_string(), + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + println!("added user {user_id} to team {team_id}"); + Ok(()) +} + +pub async fn remove_member(server: &str, token: &str, team_id: &str, user_id: &str) -> anyhow::Result<()> { + let resp = client(token) + .delete(format!("{server}/api/v{PROTOCOL_VERSION}/teams/{team_id}/members/{user_id}")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + println!("removed user {user_id} from team {team_id}"); + Ok(()) +} + +#[derive(Serialize)] +struct SetRoleRequest { + role: String, +} + +pub async fn set_role( + server: &str, + token: &str, + team_id: &str, + user_id: &str, + role: &str, +) -> anyhow::Result<()> { + let resp = client(token) + .put(format!( + "{server}/api/v{PROTOCOL_VERSION}/teams/{team_id}/members/{user_id}/role" + )) + .json(&SetRoleRequest { + role: role.to_string(), + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + println!("set role '{role}' for user {user_id} in team {team_id}"); + Ok(()) +} diff --git a/crates/funnel-client/src/tunnel.rs b/crates/funnel-client/src/tunnel.rs index 6268059..8c34549 100644 --- a/crates/funnel-client/src/tunnel.rs +++ b/crates/funnel-client/src/tunnel.rs @@ -2,18 +2,22 @@ use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, Instant}; -use tokio_util::io::ReaderStream; +use bytes::Bytes; +use http_body_util::BodyExt; +use hyper::body::Incoming; +use hyper_util::rt::TokioIo; +use tokio::io; use tokio_util::sync::CancellationToken; use url::Url; -use funnel_core::protocol::QUIC_ALPN; +use funnel_core::protocol::{PROTOCOL_VERSION, QUIC_ALPN}; use funnel_core::protocol::frame; use funnel_core::protocol::handshake::{Handshake, HandshakeResponse}; use funnel_core::protocol::request::RequestMeta; use funnel_core::tunnel::id::TunnelId; use crate::display::{RequestResult, TunnelDisplay}; -use crate::forwarder::Forwarder; +use crate::forwarder::{ForwardResult, ForwardUpgradeResult, Forwarder}; const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); const MAX_CONCURRENT_REQUESTS: usize = 128; @@ -25,6 +29,7 @@ pub struct TunnelClient { quic_addr: SocketAddr, host: String, endpoint: quinn::Endpoint, + team: Option, } impl TunnelClient { @@ -35,6 +40,7 @@ impl TunnelClient { token: Option, quic_port: u16, insecure: bool, + team: Option, ) -> anyhow::Result { let url = Url::parse(server_url)?; let host = url @@ -63,6 +69,7 @@ impl TunnelClient { quic_addr, host, endpoint, + team, }) } @@ -73,7 +80,7 @@ impl TunnelClient { display: &Arc, ) -> anyhow::Result<()> { let conn = self.connect().await?; - let forwarder = Arc::new(Forwarder::new(self.local_addr.clone())?); + let forwarder = Arc::new(Forwarder::new(self.local_addr.clone())); let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_REQUESTS)); display.set_message("waiting for requests..."); @@ -132,6 +139,8 @@ impl TunnelClient { let handshake = Handshake { tunnel_id: self.tunnel_id.clone(), token: self.token.clone(), + team: self.team.clone(), + version: Some(PROTOCOL_VERSION), }; frame::write_meta(&mut send, &handshake).await?; @@ -164,22 +173,117 @@ async fn handle_stream( let method = meta.method.clone(); let path = meta.path.clone(); - // stream body from quic directly to local service (no buffer cap) - let body_stream = ReaderStream::new(recv); - let body = reqwest::Body::wrap_stream(body_stream); + if meta.upgrade { + return handle_upgrade_stream(send, recv, forwarder, &meta, &method, &path, start).await; + } - let (resp_meta, resp_body) = forwarder.forward(meta, body).await; + // read full body from quic for normal requests + let body = recv.read_to_end(64 * 1024 * 1024).await?; + let body = Bytes::from(body); + + match forwarder.forward(meta, body).await { + ForwardResult::Success { + meta: resp_meta, + body: incoming, + conn, + } => { + frame::write_meta(&mut send, &resp_meta).await?; + stream_body_to_quic(incoming, &mut send).await?; + send.finish()?; + forwarder.release(conn); + Ok(RequestResult { + method, + path, + status: resp_meta.status, + duration: start.elapsed(), + }) + } + ForwardResult::LocalError { + meta: resp_meta, + body: resp_body, + } => { + frame::write_meta(&mut send, &resp_meta).await?; + send.write_all(&resp_body).await?; + send.finish()?; + Ok(RequestResult { + method, + path, + status: resp_meta.status, + duration: start.elapsed(), + }) + } + } +} - frame::write_meta(&mut send, &resp_meta).await?; - send.write_all(&resp_body).await?; - send.finish()?; +async fn stream_body_to_quic( + mut body: Incoming, + send: &mut quinn::SendStream, +) -> anyhow::Result<()> { + while let Some(frame) = body.frame().await { + let frame = frame?; + if let Some(data) = frame.data_ref() { + send.write_all(data).await?; + } + } + Ok(()) +} - Ok(RequestResult { - method, - path, - status: resp_meta.status, - duration: start.elapsed(), - }) +async fn handle_upgrade_stream( + mut send: quinn::SendStream, + mut recv: quinn::RecvStream, + forwarder: &Forwarder, + meta: &RequestMeta, + method: &str, + path: &str, + start: Instant, +) -> anyhow::Result { + let result = forwarder.forward_upgrade(meta).await?; + + match result { + ForwardUpgradeResult::Upgraded(upgrade) => { + frame::write_meta(&mut send, &upgrade.meta).await?; + + let status = upgrade.meta.status; + + let upgraded_io = TokioIo::new(upgrade.upgraded); + let (mut local_read, mut local_write) = io::split(upgraded_io); + + let quic_to_local = io::copy(&mut recv, &mut local_write); + let local_to_quic = io::copy(&mut local_read, &mut send); + + tokio::select! { + r = quic_to_local => { + if let Err(e) = r { + tracing::debug!(error = %e, "quic to local copy ended"); + } + } + r = local_to_quic => { + if let Err(e) = r { + tracing::debug!(error = %e, "local to quic copy ended"); + } + } + } + + Ok(RequestResult { + method: method.to_string(), + path: path.to_string(), + status, + duration: start.elapsed(), + }) + } + ForwardUpgradeResult::Rejected(resp_meta, resp_body) => { + frame::write_meta(&mut send, &resp_meta).await?; + send.write_all(&resp_body).await?; + send.finish()?; + + Ok(RequestResult { + method: method.to_string(), + path: path.to_string(), + status: resp_meta.status, + duration: start.elapsed(), + }) + } + } } fn is_loopback(host: &str, addr: &SocketAddr) -> bool { diff --git a/crates/funnel-client/src/users.rs b/crates/funnel-client/src/users.rs new file mode 100644 index 0000000..7dccd9a --- /dev/null +++ b/crates/funnel-client/src/users.rs @@ -0,0 +1,124 @@ +use funnel_core::protocol::PROTOCOL_VERSION; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +struct User { + id: String, + email: String, + name: Option, + role: String, + deactivated_at: Option, +} + +fn client(token: &str) -> reqwest::Client { + reqwest::Client::builder() + .default_headers({ + let mut h = reqwest::header::HeaderMap::new(); + if let Ok(v) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) { + h.insert(reqwest::header::AUTHORIZATION, v); + } + h + }) + .build() + .unwrap_or_default() +} + +pub async fn list(server: &str, token: &str, limit: u32) -> anyhow::Result<()> { + let resp = client(token) + .get(format!("{server}/api/v{PROTOCOL_VERSION}/users?limit={limit}")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let users: Vec = resp.json().await?; + + if users.is_empty() { + println!("no users"); + return Ok(()); + } + + for (i, u) in users.iter().enumerate() { + let name = u.name.as_deref().unwrap_or("(no name)"); + let status = if u.deactivated_at.is_some() { + " [deactivated]" + } else { + "" + }; + println!("{} ({}){}", u.email, u.role, status); + println!(" id: {}", u.id); + println!(" name: {name}"); + if i < users.len() - 1 { + println!(); + } + } + + if users.len() > 1 { + println!("\n{} users", users.len()); + } + + Ok(()) +} + +#[derive(Serialize)] +struct SetRoleRequest { + role: String, +} + +pub async fn set_role(server: &str, token: &str, id: &str, role: &str) -> anyhow::Result<()> { + let resp = client(token) + .put(format!("{server}/api/v{PROTOCOL_VERSION}/users/{id}/role")) + .json(&SetRoleRequest { + role: role.to_string(), + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let user: User = resp.json().await?; + println!("updated {} to role '{}'", user.email, user.role); + Ok(()) +} + +pub async fn deactivate(server: &str, token: &str, id: &str) -> anyhow::Result<()> { + let resp = client(token) + .post(format!("{server}/api/v{PROTOCOL_VERSION}/users/{id}/deactivate")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let user: User = resp.json().await?; + println!("deactivated {}", user.email); + Ok(()) +} + +pub async fn reactivate(server: &str, token: &str, id: &str) -> anyhow::Result<()> { + let resp = client(token) + .post(format!("{server}/api/v{PROTOCOL_VERSION}/users/{id}/reactivate")) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + + let user: User = resp.json().await?; + println!("reactivated {}", user.email); + Ok(()) +} diff --git a/crates/funnel-client/src/whoami.rs b/crates/funnel-client/src/whoami.rs index 3b8a522..c8d427c 100644 --- a/crates/funnel-client/src/whoami.rs +++ b/crates/funnel-client/src/whoami.rs @@ -1,3 +1,4 @@ +use funnel_core::protocol::PROTOCOL_VERSION; use serde::Deserialize; #[derive(Deserialize)] @@ -9,7 +10,7 @@ struct UserInfo { pub async fn run(server: &str, token: &str, context_name: &str) -> anyhow::Result<()> { let resp = reqwest::Client::new() - .get(format!("{server}/api/me")) + .get(format!("{server}/api/v{PROTOCOL_VERSION}/me")) .header("authorization", format!("Bearer {token}")) .send() .await?; diff --git a/crates/funnel-core/src/protocol/handshake.rs b/crates/funnel-core/src/protocol/handshake.rs index 5407f51..829063e 100644 --- a/crates/funnel-core/src/protocol/handshake.rs +++ b/crates/funnel-core/src/protocol/handshake.rs @@ -6,6 +6,10 @@ use crate::tunnel::id::TunnelId; pub struct Handshake { pub tunnel_id: TunnelId, pub token: Option, + #[serde(default)] + pub team: Option, + #[serde(default)] + pub version: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/funnel-core/src/protocol/mod.rs b/crates/funnel-core/src/protocol/mod.rs index d8218d6..f35df78 100644 --- a/crates/funnel-core/src/protocol/mod.rs +++ b/crates/funnel-core/src/protocol/mod.rs @@ -2,5 +2,9 @@ pub mod frame; pub mod handshake; pub mod request; +/// unified version number shared across protocol, schema, and API +pub const PROTOCOL_VERSION: u32 = 1; + /// application level protocol name used for QUIC ALPN negotiation -pub const QUIC_ALPN: &[u8] = b"funnel/1"; +pub static QUIC_ALPN: std::sync::LazyLock> = + std::sync::LazyLock::new(|| format!("funnel/{PROTOCOL_VERSION}").into_bytes()); diff --git a/crates/funnel-core/src/protocol/request.rs b/crates/funnel-core/src/protocol/request.rs index 590d8cf..90d8ddd 100644 --- a/crates/funnel-core/src/protocol/request.rs +++ b/crates/funnel-core/src/protocol/request.rs @@ -17,6 +17,8 @@ pub struct RequestMeta { pub method: String, pub path: String, pub headers: HashMap>, + #[serde(default)] + pub upgrade: bool, } impl RequestMeta { @@ -72,6 +74,7 @@ mod tests { h.insert("content-type".into(), vec!["application/json".into()]); h }, + upgrade: false, }; let encoded = rmp_serde::to_vec_named(&meta)?; @@ -100,6 +103,7 @@ mod tests { method: "GET".into(), path: "/".into(), headers: HashMap::new(), + upgrade: false, }; assert_eq!(meta.http_method().ok(), Some(http::Method::GET)); } @@ -110,6 +114,7 @@ mod tests { method: "".into(), path: "/".into(), headers: HashMap::new(), + upgrade: false, }; assert!(meta.http_method().is_err()); } diff --git a/crates/funnel-e2e/Cargo.toml b/crates/funnel-e2e/Cargo.toml index a693938..eadf9e0 100644 --- a/crates/funnel-e2e/Cargo.toml +++ b/crates/funnel-e2e/Cargo.toml @@ -8,6 +8,8 @@ workspace = true [dev-dependencies] tokio.workspace = true +tokio-tungstenite = "0.26" reqwest = { workspace = true, features = ["rustls", "json"] } serde_json.workspace = true -axum.workspace = true +axum = { workspace = true, features = ["ws"] } +futures-util = "0.3" diff --git a/crates/funnel-e2e/funnel.db b/crates/funnel-e2e/funnel.db new file mode 100644 index 0000000000000000000000000000000000000000..84967eee6d0a3e3e643e29b61d69f70b8ed1ec1f GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AO#03|Ns9mfbu}{_<>$s1OpKB yf>a6MS2`*?8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*Oqai@M5C8zc7Yh{t literal 0 HcmV?d00001 diff --git a/crates/funnel-e2e/funnel.db-wal b/crates/funnel-e2e/funnel.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..cc86ec9a2c745c076ae0794118ae2e673f7a5b49 GIT binary patch literal 234872 zcmeI53ve8FeaG+gxYJvHZyZ~&F-fGYPx0v#$&bc$6lrbL$a11bu_g(ZlWt`lk?!QX zldg#=PJKcV8 z^r_8#n?#epxV@p{?k&gP{)N-Sw+|$;mOhzE&&0BNr`Wop!gsT-JN+x{ZJf#8zUqY+ zo(~r-t9461-f-_xpZMP>*7}>@=o9~#AOHafKmY;|fB*y_009U<00J*x0;l{z%`p7A z_H1l?+R98!Su?Td2`imRq>}EXiryi!XT;P;dTtpob@w{G&Dlin9~?0cnM3;Ekr91x zbYLK)$7W}z6IMJb?(s*=qoUPWV+iQ>J${86FGzg#ft!#2`-4x~_w3G4f<4Cs0SG_< z0uX=z1Rwwb2tWV=5P-niB;Xns_|ZeJ{$1-&j=e7*%m0C|`3GzBMf0j4009U<00Izz z00bZa0SG_<0xu^5Tl|%^osn>)yFJ|1-VxR#k$vH=ec`==fh&*TkKXXLx4eI2W425l zL3I&3dt71Fc?4BWJ47Bq)5)g8VulF<5P$##AOHafKmY;|fB*z8Ljp!sK-;hReTih; zI+-~>Ez-`TvAJwYE=T7wLhc@o$cJj9vV7ML*|loEt|F*un*aWc)XvLB`KQvQl$T9< zTOyv9zuz|0f4FC8Ouy9}3+X~^pO|*b;`PzN{@X{*ke-aqSaOw+(4UB9W9jHzI^Q>> zr&H7Ubpn% zNL$4Xg(nH=bF*TP3tbCqj7ytGP-$OuDv#ht4T0e&j$QYu;yi*f{sQ*qs~#61SHZ@| z1OW&@00Izz00bZa0SG_<0uZ=l1gh+3R^tM%eZ@_8%{}p}Pu1Ctd8FYvkB=0Unbzo4E=zUwBJA2XCBr256aYb^JxF@ z$gu9|uOAuIovePjy@9{LLRr5BBZ4*LFJRx}2b5mmnP;~@H8AzrH;Iced925i~009U<00Izz z00bZa0SG|gd=qf_3;h1NZ(Z}YqhI@28GiveY~UP-UFUGs{sOi2A6?`xAexvU009U< z00Izz00bZafn^Cy)dsa)E&km4g8RHmyq7lgYN4)-1-LKLN=8!l#t~T zJk0SIc=7%M_C0=`(hD4Td(GB!ztQK=3oQE~%t{DA00Izz00bZa0SG_<0uX?}S|Q-l z3-tee#hK^LliSMZ1?r1lyj|yT)p~)tdSeN_fM{fb00bZa0SG_<0uX?}4 z65^O3009U<00Izz00bZa0SG_<0_UB8OD|Ay`-7*lmanypUO<*HbdJQXbGT}~Ktt_P z{sQ$z?XzM969gat0SG_<0uX?}G6Z%tEL$XgHW9tYI+clb#GJXt`QCb0k$9(Ft#5FJ zpj2y#oL)OF_!5G$$=HnLj+KRUakSB?SY~Re_Sv*GnK?6|8nq?b0pSX70-)w#Gr>~PY z{_B?c8e$a$AOHafKmY;|fB*y_009U<00L`)fIS4zsKy1>)d!;Q+PL9;xw!>jIi<;$>UmoOFT~T?#0g7@?VB8(bG|y3 zuzTt=kLvvgr9jj?+CMxptS90pqpG_s2^THekF=X}o}^G7E~MK%4E;5)Hit~TNZ;N+ zELErE5s*hfox&0G=!lN*pvdVlj617?+D^@%t1NtS<=p_KZ=jsTcQ87ao|gF^Aw8X% z_9QbcBq7aMGviiTWE`z1HzaP!?}OsxfGaSMz`nyKuy1ByGa%s!UQg(D9-O(YH zxggLB6de2mckkG|n|d-lBIzys(7_z4C9 z2tWV=5P$##AOHafKmY;|cv%o|=>=Z%*sYuPwZCOk8NEPpr2=~hVbywp=9;UQ)eDH# zOb~zo1Rwwb2wZXkxr*j|tlli7t*T(MtShG$)~z&FFN@LHfzd)ny(&>xI+-55z(V~z z`~{@HLP*D7z$2ki@&h}UT*(gV%DpX%Hr=ehUVeG0|QQ1N26k6Gss6ZEUOAQv1 z)=!^VwnQd+foV&+Ajm(J&UHH?pDhAOHafKmY;|fB*y_009U<;9?MP=>hN1(j7xSyu3#<z{}fWTTMuz!6(yM3qMCxg(LpE+QTn0jx|aBoka8Pc66NyOcO3}I6&#_N;W zXkyk$dz?s#S2~ehBIhrp#}gtX>RBDq$4_OgOf-=czW45MKp`>}R-ICF*=2jBt>bf6 zCYxENlYw492^$vG5l}0rM<65kD}Q(3FMz**_)u4xhMM`_Ku~LG@t+-&pXRDvJqBH$ z->UV(d~hot0q^IwYR6w78MjU@`ofmqhS7+8s5UAC+J3FncYD5ThkU4-ud4`Zn&!Vh zBhS2Slz-3*WG1GB6e4=UO3U9suB8gsJHp*lZ*w+T^sTJNCapjSGC^>Cm&m=>LA4Jc6}$ znt44CfB*y_009U<00Izz00bZafh7sJ@(AjVyzQ|^|M8h?%H$DjEZS3pm|t}s!G`J| ztssv;bYg-41Rwx`^GqODwIQhO-|5e_7kuPA%6h4n_OxwuglnZLz)|KqObK*29w4qe^4_F z@ykr+RJgMP+)EcCkHEgi->CEg@2-C8j?cg5fe)Y;IL|-7*&PHR009U<00Izz00bZa z0SG|g@+9EW3yeJR)-QkUj_)5UqZimz^ipgqf>rAUHdf!YBE5j<#smQfK;X(JkgMAm z)OtjSzN=)2J|9>2MAtXH$RK@w^CiRd&Ylx-1HFLT)9}LBR~2}B<4*VvQm4>1){5HR zdm{KK;!Kt-4_Bz#DAy+9`664TOE>OnShjkcvpZq#=FBzD_tv}2-JEu{zQJW>rCKk{ zy93`rdo^a_s)~TqxG?r+eO_C5Y)r5AYUi?99gkPab={+rV{C>i}OhfBf6`wZAC9IGA#2` zJu-%9ei?s(j0enm_l1hTK+ovFh#n5NzNj6@9QNWJTs^<%s-V{1 z?mzpgLOOf#dha1@b>|Ly4zNVNyWB}4?lBo1G7p+V=3uWmtQV5y4Le)A&m1sEOue^f zxVNXz49U8#!U4AQ=1$>PVG_I4<|{HnEuD9-s@Xa(qHs8dfxm#2$s|(A4D|@SIrL7( zy|^NCRU3lZ{+<3@dm&%kla3#?FJDjF#xmLCp6+rNOBx+WFLrSl`_~7w+jo}ypm866 zw8OLi4fDs=iDJz={co(!e@eLfdwSz9u%vYW{sKiF2kN@aUEdhg4z>7mbA`8uGD46S zb#Wa5wW7&+b2y*O{eykxQN915%-%GQ_76LDAgcSK{n&35wX#sLBcB{w+HwJZ0sIB< z7rJSoDOIwtxNVZED8r8nnE>z*hSPQ|SeE{`L=#zVNT9>+l!2D7W1_ z2?QVj0SG_<0uX=z1Rwwb2teRc6S&$w05vX<{Z-G|`rl5!o;-p}eOh>a2tWV=5P$## zAOHafKmY;|fWSo|VCMvEQ{w{H{o6MlIq{|Bdd3AV3VWUe0uX=z1Rwwb2tWV=5P$## zAaJP(xcmjy-~6VYPk%UaOBsKG?L`N@Ud*rBUtpX6%&Pna#Aa)E%GGQOY6sf=xsF0k zqc@FSruKUqx0g%$^<>-2oye?T&kDWGNme)T7r2C zVEB-cYql)1cF2_Qb4bQ!ERP$5sxVvHM`3O@9?LFOYc+Snrl8g%GUU%Jyj|S+<@gJ@ zd5HISz5#y$Q}>t;l(=M_d~?rLTVz3EcaYcnk{%)j-tHZQ^a)WvIGK$mX5|X8oJuCG ziKVXdkRDHn6l%}vkUoAYYh|K|r0z{06h0^idqxLF^swrbn#(TRD{UR0vohJtGM$WE zMRQQI6PeAzyG`Z7N2g+$DYdY!%$JesCpU6u)Zb&BlC7na>8G0V$FP3!G1%up?YnuU z$6#;dJ_hG}8~6+K+%jMm*&Qe!o!f@`5BCg>>9?9=1xp+E#jUUS-Ld%lAf&5{z`f6W z`)t~pOq}$t!CwG>0sIB<7r)_xRhDUf`d98T|I6A9?1B=mpm9umi6Q0uX=z z1Rwwb2tWV=5P$##AW%fWr59-b`HpuCesN+~8NEPD(e@j}{HpZ=+kL-StzO_4MaOU+ zW^*;$%LeJ~Ac8kY-%=(f;qAX*&Ali{Ux-nl7nm6r(Koxq0rdz3iB=IuOj}Dv%R@T# z2+#`*>xsCKb+}al&axB)x!0fuqXxAzjT0 z9b6$iJXO0$LZgnuzLwR#>sERU_D1gKS8s`3H36-+rBvw{^+eH#nycLvT+s#{YJ-~j z-at@mY4M*OlafT$t{#KVYsi0otJVwi!L3kx#VM-2=oEOn%vjl&kT}NN?FAKA>*+Jg z{;n&fk6if0r;cIi3&OawT9oJ!y3WdisLDQrnW*rpPfW|%K$(l7sK0$F$VI(PZf_V> z0d2ol>MF=@8IcdwMrHY~9kOfHd|ibUqu!s9XI?hSKb5X?E}Kf6aCvwh=1PrP5GDyL z{uAQVq@#1`Y1vm4<4H|>MXXA2*P5@nCQjFi&S~6=O=J^d{|oBY<$l1II?=`{e^4_F zQ7KBY$hcGh?xhMB9rwD`)+4a*@mrK$;MrS${K9B+-Es5+=V7RUT|fW=5P$##AOHaf zKmY;|fB*z8e*%^EA*ykKn+D(cv+q9GS8vxT^fxDc;-4?8cly@(Dk^+8>$=@6{#S_q z#nYviA45=yY_|m-p~>0?%ut7XE?ks94@zBfUmOQt8sw`|LU`c^mAv%Y`uWL z`RhLMj|l=0fB*y_009U<00Izz00bZafh&waYvpL|N(LUp%c{0tni?1Q-hZaw^ZGmg z<30Itfgk#se|Uwxa1Iv&5P$##AOHafKmY;|fB*y_0D;8>wp4c3E}=dU9RuQj!C&B~ z9~!^s2R%=IgmHnz2x)-;1Rwwb2tWV=5P$##AOHafT(Ja7j|vn$@&z1J%4Q_@n< zvf`aL?w#xykL(@~cXsUQ>JE2z#=1J=6T)^d9O;Tj#`na!#>1A?)g_*0GIC=izDH~m zxiJ=wjCXcVMs{cJiQe|QQ$3@D(|7Eci6vvltQjkr-4WW6ugRR*ao3W*gPmO+H!h3| z*h2t?ae+5KzT@n79)9wZ_zqs=i;Sm(00bZa0SG_<0uX=z1Rwwb2%JX(Uf;n|BN*Z- zo9w4mdVy!Z`9FbczWm&C`Eh}-_?o|R9uJCLK>z{}fB*y_009U<00Izz00ba#p$UYl zMmMjh7C}ZR7R13c+Xtk^1)6?&?txF;|EIU17r4-;iTy(W0uX=z1Rwwb2tWV=5P$## zE*t@`UZ7MqgE)e9;(x(+@W-t```}+2`*!BX1s?M?KX&1ckv&5I0uX=z1Rwwb2tWV= V5P$##Ah2ozeO0-}+L!83@c)E^0B`^R literal 0 HcmV?d00001 diff --git a/crates/funnel-e2e/tests/e2e/auth.rs b/crates/funnel-e2e/tests/e2e/auth.rs index 2e3d699..199fc9a 100644 --- a/crates/funnel-e2e/tests/e2e/auth.rs +++ b/crates/funnel-e2e/tests/e2e/auth.rs @@ -245,17 +245,20 @@ async fn me_requires_auth() -> TestResult { } #[tokio::test(flavor = "multi_thread")] -async fn me_returns_404_for_seed_key_user() -> TestResult { +async fn me_returns_seed_user() -> TestResult { let env = AuthTestEnv::start().await?; - // seed key is created with nil UUID, no matching user exists + // seed key creates a proper system user let resp = env .client .get(env.url("/api/me")) .header("authorization", env.auth_header()) .send() .await?; - assert_eq!(resp.status(), 404); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await?; + assert_eq!(body["email"], "system@funnel.local"); + assert_eq!(body["role"], "admin"); Ok(()) } diff --git a/crates/funnel-e2e/tests/e2e/harness.rs b/crates/funnel-e2e/tests/e2e/harness.rs index bf329c8..f82bf2b 100644 --- a/crates/funnel-e2e/tests/e2e/harness.rs +++ b/crates/funnel-e2e/tests/e2e/harness.rs @@ -1,4 +1,5 @@ use std::fs::File; +use std::io::{BufRead, BufReader}; use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -6,7 +7,10 @@ use std::time::Duration; use axum::Router; use axum::extract::DefaultBodyLimit; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::response::IntoResponse; use axum::routing::{get, post}; +use futures_util::StreamExt; use tokio::task::JoinHandle; const TUNNEL_ID: &str = "e2etest"; @@ -20,9 +24,12 @@ pub struct TestEnv { local_server_handle: JoinHandle<()>, server_log: PathBuf, client_log: PathBuf, + turso_db_path: PathBuf, pub http_port: u16, pub host_header: String, pub client: reqwest::Client, + #[allow(dead_code)] + pub seed_key: String, } impl TestEnv { @@ -31,12 +38,17 @@ impl TestEnv { let http_port = free_port()?; let quic_port = free_port()?; let host_header = format!("{TUNNEL_ID}.localhost:{http_port}"); + let turso_db_path = std::env::temp_dir().join(format!( + "funnel-e2e-{}.db", + std::process::id() + )); - let (server_process, server_log) = start_server_process(http_port, quic_port)?; + let (server_process, server_log, seed_key) = + start_server_process(http_port, quic_port, &turso_db_path)?; wait_for_tcp(http_port).await; let (client_process, client_log) = - start_client_process(local_port, http_port, quic_port, TUNNEL_ID)?; + start_client_process(local_port, http_port, quic_port, TUNNEL_ID, &seed_key)?; let client = reqwest::Client::new(); wait_for_tunnel(&client, http_port, &host_header).await; @@ -47,9 +59,11 @@ impl TestEnv { local_server_handle: local_handle, server_log, client_log, + turso_db_path, http_port, host_header, client, + seed_key, }) } @@ -96,6 +110,7 @@ impl Drop for TestEnv { let _ = std::fs::remove_file(&self.server_log); let _ = std::fs::remove_file(&self.client_log); + let _ = std::fs::remove_file(&self.turso_db_path); } } @@ -163,16 +178,37 @@ fn mock_app() -> Router { "/large", post(|body: axum::body::Bytes| async move { body }), ) + .route("/ws-echo", get(ws_echo_handler)) .layer(DefaultBodyLimit::max(MOCK_BODY_LIMIT)) } +async fn ws_echo_handler(ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(ws_echo) +} + +async fn ws_echo(mut socket: WebSocket) { + while let Some(Ok(msg)) = socket.next().await { + let should_echo = matches!(msg, Message::Text(_) | Message::Binary(_)); + if !should_echo { + if matches!(msg, Message::Close(_)) { + break; + } + continue; + } + if socket.send(msg).await.is_err() { + break; + } + } +} + fn start_server_process( http_port: u16, quic_port: u16, -) -> Result<(Child, PathBuf), Box> { + turso_db_path: &Path, +) -> Result<(Child, PathBuf, String), Box> { let (stderr_file, log_path) = log_file("server")?; - let child = Command::new(binary_path("funnel-server")?) + let mut child = Command::new(binary_path("funnel-server")?) .args([ "--port", &http_port.to_string(), @@ -180,12 +216,21 @@ fn start_server_process( &quic_port.to_string(), "--host", "127.0.0.1", + "--turso-db-path", + &turso_db_path.to_string_lossy(), + "--seed-api-key", ]) - .stdout(Stdio::null()) + .stdout(Stdio::piped()) .stderr(Stdio::from(stderr_file)) .spawn()?; - Ok((child, log_path)) + let stdout = child.stdout.take().ok_or("stdout not piped")?; + let mut reader = BufReader::new(stdout); + let mut seed_key = String::new(); + reader.read_line(&mut seed_key)?; + let seed_key = seed_key.trim().to_string(); + + Ok((child, log_path, seed_key)) } fn start_client_process( @@ -193,6 +238,7 @@ fn start_client_process( server_http_port: u16, quic_port: u16, tunnel_id: &str, + token: &str, ) -> Result<(Child, PathBuf), Box> { let (stderr_file, log_path) = log_file("client")?; @@ -207,6 +253,8 @@ fn start_client_process( "--quic-port", &quic_port.to_string(), "--insecure", + "--token", + token, ]) .stdout(Stdio::null()) .stderr(Stdio::from(stderr_file)) diff --git a/crates/funnel-e2e/tests/e2e/main.rs b/crates/funnel-e2e/tests/e2e/main.rs index 47e8c32..07d0f34 100644 --- a/crates/funnel-e2e/tests/e2e/main.rs +++ b/crates/funnel-e2e/tests/e2e/main.rs @@ -1,8 +1,10 @@ mod auth; mod harness; +use futures_util::{SinkExt, StreamExt}; use harness::TestEnv; use reqwest::Method; +use tokio_tungstenite::tungstenite; type TestResult = Result<(), Box>; @@ -116,3 +118,77 @@ async fn unknown_tunnel_returns_404() -> TestResult { assert_eq!(resp.status(), 404); Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn websocket_echo() -> TestResult { + let env = TestEnv::start().await?; + + let request = ws_request(&env, "/ws-echo")?; + let (mut ws, _resp) = tokio_tungstenite::connect_async(request).await?; + + ws.send(tungstenite::Message::Text("hello websocket".into())).await?; + + let msg = ws.next().await + .ok_or("no message received")??; + + assert_eq!(msg.into_text()?, "hello websocket"); + + ws.close(None).await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn websocket_multiple_messages() -> TestResult { + let env = TestEnv::start().await?; + + let request = ws_request(&env, "/ws-echo")?; + let (mut ws, _) = tokio_tungstenite::connect_async(request).await?; + + for i in 0..5 { + let msg = format!("msg {i}"); + ws.send(tungstenite::Message::Text(msg.clone().into())).await?; + + let resp = ws.next().await + .ok_or("no message received")??; + assert_eq!(resp.into_text()?, msg); + } + + ws.close(None).await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn websocket_binary_data() -> TestResult { + let env = TestEnv::start().await?; + + let request = ws_request(&env, "/ws-echo")?; + let (mut ws, _) = tokio_tungstenite::connect_async(request).await?; + + let payload = vec![0u8, 1, 2, 255, 254, 253]; + ws.send(tungstenite::Message::Binary(payload.clone().into())).await?; + + let resp = ws.next().await + .ok_or("no message received")??; + assert_eq!(resp.into_data().to_vec(), payload); + + ws.close(None).await?; + Ok(()) +} + +fn ws_request( + env: &TestEnv, + path: &str, +) -> Result, tungstenite::http::Error> { + let url = format!("ws://127.0.0.1:{}{path}", env.http_port); + tungstenite::http::Request::builder() + .uri(&url) + .header("host", &env.host_header) + .header("connection", "Upgrade") + .header("upgrade", "websocket") + .header("sec-websocket-version", "13") + .header( + "sec-websocket-key", + tungstenite::handshake::client::generate_key(), + ) + .body(()) +} diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index f9860a8..cdf8899 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -17,7 +17,9 @@ dashmap = "6" getrandom = "0.3" hex = "0.4" http-body-util = "0.1" -ipnetwork = "0.20" +hyper = { version = "1", features = ["server", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +ipnetwork.workspace = true metrics = "0.24" metrics-exporter-prometheus = "0.16" quinn.workspace = true @@ -25,6 +27,7 @@ rcgen = "0.13" serde.workspace = true serde_json.workspace = true sqlx.workspace = true +turso.workspace = true thiserror.workspace = true tokio.workspace = true tokio-util.workspace = true diff --git a/crates/funnel-server/src/api/keys.rs b/crates/funnel-server/src/api/keys.rs index e69b64e..cb3582f 100644 --- a/crates/funnel-server/src/api/keys.rs +++ b/crates/funnel-server/src/api/keys.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use axum::Json; use axum::extract::{Path, State}; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -14,6 +15,7 @@ use crate::error::AppError; pub struct CreateKeyRequest { pub name: String, pub scopes: Option>, + pub expires_at: Option>, } #[derive(Serialize)] @@ -41,7 +43,7 @@ pub async fn create( let (plaintext, info) = state .api_keys - .create(auth.user_id, &req.name, &scopes) + .create(auth.user_id, &req.name, &scopes, req.expires_at) .await?; Ok(Json(CreateKeyResponse { diff --git a/crates/funnel-server/src/api/mod.rs b/crates/funnel-server/src/api/mod.rs index 69b401d..3fe1eb8 100644 --- a/crates/funnel-server/src/api/mod.rs +++ b/crates/funnel-server/src/api/mod.rs @@ -3,4 +3,7 @@ pub mod health; pub mod keys; pub mod me; pub mod oauth; +pub mod sessions; +pub mod teams; pub mod tunnels; +pub mod users; diff --git a/crates/funnel-server/src/api/oauth.rs b/crates/funnel-server/src/api/oauth.rs index cf5391e..4b6a240 100644 --- a/crates/funnel-server/src/api/oauth.rs +++ b/crates/funnel-server/src/api/oauth.rs @@ -135,23 +135,51 @@ pub async fn callback( } } } else { - // no account exists, create new user then link account - let user = match state - .users - .create(NewUser { - email: info.email, - name: info.name, - avatar_url: info.avatar_url, - }) - .await - { + // no account for this provider, check if user exists by email + let existing_user = match state.users.find_by_email(&info.email).await { Ok(u) => u, Err(e) => { - tracing::error!(error = %e, "failed to create user"); + tracing::error!(error = %e, "failed to look up user by email"); return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } }; + let user = if let Some(user) = existing_user { + // user exists from a different provider, link this account + match state + .users + .update_profile( + user.id, + info.name.as_deref(), + info.avatar_url.as_deref(), + ) + .await + { + Ok(u) => u, + Err(e) => { + tracing::error!(error = %e, "failed to update user profile"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + } + } else { + // completely new user + match state + .users + .create(NewUser { + email: info.email, + name: info.name, + avatar_url: info.avatar_url, + }) + .await + { + Ok(u) => u, + Err(e) => { + tracing::error!(error = %e, "failed to create user"); + return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); + } + } + }; + if let Err(e) = state .accounts .create(NewAccount { @@ -166,13 +194,45 @@ pub async fn callback( return html_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } - user + // auto promote first user to admin, or match initial admin email + let should_promote = match state.users.count().await { + Ok(1) => true, + Ok(_) => { + state + .initial_admin_email + .as_ref() + .is_some_and(|email| email == &user.email) + && !user.is_admin() + } + Err(_) => false, + }; + + if should_promote { + match state.users.update_role(user.id, "admin").await { + Ok(promoted) => { + tracing::info!( + user_id = %promoted.id, + email = %promoted.email, + "auto promoted user to admin" + ); + promoted + } + Err(e) => { + tracing::error!(error = %e, "failed to promote user to admin"); + user + } + } + } else { + user + } }; - let key_name = format!("cli-{}", uuid::Uuid::now_v7()); + // revoke any existing cli key before issuing a new one + let _ = state.api_keys.revoke_by_name(user.id, "cli").await; + let (plaintext, _) = match state .api_keys - .create(user.id, &key_name, &default_scopes()) + .create(user.id, "cli", &default_scopes(), None) .await { Ok(k) => k, @@ -209,10 +269,7 @@ mod tests { use crate::auth::oauth::{OAuthError, OAuthProvider, OAuthState, OAuthUserInfo}; use crate::store::BoxFuture; use crate::store::health::UptimeHealthReporter; - use crate::store::memory::account_store::InMemoryAccountStore; - use crate::store::memory::api_key_store::InMemoryApiKeyStore; - use crate::store::memory::session_recorder::InMemorySessionRecorder; - use crate::store::memory::user_store::InMemoryUserStore; + use crate::store::turso; use crate::tunnel::manager::TunnelManager; struct MockProvider; @@ -257,7 +314,7 @@ mod tests { } } - fn test_state(with_oauth: bool) -> Arc { + async fn test_state(with_oauth: bool) -> Arc { let oauth_state = if with_oauth { let mut providers: HashMap> = HashMap::new(); providers.insert("mock".into(), Arc::new(MockProvider)); @@ -269,15 +326,27 @@ mod tests { None }; + let db = turso::open(":memory:") + .await + .unwrap_or_else(|e| panic!("open turso: {e}")); + Arc::new(AppState { tunnels: Arc::new(TunnelManager::new()), - api_keys: Arc::new(InMemoryApiKeyStore::new()), - users: Arc::new(InMemoryUserStore::new()), - accounts: Arc::new(InMemoryAccountStore::new()), - sessions: Arc::new(InMemorySessionRecorder::new()), + api_keys: Arc::new(turso::api_key_store::TursoApiKeyStore::new( + Arc::clone(&db), + )), + users: Arc::new(turso::user_store::TursoUserStore::new(Arc::clone(&db))), + accounts: Arc::new(turso::account_store::TursoAccountStore::new( + Arc::clone(&db), + )), + sessions: Arc::new(turso::session_recorder::TursoSessionRecorder::new( + Arc::clone(&db), + )), + teams: Arc::new(turso::team_store::TursoTeamStore::new(db)), health: Arc::new(UptimeHealthReporter::new()), is_tls: false, oauth_state, + initial_admin_email: None, }) } @@ -292,7 +361,7 @@ mod tests { #[tokio::test] async fn authorize_redirects_to_provider() { - let state = test_state(true); + let state = test_state(true).await; let server = axum_test::TestServer::new(test_router(state)); let resp = server @@ -309,7 +378,7 @@ mod tests { #[tokio::test] async fn authorize_unknown_provider_returns_404() { - let state = test_state(true); + let state = test_state(true).await; let server = axum_test::TestServer::new(test_router(state)); let resp = server @@ -324,7 +393,7 @@ mod tests { #[tokio::test] async fn authorize_without_oauth_returns_404() { - let state = test_state(false); + let state = test_state(false).await; let server = axum_test::TestServer::new(test_router(state)); let resp = server @@ -339,7 +408,7 @@ mod tests { #[tokio::test] async fn callback_with_invalid_state_returns_400() { - let state = test_state(true); + let state = test_state(true).await; let server = axum_test::TestServer::new(test_router(state)); let resp = server @@ -355,7 +424,7 @@ mod tests { #[tokio::test] async fn callback_with_bad_code_returns_502() { - let state = test_state(true); + let state = test_state(true).await; let oauth = state.oauth_state.as_ref().unwrap(); oauth.insert_pending("test_state_token".into(), 9999); @@ -372,7 +441,7 @@ mod tests { #[tokio::test] async fn callback_full_flow_creates_user_and_returns_token() { - let state = test_state(true); + let state = test_state(true).await; let oauth = state.oauth_state.as_ref().unwrap(); oauth.insert_pending("valid_state".into(), 7777); @@ -410,7 +479,7 @@ mod tests { #[tokio::test] async fn callback_state_is_consumed() { - let state = test_state(true); + let state = test_state(true).await; let oauth = state.oauth_state.as_ref().unwrap(); oauth.insert_pending("one_time".into(), 7777); @@ -433,7 +502,7 @@ mod tests { #[tokio::test] async fn callback_second_login_updates_profile() { - let state = test_state(true); + let state = test_state(true).await; let oauth = state.oauth_state.as_ref().unwrap(); // first login @@ -465,11 +534,16 @@ mod tests { // still only one account let accounts = state.accounts.list_for_user(user.id).await.unwrap(); assert_eq!(accounts.len(), 1); + + // only one active cli key (old one was revoked) + let keys = state.api_keys.list_for_user(user.id).await.unwrap(); + let cli_keys: Vec<_> = keys.iter().filter(|k| k.name == "cli").collect(); + assert_eq!(cli_keys.len(), 1); } #[tokio::test] async fn me_requires_auth() { - let state = test_state(false); + let state = test_state(false).await; let server = axum_test::TestServer::new(test_router(state)); let resp = server.get("/api/me").await; @@ -478,7 +552,7 @@ mod tests { #[tokio::test] async fn me_returns_user_after_oauth_login() { - let state = test_state(true); + let state = test_state(true).await; let oauth = state.oauth_state.as_ref().unwrap(); oauth.insert_pending("login_state".into(), 7777); @@ -505,6 +579,6 @@ mod tests { let user: serde_json::Value = resp.json(); assert_eq!(user["email"], "test@example.com"); assert_eq!(user["name"], "Test User"); - assert_eq!(user["role"], "user"); + assert_eq!(user["role"], "admin"); } } diff --git a/crates/funnel-server/src/api/sessions.rs b/crates/funnel-server/src/api/sessions.rs new file mode 100644 index 0000000..c20dad1 --- /dev/null +++ b/crates/funnel-server/src/api/sessions.rs @@ -0,0 +1,39 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Query, State}; +use serde::Deserialize; + +use crate::app::AppState; +use crate::auth::{Management, Scoped}; +use crate::db::tunnel_sessions::TunnelSession; +use crate::error::AppError; + +#[derive(Deserialize)] +pub struct ListParams { + #[serde(default = "default_limit")] + limit: i64, + #[serde(default)] + all: bool, +} + +const fn default_limit() -> i64 { + 50 +} + +pub async fn list( + State(state): State>, + auth: Scoped, + Query(params): Query, +) -> Result>, AppError> { + let sessions = if params.all && auth.is_admin() { + state.sessions.list_all(params.limit).await? + } else { + state + .sessions + .list_for_user(auth.user_id, params.limit) + .await? + }; + + Ok(Json(sessions)) +} diff --git a/crates/funnel-server/src/api/teams.rs b/crates/funnel-server/src/api/teams.rs new file mode 100644 index 0000000..5a82617 --- /dev/null +++ b/crates/funnel-server/src/api/teams.rs @@ -0,0 +1,188 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, State}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::app::AppState; +use crate::auth::{Management, RequireAdmin, Scoped}; +use crate::db::teams::{Team, TeamMembership, TEAM_ROLE_MEMBER, TEAM_ROLE_OWNER}; +use crate::error::AppError; + +async fn can_manage_team(state: &AppState, team_id: Uuid, auth: &Scoped) -> Result { + if auth.is_admin() { + return Ok(true); + } + let membership = state.teams.find_membership(team_id, auth.user_id).await?; + Ok(membership.map_or(false, |m| m.role == TEAM_ROLE_OWNER)) +} + +pub async fn list( + State(state): State>, + auth: Scoped, +) -> Result>, AppError> { + let teams = if auth.is_admin() { + state.teams.list_all().await? + } else { + state.teams.list_teams_for_user(auth.user_id).await? + }; + Ok(Json(teams)) +} + +#[derive(Deserialize)] +pub struct CreateTeamRequest { + pub name: String, + pub owner_id: Option, +} + +pub async fn create( + State(state): State>, + admin: RequireAdmin, + Json(req): Json, +) -> Result, AppError> { + let team = state.teams.create(&req.name).await?; + let owner_id = req.owner_id.unwrap_or(admin.user_id); + state + .teams + .add_member(team.id, owner_id, TEAM_ROLE_OWNER) + .await?; + Ok(Json(team)) +} + +pub async fn delete( + State(state): State>, + _admin: RequireAdmin, + Path(id): Path, +) -> Result, AppError> { + let deleted = state.teams.delete(id).await?; + if !deleted { + return Err(AppError::NotFound("team not found".into())); + } + Ok(Json(serde_json::json!({ "deleted": true }))) +} + +pub async fn list_members( + State(state): State>, + auth: Scoped, + Path(id): Path, +) -> Result>, AppError> { + state + .teams + .find_by_id(id) + .await? + .ok_or_else(|| AppError::NotFound("team not found".into()))?; + + if !auth.is_admin() { + let is_member = state.teams.is_member(id, auth.user_id).await?; + if !is_member { + return Err(AppError::Forbidden); + } + } + + let members = state.teams.list_members(id).await?; + Ok(Json(members)) +} + +#[derive(Deserialize)] +pub struct AddMemberRequest { + pub user_id: Uuid, +} + +pub async fn add_member( + State(state): State>, + auth: Scoped, + Path(id): Path, + Json(req): Json, +) -> Result, AppError> { + state + .teams + .find_by_id(id) + .await? + .ok_or_else(|| AppError::NotFound("team not found".into()))?; + + if !can_manage_team(&state, id, &auth).await? { + return Err(AppError::Forbidden); + } + + let membership = state + .teams + .add_member(id, req.user_id, TEAM_ROLE_MEMBER) + .await?; + Ok(Json(membership)) +} + +pub async fn remove_member( + State(state): State>, + auth: Scoped, + Path((id, user_id)): Path<(Uuid, Uuid)>, +) -> Result, AppError> { + if !can_manage_team(&state, id, &auth).await? { + return Err(AppError::Forbidden); + } + + // prevent removing the last owner + let membership = state.teams.find_membership(id, user_id).await?; + if let Some(ref m) = membership { + if m.role == TEAM_ROLE_OWNER { + let owner_count = state.teams.count_owners(id).await?; + if owner_count <= 1 { + return Err(AppError::BadRequest( + "cannot remove the last owner of a team".into(), + )); + } + } + } + + let removed = state.teams.remove_member(id, user_id).await?; + if !removed { + return Err(AppError::NotFound("membership not found".into())); + } + Ok(Json(serde_json::json!({ "removed": true }))) +} + +#[derive(Deserialize)] +pub struct SetRoleRequest { + pub role: String, +} + +pub async fn set_member_role( + State(state): State>, + auth: Scoped, + Path((id, user_id)): Path<(Uuid, Uuid)>, + Json(req): Json, +) -> Result, AppError> { + if req.role != TEAM_ROLE_OWNER && req.role != TEAM_ROLE_MEMBER { + return Err(AppError::BadRequest(format!( + "invalid role '{}', must be '{}' or '{}'", + req.role, TEAM_ROLE_OWNER, TEAM_ROLE_MEMBER + ))); + } + + if !can_manage_team(&state, id, &auth).await? { + return Err(AppError::Forbidden); + } + + // prevent demoting the last owner + if req.role == TEAM_ROLE_MEMBER { + let current = state + .teams + .find_membership(id, user_id) + .await? + .ok_or_else(|| AppError::NotFound("membership not found".into()))?; + if current.role == TEAM_ROLE_OWNER { + let owner_count = state.teams.count_owners(id).await?; + if owner_count <= 1 { + return Err(AppError::BadRequest( + "cannot demote the last owner of a team".into(), + )); + } + } + } + + let membership = state + .teams + .update_member_role(id, user_id, &req.role) + .await?; + Ok(Json(membership)) +} diff --git a/crates/funnel-server/src/api/tunnels.rs b/crates/funnel-server/src/api/tunnels.rs index 1cb9bab..dc73220 100644 --- a/crates/funnel-server/src/api/tunnels.rs +++ b/crates/funnel-server/src/api/tunnels.rs @@ -12,14 +12,39 @@ use crate::tunnel::manager::TunnelInfo; pub async fn list( State(state): State>, - _auth: Scoped, + auth: Scoped, ) -> Result>, AppError> { - Ok(Json(state.tunnels.list())) + let all = state.tunnels.list(); + + if auth.is_admin() { + return Ok(Json(all)); + } + + let team_ids = state + .teams + .get_team_ids_for_user(auth.user_id) + .await + .map_err(AppError::Store)?; + + let visible: Vec = all + .into_iter() + .filter(|t| { + t.owner_id == auth.user_id + || t.team_id.is_some_and(|tid| team_ids.contains(&tid)) + }) + .collect(); + + Ok(Json(visible)) +} + +fn can_access(tunnel: &TunnelInfo, user_id: uuid::Uuid, team_ids: &[uuid::Uuid]) -> bool { + tunnel.owner_id == user_id + || tunnel.team_id.is_some_and(|tid| team_ids.contains(&tid)) } pub async fn get_tunnel( State(state): State>, - _auth: Scoped, + auth: Scoped, Path(id): Path, ) -> Result, AppError> { let tunnel_id = TunnelId::new(id.clone())?; @@ -29,25 +54,66 @@ pub async fn get_tunnel( .get(&tunnel_id) .ok_or_else(|| AppError::TunnelNotFound(id))?; - Ok(Json(TunnelInfo { + let info = TunnelInfo { id: tunnel.id().to_string(), uptime_secs: tunnel.connected_at().elapsed().as_secs_f64(), stats: tunnel.stats(), - })) + owner_id: tunnel.owner_id(), + team_id: tunnel.team_id(), + }; + + if !auth.is_admin() { + let team_ids = state + .teams + .get_team_ids_for_user(auth.user_id) + .await + .map_err(AppError::Store)?; + if !can_access(&info, auth.user_id, &team_ids) { + return Err(AppError::Forbidden); + } + } + + Ok(Json(info)) } pub async fn delete( State(state): State>, - _auth: Scoped, + auth: Scoped, Path(id): Path, ) -> Result, AppError> { let tunnel_id = TunnelId::new(id.clone())?; let tunnel = state .tunnels - .remove(&tunnel_id) + .get(&tunnel_id) .ok_or_else(|| AppError::TunnelNotFound(id))?; + if !auth.is_admin() { + let info = TunnelInfo { + id: tunnel.id().to_string(), + uptime_secs: 0.0, + stats: tunnel.stats(), + owner_id: tunnel.owner_id(), + team_id: tunnel.team_id(), + }; + let team_ids = state + .teams + .get_team_ids_for_user(auth.user_id) + .await + .map_err(AppError::Store)?; + if !can_access(&info, auth.user_id, &team_ids) { + return Err(AppError::Forbidden); + } + } + + // drop the Arc from get() before removing + drop(tunnel); + + let tunnel = state + .tunnels + .remove(&tunnel_id) + .ok_or_else(|| AppError::TunnelNotFound(tunnel_id.to_string()))?; + tunnel.close(); Ok(Json(serde_json::json!({ "deleted": true }))) diff --git a/crates/funnel-server/src/api/users.rs b/crates/funnel-server/src/api/users.rs new file mode 100644 index 0000000..b939b65 --- /dev/null +++ b/crates/funnel-server/src/api/users.rs @@ -0,0 +1,103 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, Query, State}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::app::AppState; +use crate::auth::RequireAdmin; +use crate::db::users::{User, ROLE_ADMIN, ROLE_MEMBER}; +use crate::error::AppError; + +#[derive(Deserialize)] +pub struct ListParams { + #[serde(default = "default_limit")] + limit: i64, +} + +const fn default_limit() -> i64 { + 50 +} + +pub async fn list( + State(state): State>, + _admin: RequireAdmin, + Query(params): Query, +) -> Result>, AppError> { + let users = state.users.list_all(params.limit).await?; + Ok(Json(users)) +} + +#[derive(Deserialize)] +pub struct SetRoleRequest { + pub role: String, +} + +pub async fn set_role( + State(state): State>, + _admin: RequireAdmin, + Path(id): Path, + Json(req): Json, +) -> Result, AppError> { + if req.role != ROLE_ADMIN && req.role != ROLE_MEMBER { + return Err(AppError::BadRequest(format!( + "invalid role: {}, must be '{}' or '{}'", + req.role, ROLE_ADMIN, ROLE_MEMBER + ))); + } + + // prevent demotion of the last admin + if req.role == ROLE_MEMBER { + let target = state + .users + .find_by_id(id) + .await? + .ok_or_else(|| AppError::NotFound("user not found".into()))?; + + if target.is_admin() { + let admin_count = state.users.count_admins().await?; + if admin_count <= 1 { + return Err(AppError::BadRequest( + "cannot demote the last admin".into(), + )); + } + } + } + + let user = state.users.update_role(id, &req.role).await?; + Ok(Json(user)) +} + +pub async fn deactivate( + State(state): State>, + _admin: RequireAdmin, + Path(id): Path, +) -> Result, AppError> { + let target = state + .users + .find_by_id(id) + .await? + .ok_or_else(|| AppError::NotFound("user not found".into()))?; + + if target.is_admin() { + let admin_count = state.users.count_admins().await?; + if admin_count <= 1 { + return Err(AppError::BadRequest( + "cannot deactivate the last admin".into(), + )); + } + } + + let user = state.users.deactivate(id).await?; + Ok(Json(user)) +} + +pub async fn reactivate( + State(state): State>, + _admin: RequireAdmin, + Path(id): Path, +) -> Result, AppError> { + let user = state.users.reactivate(id).await?; + Ok(Json(user)) +} diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs index 4fc802b..46e70c0 100644 --- a/crates/funnel-server/src/app.rs +++ b/crates/funnel-server/src/app.rs @@ -11,6 +11,7 @@ use metrics_exporter_prometheus::PrometheusHandle; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; +use funnel_core::protocol::PROTOCOL_VERSION; use funnel_core::tunnel::id::TunnelId; use crate::api; @@ -21,6 +22,7 @@ use crate::store::account_store::AccountStore; use crate::store::api_key_store::ApiKeyStore; use crate::store::health::HealthReporter; use crate::store::session_recorder::SessionRecorder; +use crate::store::team_store::TeamStore; use crate::store::tunnel_registry::TunnelRegistry; use crate::store::user_store::UserStore; @@ -30,9 +32,11 @@ pub struct AppState { pub users: Arc, pub accounts: Arc, pub sessions: Arc, + pub teams: Arc, pub health: Arc, pub is_tls: bool, pub oauth_state: Option>, + pub initial_admin_email: Option, } pub fn build_router(state: Arc, metrics_handle: PrometheusHandle) -> Router { @@ -47,15 +51,49 @@ pub fn build_router(state: Arc, metrics_handle: PrometheusHandle) -> R .route("/keys/{id}", axum::routing::delete(api::keys::revoke)) .route("/me", get(api::me::handler)) .route("/accounts", get(api::accounts::list)) + .route("/sessions", get(api::sessions::list)) + .route( + "/users", + get(api::users::list), + ) + .route("/users/{id}/role", axum::routing::put(api::users::set_role)) + .route( + "/users/{id}/deactivate", + axum::routing::post(api::users::deactivate), + ) + .route( + "/users/{id}/reactivate", + axum::routing::post(api::users::reactivate), + ) + .route( + "/teams", + get(api::teams::list).post(api::teams::create), + ) + .route("/teams/{id}", axum::routing::delete(api::teams::delete)) + .route( + "/teams/{id}/members", + get(api::teams::list_members).post(api::teams::add_member), + ) + .route( + "/teams/{id}/members/{user_id}", + axum::routing::delete(api::teams::remove_member), + ) + .route( + "/teams/{id}/members/{user_id}/role", + axum::routing::put(api::teams::set_member_role), + ) .route("/metrics", get(metrics::handler).with_state(metrics_handle)); let auth_routes = Router::new() .route("/{provider}/authorize", get(api::oauth::authorize)) .route("/{provider}/callback", get(api::oauth::callback)); + let api_prefix = format!("/api/v{PROTOCOL_VERSION}"); + let auth_prefix = format!("/auth/v{PROTOCOL_VERSION}"); + Router::new() - .nest("/api", api_routes) - .nest("/auth", auth_routes) + .nest(&api_prefix, api_routes) + .nest(&auth_prefix, auth_routes) .fallback(proxy::router::handle_tunnel_request) .layer(middleware::from_fn_with_state( Arc::clone(&state), @@ -70,18 +108,19 @@ pub fn build_router(state: Arc, metrics_handle: PrometheusHandle) -> R /// route directly to the tunnel proxy, bypassing api/auth routes async fn tunnel_routing( State(state): State>, - request: Request, + mut request: Request, next: Next, ) -> Response { - let is_tunnel = request + let tunnel_id = request .headers() .get("host") .and_then(|v| v.to_str().ok()) .and_then(proxy::router::extract_subdomain) .and_then(|sub| TunnelId::new(sub).ok()) - .is_some_and(|id| state.tunnels.get(&id).is_some()); + .filter(|id| state.tunnels.get(id).is_some()); - if is_tunnel { + if let Some(id) = tunnel_id { + request.extensions_mut().insert(id); proxy::router::handle_tunnel_request(State(state), request).await } else { next.run(request).await diff --git a/crates/funnel-server/src/auth/mod.rs b/crates/funnel-server/src/auth/mod.rs index 0ddafdc..ab8b1ec 100644 --- a/crates/funnel-server/src/auth/mod.rs +++ b/crates/funnel-server/src/auth/mod.rs @@ -14,12 +14,17 @@ use crate::error::AppError; pub struct AuthUser { pub user_id: Uuid, pub scopes: Vec, + pub role: String, } impl AuthUser { fn has_scope(&self, scope: &str) -> bool { self.scopes.iter().any(|s| s == scope) } + + pub fn is_admin(&self) -> bool { + self.role == "admin" + } } impl FromRequestParts> for AuthUser { @@ -43,6 +48,17 @@ impl FromRequestParts> for AuthUser { .map_err(AppError::Store)? .ok_or(AppError::Unauthorized)?; + let user = state + .users + .find_by_id(api_key.user_id) + .await + .map_err(AppError::Store)? + .ok_or(AppError::Unauthorized)?; + + if !user.is_active() { + return Err(AppError::Unauthorized); + } + let scopes = api_key .scopes .as_array() @@ -56,6 +72,7 @@ impl FromRequestParts> for AuthUser { Ok(Self { user_id: api_key.user_id, scopes, + role: user.role, }) } } @@ -84,9 +101,16 @@ impl Scope for Tunnels { pub struct Scoped { pub user_id: Uuid, + pub role: String, _scope: std::marker::PhantomData, } +impl Scoped { + pub fn is_admin(&self) -> bool { + self.role == "admin" + } +} + impl FromRequestParts> for Scoped { type Rejection = AppError; @@ -102,7 +126,33 @@ impl FromRequestParts> for Scoped { Ok(Self { user_id: auth.user_id, + role: auth.role, _scope: std::marker::PhantomData, }) } } + +/// extractor that requires management scope AND admin role +pub struct RequireAdmin { + #[allow(dead_code)] + pub user_id: Uuid, +} + +impl FromRequestParts> for RequireAdmin { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let auth = AuthUser::from_request_parts(parts, state).await?; + + if !auth.has_scope("management") || !auth.is_admin() { + return Err(AppError::Forbidden); + } + + Ok(Self { + user_id: auth.user_id, + }) + } +} diff --git a/crates/funnel-server/src/db/api_keys.rs b/crates/funnel-server/src/db/api_keys.rs index 40fabb9..8b8be57 100644 --- a/crates/funnel-server/src/db/api_keys.rs +++ b/crates/funnel-server/src/db/api_keys.rs @@ -15,6 +15,7 @@ pub struct ApiKey { pub scopes: serde_json::Value, pub created_at: DateTime, pub revoked_at: Option>, + pub expires_at: Option>, } impl ApiKey { @@ -33,6 +34,7 @@ pub struct ApiKeyView { pub scopes: serde_json::Value, pub created_at: DateTime, pub revoked_at: Option>, + pub expires_at: Option>, } impl From for ApiKeyView { @@ -44,6 +46,7 @@ impl From for ApiKeyView { scopes: key.scopes, created_at: key.created_at, revoked_at: key.revoked_at, + expires_at: key.expires_at, } } } @@ -57,6 +60,7 @@ pub async fn create( user_id: Uuid, name: &str, scopes: &serde_json::Value, + expires_at: Option>, ) -> Result<(String, ApiKey), sqlx::Error> { let plaintext = auth::generate_api_key() .map_err(|e| sqlx::Error::Protocol(format!("failed to generate api key: {e}")))?; @@ -65,8 +69,8 @@ pub async fn create( let key = sqlx::query_as::<_, ApiKey>( r" - INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING * ", ) @@ -75,6 +79,7 @@ pub async fn create( .bind(&hash) .bind(prefix.as_ref()) .bind(scopes) + .bind(expires_at) .fetch_one(pool) .await?; @@ -84,10 +89,12 @@ pub async fn create( pub async fn validate(pool: &PgPool, plaintext: &str) -> Result, sqlx::Error> { let hash = auth::hash_token(plaintext); - sqlx::query_as::<_, ApiKey>("SELECT * FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL") - .bind(&hash) - .fetch_optional(pool) - .await + sqlx::query_as::<_, ApiKey>( + "SELECT * FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())", + ) + .bind(&hash) + .fetch_optional(pool) + .await } pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { @@ -110,3 +117,15 @@ pub async fn revoke(pool: &PgPool, key_id: Uuid, user_id: Uuid) -> Result 0) } + +pub async fn revoke_by_name(pool: &PgPool, user_id: Uuid, name: &str) -> Result { + let result = sqlx::query( + "UPDATE api_keys SET revoked_at = now() WHERE user_id = $1 AND name = $2 AND revoked_at IS NULL", + ) + .bind(user_id) + .bind(name) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} diff --git a/crates/funnel-server/src/db/mod.rs b/crates/funnel-server/src/db/mod.rs index 74cb9a5..3b5a87e 100644 --- a/crates/funnel-server/src/db/mod.rs +++ b/crates/funnel-server/src/db/mod.rs @@ -1,5 +1,5 @@ pub mod accounts; pub mod api_keys; -#[allow(dead_code)] +pub mod teams; pub mod tunnel_sessions; pub mod users; diff --git a/crates/funnel-server/src/db/teams.rs b/crates/funnel-server/src/db/teams.rs new file mode 100644 index 0000000..b04701b --- /dev/null +++ b/crates/funnel-server/src/db/teams.rs @@ -0,0 +1,199 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct Team { + pub id: Uuid, + pub name: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub const TEAM_ROLE_OWNER: &str = "owner"; +pub const TEAM_ROLE_MEMBER: &str = "member"; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct TeamMembership { + pub id: Uuid, + pub team_id: Uuid, + pub user_id: Uuid, + pub role: String, + pub created_at: DateTime, +} + +pub async fn create(pool: &PgPool, name: &str) -> Result { + sqlx::query_as::<_, Team>( + r" + INSERT INTO teams (name) + VALUES ($1) + RETURNING * + ", + ) + .bind(name) + .fetch_one(pool) + .await +} + +pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn find_by_name(pool: &PgPool, name: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Team>("SELECT * FROM teams WHERE name = $1") + .bind(name) + .fetch_optional(pool) + .await +} + +pub async fn list_all(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, Team>("SELECT * FROM teams ORDER BY name") + .fetch_all(pool) + .await +} + +pub async fn delete(pool: &PgPool, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM teams WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn add_member( + pool: &PgPool, + team_id: Uuid, + user_id: Uuid, + role: &str, +) -> Result { + sqlx::query_as::<_, TeamMembership>( + r" + INSERT INTO team_memberships (team_id, user_id, role) + VALUES ($1, $2, $3) + RETURNING * + ", + ) + .bind(team_id) + .bind(user_id) + .bind(role) + .fetch_one(pool) + .await +} + +pub async fn update_member_role( + pool: &PgPool, + team_id: Uuid, + user_id: Uuid, + role: &str, +) -> Result { + sqlx::query_as::<_, TeamMembership>( + r" + UPDATE team_memberships SET role = $3 + WHERE team_id = $1 AND user_id = $2 + RETURNING * + ", + ) + .bind(team_id) + .bind(user_id) + .bind(role) + .fetch_one(pool) + .await +} + +pub async fn find_membership( + pool: &PgPool, + team_id: Uuid, + user_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, TeamMembership>( + "SELECT * FROM team_memberships WHERE team_id = $1 AND user_id = $2", + ) + .bind(team_id) + .bind(user_id) + .fetch_optional(pool) + .await +} + +pub async fn count_owners(pool: &PgPool, team_id: Uuid) -> Result { + let (count,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM team_memberships WHERE team_id = $1 AND role = 'owner'", + ) + .bind(team_id) + .fetch_one(pool) + .await?; + Ok(count) +} + +pub async fn remove_member( + pool: &PgPool, + team_id: Uuid, + user_id: Uuid, +) -> Result { + let result = + sqlx::query("DELETE FROM team_memberships WHERE team_id = $1 AND user_id = $2") + .bind(team_id) + .bind(user_id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn list_members( + pool: &PgPool, + team_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, TeamMembership>( + "SELECT * FROM team_memberships WHERE team_id = $1 ORDER BY created_at", + ) + .bind(team_id) + .fetch_all(pool) + .await +} + +pub async fn list_teams_for_user( + pool: &PgPool, + user_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Team>( + r" + SELECT t.* FROM teams t + INNER JOIN team_memberships tm ON t.id = tm.team_id + WHERE tm.user_id = $1 + ORDER BY t.name + ", + ) + .bind(user_id) + .fetch_all(pool) + .await +} + +pub async fn get_team_ids_for_user( + pool: &PgPool, + user_id: Uuid, +) -> Result, sqlx::Error> { + let rows: Vec<(Uuid,)> = + sqlx::query_as("SELECT team_id FROM team_memberships WHERE user_id = $1") + .bind(user_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(id,)| id).collect()) +} + +pub async fn is_member( + pool: &PgPool, + team_id: Uuid, + user_id: Uuid, +) -> Result { + let row: (bool,) = sqlx::query_as( + "SELECT EXISTS(SELECT 1 FROM team_memberships WHERE team_id = $1 AND user_id = $2)", + ) + .bind(team_id) + .bind(user_id) + .fetch_one(pool) + .await?; + Ok(row.0) +} diff --git a/crates/funnel-server/src/db/tunnel_sessions.rs b/crates/funnel-server/src/db/tunnel_sessions.rs index b444729..4f10c30 100644 --- a/crates/funnel-server/src/db/tunnel_sessions.rs +++ b/crates/funnel-server/src/db/tunnel_sessions.rs @@ -80,6 +80,7 @@ pub async fn list_for_user( .await } +#[allow(dead_code)] pub async fn list_active(pool: &PgPool) -> Result, sqlx::Error> { sqlx::query_as::<_, TunnelSession>( "SELECT * FROM tunnel_sessions WHERE disconnected_at IS NULL ORDER BY connected_at DESC", @@ -88,6 +89,15 @@ pub async fn list_active(pool: &PgPool) -> Result, sqlx::Erro .await } +pub async fn list_all(pool: &PgPool, limit: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, TunnelSession>( + "SELECT * FROM tunnel_sessions ORDER BY connected_at DESC LIMIT $1", + ) + .bind(limit) + .fetch_all(pool) + .await +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/funnel-server/src/db/users.rs b/crates/funnel-server/src/db/users.rs index 4ba1c0a..b6dff64 100644 --- a/crates/funnel-server/src/db/users.rs +++ b/crates/funnel-server/src/db/users.rs @@ -3,6 +3,9 @@ use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; +pub const ROLE_ADMIN: &str = "admin"; +pub const ROLE_MEMBER: &str = "member"; + #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct User { pub id: Uuid, @@ -13,6 +16,17 @@ pub struct User { pub metadata: serde_json::Value, pub created_at: DateTime, pub updated_at: DateTime, + pub deactivated_at: Option>, +} + +impl User { + pub fn is_admin(&self) -> bool { + self.role == ROLE_ADMIN + } + + pub const fn is_active(&self) -> bool { + self.deactivated_at.is_none() + } } pub struct NewUser { @@ -69,3 +83,68 @@ pub async fn update_profile( .fetch_one(pool) .await } + +pub async fn update_role(pool: &PgPool, id: Uuid, role: &str) -> Result { + sqlx::query_as::<_, User>( + r" + UPDATE users SET role = $2, updated_at = now() + WHERE id = $1 + RETURNING * + ", + ) + .bind(id) + .bind(role) + .fetch_one(pool) + .await +} + +pub async fn deactivate(pool: &PgPool, id: Uuid) -> Result { + sqlx::query_as::<_, User>( + r" + UPDATE users SET deactivated_at = now(), updated_at = now() + WHERE id = $1 + RETURNING * + ", + ) + .bind(id) + .fetch_one(pool) + .await +} + +pub async fn reactivate(pool: &PgPool, id: Uuid) -> Result { + sqlx::query_as::<_, User>( + r" + UPDATE users SET deactivated_at = NULL, updated_at = now() + WHERE id = $1 + RETURNING * + ", + ) + .bind(id) + .fetch_one(pool) + .await +} + +pub async fn list_all(pool: &PgPool, limit: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>( + "SELECT * FROM users ORDER BY created_at DESC LIMIT $1", + ) + .bind(limit) + .fetch_all(pool) + .await +} + +pub async fn count(pool: &PgPool) -> Result { + let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + .fetch_one(pool) + .await?; + Ok(row.0) +} + +pub async fn count_admins(pool: &PgPool) -> Result { + let row: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM users WHERE role = 'admin' AND deactivated_at IS NULL", + ) + .fetch_one(pool) + .await?; + Ok(row.0) +} diff --git a/crates/funnel-server/src/error.rs b/crates/funnel-server/src/error.rs index 74195f1..dfae9ed 100644 --- a/crates/funnel-server/src/error.rs +++ b/crates/funnel-server/src/error.rs @@ -31,6 +31,9 @@ pub enum AppError { #[error("forbidden")] Forbidden, + #[error("bad request: {0}")] + BadRequest(String), + #[error("internal error: {0}")] Internal(#[from] anyhow::Error), } @@ -43,7 +46,9 @@ impl IntoResponse for AppError { Self::TunnelNotFound(_) | Self::NotFound(_) => { (StatusCode::NOT_FOUND, self.to_string()) } - Self::InvalidTunnelId(_) => (StatusCode::BAD_REQUEST, self.to_string()), + Self::InvalidTunnelId(_) | Self::BadRequest(_) => { + (StatusCode::BAD_REQUEST, self.to_string()) + } Self::Store(e) => match e { StoreError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), StoreError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()), diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index 2762964..7bf3358 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -29,15 +29,15 @@ use tunnel::manager::TunnelManager; #[command(name = "funnel-server", about = "Funnel tunnel server")] struct Cli { /// Port to listen on - #[arg(short, long, default_value_t = 8080)] + #[arg(short, long, default_value_t = 8080, env = "FUNNEL_PORT")] port: u16, /// Host to bind to - #[arg(long, default_value = "0.0.0.0")] + #[arg(long, default_value = "0.0.0.0", env = "FUNNEL_HOST")] host: String, /// QUIC port for tunnel connections - #[arg(long, default_value_t = 4433)] + #[arg(long, default_value_t = 4433, env = "FUNNEL_QUIC_PORT")] quic_port: u16, /// PostgreSQL connection URL @@ -45,19 +45,19 @@ struct Cli { database_url: Option, /// Maximum database connections - #[arg(long, default_value_t = 10)] + #[arg(long, default_value_t = 10, env = "FUNNEL_DB_MAX_CONNECTIONS")] db_max_connections: u32, /// Enable TLS/HTTPS - #[arg(long)] + #[arg(long, env = "FUNNEL_ENABLE_TLS")] enable_tls: bool, /// TLS port - #[arg(long, default_value_t = 8443)] + #[arg(long, default_value_t = 8443, env = "FUNNEL_TLS_PORT")] tls_port: u16, /// Certificate storage directory - #[arg(long, default_value = "./certs")] + #[arg(long, default_value = "./certs", env = "FUNNEL_CERT_DIR")] cert_dir: String, /// Let's Encrypt email for ACME registration @@ -69,13 +69,21 @@ struct Cli { dns_providers_config: Option, /// Use Let's Encrypt staging environment - #[arg(long)] + #[arg(long, env = "FUNNEL_ACME_STAGING")] acme_staging: bool, /// Create a seed API key at startup and print it to stdout - #[arg(long)] + #[arg(long, env = "FUNNEL_SEED_API_KEY")] seed_api_key: bool, + /// Path to turso/libsql database file (used when no PostgreSQL URL is set) + #[arg(long, default_value = "./funnel.db", env = "FUNNEL_TURSO_DB_PATH")] + turso_db_path: String, + + /// Email of the initial admin user (auto promotes on first login) + #[arg(long, env = "FUNNEL_INITIAL_ADMIN_EMAIL")] + initial_admin_email: Option, + /// GitHub OAuth client ID #[arg(long, env = "GITHUB_CLIENT_ID")] github_client_id: Option, @@ -162,9 +170,23 @@ async fn main() -> anyhow::Result<()> { sqlx::migrate!("../../migrations").run(&pool).await?; tracing::info!("database migrations applied"); + + let (db_version,): (i32,) = + sqlx::query_as("SELECT version FROM schema_version ORDER BY applied_at DESC LIMIT 1") + .fetch_one(&pool) + .await?; + + let expected = funnel_core::protocol::PROTOCOL_VERSION as i32; + if db_version != expected { + anyhow::bail!( + "schema version mismatch: database is v{db_version} but server expects v{expected}" + ); + } + + tracing::info!(schema_version = db_version, "schema version verified"); Some(pool) } else { - tracing::info!("no database configured, running in memory only mode"); + tracing::info!(path = %cli.turso_db_path, "no database url configured, using turso"); None }; @@ -236,52 +258,69 @@ fn build_oauth_state(cli: &Cli) -> anyhow::Result>> { Ok(Some(Arc::new(OAuthState::new(providers, base_url)))) } -fn build_state( +async fn build_state( pool: Option, + turso_db_path: &str, is_tls: bool, oauth_state: Option>, -) -> Arc { + initial_admin_email: Option, +) -> anyhow::Result> { let tunnels = Arc::new(TunnelManager::new()); if let Some(pool) = pool { - Arc::new(app::AppState { + Ok(Arc::new(app::AppState { tunnels, api_keys: Arc::new(store::pg::api_key_store::PgApiKeyStore::new(pool.clone())), users: Arc::new(store::pg::user_store::PgUserStore::new(pool.clone())), accounts: Arc::new(store::pg::account_store::PgAccountStore::new(pool.clone())), - sessions: Arc::new(store::pg::session_recorder::PgSessionRecorder::new(pool)), + sessions: Arc::new(store::pg::session_recorder::PgSessionRecorder::new( + pool.clone(), + )), + teams: Arc::new(store::pg::team_store::PgTeamStore::new(pool)), health: Arc::new(UptimeHealthReporter::new()), is_tls, oauth_state, - }) + initial_admin_email, + })) } else { - Arc::new(app::AppState { + let db = store::turso::open(turso_db_path) + .await + .map_err(|e| anyhow::anyhow!("failed to open turso database: {e}"))?; + + tracing::info!("turso database ready"); + + Ok(Arc::new(app::AppState { tunnels, - api_keys: Arc::new(store::memory::api_key_store::InMemoryApiKeyStore::new()), - users: Arc::new(store::memory::user_store::InMemoryUserStore::new()), - accounts: Arc::new(store::memory::account_store::InMemoryAccountStore::new()), - sessions: Arc::new(store::memory::session_recorder::InMemorySessionRecorder::new()), + api_keys: Arc::new(store::turso::api_key_store::TursoApiKeyStore::new( + Arc::clone(&db), + )), + users: Arc::new(store::turso::user_store::TursoUserStore::new( + Arc::clone(&db), + )), + accounts: Arc::new(store::turso::account_store::TursoAccountStore::new( + Arc::clone(&db), + )), + sessions: Arc::new(store::turso::session_recorder::TursoSessionRecorder::new( + Arc::clone(&db), + )), + teams: Arc::new(store::turso::team_store::TursoTeamStore::new(db)), health: Arc::new(UptimeHealthReporter::new()), is_tls, oauth_state, - }) + initial_admin_email, + })) } } async fn run_plain(cli: Cli, pool: Option) -> anyhow::Result<()> { let metrics_handle = metrics::setup()?; let oauth_state = build_oauth_state(&cli)?; - let state = build_state(pool, false, oauth_state); + let initial_admin_email = cli.initial_admin_email.clone(); + let state = build_state(pool, &cli.turso_db_path, false, oauth_state, initial_admin_email).await?; let router = app::build_router(Arc::clone(&state), metrics_handle); if cli.seed_api_key { - let scopes = db::api_keys::default_scopes(); - let (plaintext, _) = state - .api_keys - .create(uuid::Uuid::nil(), "seed", &scopes) - .await - .map_err(|e| anyhow::anyhow!("failed to create seed api key: {e}"))?; - println!("{plaintext}"); + create_seed_key(&state).await?; } let quic_handle = quic::config::spawn_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; @@ -304,6 +343,53 @@ async fn run_plain(cli: Cli, pool: Option) -> anyhow::Result<()> { Ok(()) } +async fn create_seed_key(state: &Arc) -> anyhow::Result<()> { + use crate::db::users::NewUser; + + let seed_email = "system@funnel.local"; + + // find or create the seed user + let user = match state.users.find_by_email(seed_email).await { + Ok(Some(u)) => u, + Ok(None) => { + let u = state + .users + .create(NewUser { + email: seed_email.to_string(), + name: Some("System".to_string()), + avatar_url: None, + }) + .await + .map_err(|e| anyhow::anyhow!("failed to create seed user: {e}"))?; + + // promote to admin + state + .users + .update_role(u.id, "admin") + .await + .map_err(|e| anyhow::anyhow!("failed to promote seed user: {e}"))? + } + Err(e) => anyhow::bail!("failed to look up seed user: {e}"), + }; + + let scopes = db::api_keys::default_scopes(); + match state + .api_keys + .create(user.id, "seed", &scopes, None) + .await + { + Ok((plaintext, _)) => { + println!("{plaintext}"); + } + Err(store::StoreError::Conflict(_)) => { + tracing::info!("seed key already exists, skipping"); + } + Err(e) => anyhow::bail!("failed to create seed api key: {e}"), + } + + Ok(()) +} + async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<()> { let email = cli .letsencrypt_email @@ -324,9 +410,14 @@ async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<() let metrics_handle = metrics::setup()?; let oauth_state = build_oauth_state(&cli)?; - let state = build_state(pool, true, oauth_state); + let initial_admin_email = cli.initial_admin_email.clone(); + let state = build_state(pool, &cli.turso_db_path, true, oauth_state, initial_admin_email).await?; let router = app::build_router(Arc::clone(&state), metrics_handle); + if cli.seed_api_key { + create_seed_key(&state).await?; + } + let quic_handle = quic::config::spawn_listener(&cli.host, cli.quic_port, Arc::clone(&state))?; let tls_addr: SocketAddr = format!("{}:{}", cli.host, cli.tls_port).parse()?; diff --git a/crates/funnel-server/src/proxy/router.rs b/crates/funnel-server/src/proxy/router.rs index e65d086..3121e03 100644 --- a/crates/funnel-server/src/proxy/router.rs +++ b/crates/funnel-server/src/proxy/router.rs @@ -3,6 +3,8 @@ use std::sync::Arc; use axum::body::Body; use axum::extract::State; use axum::http::{Request, Response, StatusCode}; +use hyper_util::rt::TokioIo; +use tokio::io; use tokio_util::io::ReaderStream; use funnel_core::protocol::request::{self as proto, RequestMeta, ResponseMeta}; @@ -10,26 +12,39 @@ use funnel_core::tunnel::id::TunnelId; use super::headers::prepare_forwarding_headers; use crate::app::AppState; -use crate::tunnel::connection::{CountedRecvStream, SendError}; +use crate::tunnel::connection::{ActiveTunnel, CountedRecvStream, SendError}; + +fn is_upgrade_request(req: &Request) -> bool { + req.headers() + .get("connection") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("upgrade")) +} /// axum fallback handler that routes requests based on subdomain. /// requests to `{tunnel_id}.{base_domain}` are forwarded through the matching tunnel. pub async fn handle_tunnel_request( State(state): State>, - request: Request, + mut request: Request, ) -> Response { let host = request .headers() .get("host") .and_then(|v| v.to_str().ok()) - .unwrap_or(""); + .unwrap_or("") + .to_string(); - let subdomain = match extract_subdomain(host) { - Some(s) => s.to_string(), - None => return not_found("tunnel not found"), - }; + // use pre-resolved tunnel id from middleware when available + let tunnel_id = request + .extensions() + .get::() + .cloned() + .or_else(|| { + extract_subdomain(&host) + .and_then(|s| TunnelId::new(s).ok()) + }); - let Ok(tunnel_id) = TunnelId::new(&subdomain) else { + let Some(tunnel_id) = tunnel_id else { return not_found("tunnel not found"); }; @@ -37,6 +52,10 @@ pub async fn handle_tunnel_request( return not_found("tunnel not found"); }; + if is_upgrade_request(&request) { + return handle_upgrade(&mut request, &host, &state, &tunnel).await; + } + let method = request.method().to_string(); let path = request.uri().to_string(); @@ -46,12 +65,13 @@ pub async fn handle_tunnel_request( .get::>() .map_or(fallback_addr, |ci| ci.0); - let headers = prepare_forwarding_headers(request.headers(), host, remote_addr, state.is_tls); + let headers = prepare_forwarding_headers(request.headers(), &host, remote_addr, state.is_tls); let meta = RequestMeta { method, path, headers, + upgrade: false, }; let body = request.into_body(); @@ -70,6 +90,101 @@ pub async fn handle_tunnel_request( } } +async fn handle_upgrade( + request: &mut Request, + host: &str, + state: &AppState, + tunnel: &ActiveTunnel, +) -> Response { + let method = request.method().to_string(); + let path = request.uri().to_string(); + + let fallback_addr = std::net::SocketAddr::from(([0, 0, 0, 0], 0)); + let remote_addr = request + .extensions() + .get::>() + .map_or(fallback_addr, |ci| ci.0); + + let headers = prepare_forwarding_headers(request.headers(), host, remote_addr, state.is_tls); + + let meta = RequestMeta { + method, + path, + headers, + upgrade: true, + }; + + // capture the upgrade future before we consume anything + let on_upgrade = hyper::upgrade::on(request); + + let (resp_meta, quic_send, quic_recv) = match tunnel.send_upgrade_request(meta).await { + Ok(v) => v, + Err(SendError::Timeout) => { + return error_response(StatusCode::GATEWAY_TIMEOUT, "request timed out"); + } + Err(e) => { + tracing::debug!(error = %e, "upgrade tunnel request failed"); + return error_response(StatusCode::BAD_GATEWAY, "tunnel error"); + } + }; + + let status = resp_meta + .http_status() + .unwrap_or(StatusCode::BAD_GATEWAY); + + if status != StatusCode::SWITCHING_PROTOCOLS { + // backend rejected the upgrade, return the response as is + let mut builder = Response::builder().status(status); + if let Some(h) = builder.headers_mut() { + *h = proto::to_header_map(&resp_meta.headers); + } + return builder + .body(Body::empty()) + .unwrap_or_else(|_| error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")); + } + + // build the 101 response for the browser + let mut builder = Response::builder().status(StatusCode::SWITCHING_PROTOCOLS); + if let Some(h) = builder.headers_mut() { + *h = proto::to_header_map(&resp_meta.headers); + } + + // spawn a task to pipe bytes between the browser upgrade and the quic streams + tokio::spawn(async move { + let upgraded = match on_upgrade.await { + Ok(u) => u, + Err(e) => { + tracing::debug!(error = %e, "upgrade handshake failed"); + return; + } + }; + + let upgraded_io = TokioIo::new(upgraded); + let (mut browser_read, mut browser_write) = io::split(upgraded_io); + let (mut quic_send, mut quic_recv) = (quic_send, quic_recv); + + let client_to_quic = io::copy(&mut browser_read, &mut quic_send); + let quic_to_client = io::copy(&mut quic_recv, &mut browser_write); + + tokio::select! { + r = client_to_quic => { + if let Err(e) = r { + tracing::debug!(error = %e, "browser to quic copy ended"); + } + } + r = quic_to_client => { + if let Err(e) = r { + tracing::debug!(error = %e, "quic to browser copy ended"); + } + } + } + }); + + builder + .body(Body::empty()) + .unwrap_or_else(|_| error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal error")) +} + pub fn extract_subdomain(host: &str) -> Option<&str> { let host = host.split(':').next().unwrap_or(host); @@ -159,4 +274,24 @@ mod tests { fn extract_subdomain_deep_nesting() { assert_eq!(extract_subdomain("a.b.example.com"), Some("a")); } + + type TestResult = Result<(), Box>; + + #[test] + fn detects_upgrade_request() -> TestResult { + let req = Request::builder() + .header("connection", "Upgrade") + .header("upgrade", "websocket") + .body(Body::empty())?; + assert!(is_upgrade_request(&req)); + Ok(()) + } + + #[test] + fn normal_request_is_not_upgrade() -> TestResult { + let req = Request::builder() + .body(Body::empty())?; + assert!(!is_upgrade_request(&req)); + Ok(()) + } } diff --git a/crates/funnel-server/src/quic/handler.rs b/crates/funnel-server/src/quic/handler.rs index 8d248d9..6f4052c 100644 --- a/crates/funnel-server/src/quic/handler.rs +++ b/crates/funnel-server/src/quic/handler.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use crate::app::AppState; use crate::tunnel::connection::ActiveTunnel; +use funnel_core::protocol::PROTOCOL_VERSION; use funnel_core::protocol::frame; use funnel_core::protocol::handshake::{Handshake, HandshakeResponse}; @@ -32,6 +33,26 @@ pub async fn handle_connection( let handshake: Handshake = frame::read_meta(&mut recv).await?; let tunnel_id = handshake.tunnel_id; + match handshake.version { + Some(v) if v != PROTOCOL_VERSION => { + let resp = HandshakeResponse::rejected(format!( + "incompatible client version: got v{v}, server requires v{PROTOCOL_VERSION}" + )); + let _ = frame::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Auth(format!( + "incompatible client version: v{v}" + ))); + } + None => { + let resp = HandshakeResponse::rejected("client too old, version field required"); + let _ = frame::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Auth( + "client did not send version".into(), + )); + } + _ => {} + } + // validate auth token let token = handshake .token @@ -53,7 +74,58 @@ pub async fn handle_connection( let user_id = api_key.user_id; - let tunnel = Arc::new(ActiveTunnel::new(tunnel_id.clone(), conn.clone())); + // check user is active + let user = state + .users + .find_by_id(user_id) + .await + .map_err(|e| ConnectionError::Auth(format!("user lookup failed: {e}")))? + .ok_or_else(|| ConnectionError::Auth("user not found".into()))?; + + if !user.is_active() { + let resp = HandshakeResponse::rejected("user account is deactivated"); + let _ = frame::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Auth("deactivated user".into())); + } + + // resolve team if specified + let team_id = if let Some(ref team_name) = handshake.team { + let team = state + .teams + .find_by_name(team_name) + .await + .map_err(|e| ConnectionError::Auth(format!("team lookup failed: {e}")))? + .ok_or_else(|| { + ConnectionError::Auth(format!("team not found: {team_name}")) + })?; + + let is_member = state + .teams + .is_member(team.id, user_id) + .await + .map_err(|e| ConnectionError::Auth(format!("membership check failed: {e}")))?; + + if !is_member { + let resp = HandshakeResponse::rejected(format!( + "not a member of team: {team_name}" + )); + let _ = frame::write_meta(&mut send, &resp).await; + return Err(ConnectionError::Auth(format!( + "not a member of team: {team_name}" + ))); + } + + Some(team.id) + } else { + None + }; + + let tunnel = Arc::new(ActiveTunnel::new( + tunnel_id.clone(), + conn.clone(), + user_id, + team_id, + )); if state .tunnels @@ -67,7 +139,12 @@ pub async fn handle_connection( frame::write_meta(&mut send, &HandshakeResponse::ok()).await?; - tracing::info!(tunnel_id = %tunnel_id, user_id = %user_id, "tunnel connected via quic"); + tracing::info!( + tunnel_id = %tunnel_id, + user_id = %user_id, + team_id = ?team_id, + "tunnel connected via quic" + ); metrics::gauge!("funnel_tunnels_active").increment(1.0); metrics::counter!("funnel_tunnels_total").increment(1); diff --git a/crates/funnel-server/src/store/api_key_store.rs b/crates/funnel-server/src/store/api_key_store.rs index 9054c8c..c3b8eba 100644 --- a/crates/funnel-server/src/store/api_key_store.rs +++ b/crates/funnel-server/src/store/api_key_store.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use uuid::Uuid; use super::{BoxFuture, StoreError}; @@ -9,8 +10,10 @@ pub trait ApiKeyStore: Send + Sync { user_id: Uuid, name: &str, scopes: &serde_json::Value, + expires_at: Option>, ) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>>; fn validate(&self, plaintext: &str) -> BoxFuture<'_, Result, StoreError>>; fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>>; fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result>; + fn revoke_by_name(&self, user_id: Uuid, name: &str) -> BoxFuture<'_, Result>; } diff --git a/crates/funnel-server/src/store/memory/account_store.rs b/crates/funnel-server/src/store/memory/account_store.rs deleted file mode 100644 index ba817ea..0000000 --- a/crates/funnel-server/src/store/memory/account_store.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::sync::{PoisonError, RwLock}; - -use chrono::Utc; -use uuid::Uuid; - -use crate::db::accounts::{Account, NewAccount}; -use crate::store::account_store::AccountStore; -use crate::store::{BoxFuture, StoreError}; - -pub struct InMemoryAccountStore { - accounts: RwLock>, -} - -impl InMemoryAccountStore { - pub const fn new() -> Self { - Self { - accounts: RwLock::new(Vec::new()), - } - } -} - -impl AccountStore for InMemoryAccountStore { - fn find_by_provider( - &self, - provider: &str, - provider_account_id: &str, - ) -> BoxFuture<'_, Result, StoreError>> { - let provider = provider.to_string(); - let provider_account_id = provider_account_id.to_string(); - Box::pin(async move { - let accounts = self.accounts.read().unwrap_or_else(PoisonError::into_inner); - Ok(accounts - .iter() - .find(|a| a.provider == provider && a.provider_account_id == provider_account_id) - .cloned()) - }) - } - - fn create(&self, new_account: NewAccount) -> BoxFuture<'_, Result> { - Box::pin(async move { - let now = Utc::now(); - let account = Account { - id: Uuid::now_v7(), - user_id: new_account.user_id, - provider: new_account.provider, - provider_account_id: new_account.provider_account_id, - metadata: new_account.metadata, - created_at: now, - updated_at: now, - }; - { - let mut accounts = self - .accounts - .write() - .unwrap_or_else(PoisonError::into_inner); - - if accounts.iter().any(|a| { - a.provider == account.provider - && a.provider_account_id == account.provider_account_id - }) { - return Err(StoreError::Conflict( - "account already exists for this provider".into(), - )); - } - - accounts.push(account.clone()); - } - Ok(account) - }) - } - - fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { - Box::pin(async move { - let accounts = self.accounts.read().unwrap_or_else(PoisonError::into_inner); - Ok(accounts - .iter() - .filter(|a| a.user_id == user_id) - .cloned() - .collect()) - }) - } -} diff --git a/crates/funnel-server/src/store/memory/api_key_store.rs b/crates/funnel-server/src/store/memory/api_key_store.rs deleted file mode 100644 index ba8b35a..0000000 --- a/crates/funnel-server/src/store/memory/api_key_store.rs +++ /dev/null @@ -1,221 +0,0 @@ -use std::sync::{PoisonError, RwLock}; - -use chrono::Utc; -use uuid::Uuid; - -use crate::db::api_keys::{ApiKey, ApiKeyView}; -use crate::store::api_key_store::ApiKeyStore; -use crate::store::{BoxFuture, StoreError}; -use funnel_core::auth::token as auth; - -pub struct InMemoryApiKeyStore { - keys: RwLock>, -} - -impl InMemoryApiKeyStore { - pub const fn new() -> Self { - Self { - keys: RwLock::new(Vec::new()), - } - } -} - -impl ApiKeyStore for InMemoryApiKeyStore { - fn create( - &self, - user_id: Uuid, - name: &str, - scopes: &serde_json::Value, - ) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>> { - let name = name.to_string(); - let scopes = scopes.clone(); - Box::pin(async move { - let plaintext = auth::generate_api_key() - .map_err(|e| StoreError::Other(format!("failed to generate api key: {e}")))?; - let hash = auth::hash_token(&plaintext); - let prefix = auth::ApiKeyPrefix::from_key(&plaintext); - - let key = ApiKey { - id: Uuid::now_v7(), - user_id, - name: name.clone(), - key_hash: hash, - key_prefix: prefix.as_ref().to_string(), - scopes, - created_at: Utc::now(), - revoked_at: None, - }; - - let view = { - let mut keys = self.keys.write().unwrap_or_else(PoisonError::into_inner); - - if keys - .iter() - .any(|k| k.user_id == user_id && k.name == name && k.revoked_at.is_none()) - { - return Err(StoreError::Conflict(format!( - "api key name already exists: {name}" - ))); - } - - let view = ApiKeyView::from(key.clone()); - keys.push(key); - view - }; - - Ok((plaintext, view)) - }) - } - - fn validate(&self, plaintext: &str) -> BoxFuture<'_, Result, StoreError>> { - let hash = auth::hash_token(plaintext); - Box::pin(async move { - let keys = self.keys.read().unwrap_or_else(PoisonError::into_inner); - Ok(keys - .iter() - .find(|k| k.key_hash == hash && k.revoked_at.is_none()) - .cloned()) - }) - } - - fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { - Box::pin(async move { - let keys = self.keys.read().unwrap_or_else(PoisonError::into_inner); - Ok(keys - .iter() - .filter(|k| k.user_id == user_id && k.revoked_at.is_none()) - .cloned() - .map(Into::into) - .collect()) - }) - } - - fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { - Box::pin(async move { - let mut keys = self.keys.write().unwrap_or_else(PoisonError::into_inner); - if let Some(key) = keys - .iter_mut() - .find(|k| k.id == key_id && k.user_id == user_id && k.revoked_at.is_none()) - { - key.revoked_at = Some(Utc::now()); - Ok(true) - } else { - Ok(false) - } - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::db::api_keys::default_scopes; - - fn user_id() -> Uuid { - Uuid::now_v7() - } - - #[tokio::test] - async fn create_and_validate() { - let store = InMemoryApiKeyStore::new(); - let uid = user_id(); - - let (plaintext, view) = store - .create(uid, "test-key", &default_scopes()) - .await - .unwrap(); - assert_eq!(view.name, "test-key"); - - let found = store.validate(&plaintext).await.unwrap(); - assert!(found.is_some()); - assert_eq!(found.unwrap().id, view.id); - } - - #[tokio::test] - async fn validate_wrong_key_returns_none() { - let store = InMemoryApiKeyStore::new(); - let found = store.validate("fnl_boguskey1234567890").await.unwrap(); - assert!(found.is_none()); - } - - #[tokio::test] - async fn duplicate_name_returns_conflict() { - let store = InMemoryApiKeyStore::new(); - let uid = user_id(); - - store.create(uid, "dup", &default_scopes()).await.unwrap(); - let result = store.create(uid, "dup", &default_scopes()).await; - assert!(matches!(result, Err(StoreError::Conflict(_)))); - } - - #[tokio::test] - async fn list_for_user_only_returns_matching() { - let store = InMemoryApiKeyStore::new(); - let uid1 = user_id(); - let uid2 = user_id(); - - store.create(uid1, "a", &default_scopes()).await.unwrap(); - store.create(uid1, "b", &default_scopes()).await.unwrap(); - store.create(uid2, "c", &default_scopes()).await.unwrap(); - - let list = store.list_for_user(uid1).await.unwrap(); - assert_eq!(list.len(), 2); - assert!(list.iter().all(|k| k.name == "a" || k.name == "b")); - } - - #[tokio::test] - async fn revoke_makes_key_invalid() { - let store = InMemoryApiKeyStore::new(); - let uid = user_id(); - - let (plaintext, view) = store - .create(uid, "to-revoke", &default_scopes()) - .await - .unwrap(); - let revoked = store.revoke(view.id, uid).await.unwrap(); - assert!(revoked); - - let found = store.validate(&plaintext).await.unwrap(); - assert!(found.is_none()); - - let list = store.list_for_user(uid).await.unwrap(); - assert!(list.is_empty()); - } - - #[tokio::test] - async fn revoke_wrong_user_returns_false() { - let store = InMemoryApiKeyStore::new(); - let uid = user_id(); - let other = user_id(); - - let (_plaintext, view) = store.create(uid, "key", &default_scopes()).await.unwrap(); - let revoked = store.revoke(view.id, other).await.unwrap(); - assert!(!revoked); - } - - #[tokio::test] - async fn can_reuse_name_after_revoke() { - let store = InMemoryApiKeyStore::new(); - let uid = user_id(); - - let (_plaintext, view) = store.create(uid, "reuse", &default_scopes()).await.unwrap(); - store.revoke(view.id, uid).await.unwrap(); - - let result = store.create(uid, "reuse", &default_scopes()).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn scopes_are_preserved() { - let store = InMemoryApiKeyStore::new(); - let uid = user_id(); - let scopes = serde_json::json!(["tunnels"]); - - let (plaintext, view) = store.create(uid, "tunnel-only", &scopes).await.unwrap(); - assert_eq!(view.scopes, scopes); - - let key = store.validate(&plaintext).await.unwrap().unwrap(); - assert!(key.has_scope("tunnels")); - assert!(!key.has_scope("management")); - } -} diff --git a/crates/funnel-server/src/store/memory/mod.rs b/crates/funnel-server/src/store/memory/mod.rs deleted file mode 100644 index a556f32..0000000 --- a/crates/funnel-server/src/store/memory/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod account_store; -pub mod api_key_store; -pub mod session_recorder; -pub mod user_store; diff --git a/crates/funnel-server/src/store/memory/session_recorder.rs b/crates/funnel-server/src/store/memory/session_recorder.rs deleted file mode 100644 index 8eaa8c5..0000000 --- a/crates/funnel-server/src/store/memory/session_recorder.rs +++ /dev/null @@ -1,221 +0,0 @@ -use std::sync::{PoisonError, RwLock}; - -use chrono::Utc; -use ipnetwork::IpNetwork; -use uuid::Uuid; - -use crate::db::tunnel_sessions::TunnelSession; -use crate::store::session_recorder::SessionRecorder; -use crate::store::{BoxFuture, StoreError}; - -pub struct InMemorySessionRecorder { - sessions: RwLock>, -} - -impl InMemorySessionRecorder { - pub const fn new() -> Self { - Self { - sessions: RwLock::new(Vec::new()), - } - } -} - -impl SessionRecorder for InMemorySessionRecorder { - fn record_connect( - &self, - user_id: Uuid, - tunnel_id: &str, - client_ip: Option, - ) -> BoxFuture<'_, Result> { - let tunnel_id = tunnel_id.to_string(); - Box::pin(async move { - let session = TunnelSession { - id: Uuid::now_v7(), - user_id, - tunnel_id, - client_ip, - connected_at: Utc::now(), - disconnected_at: None, - bytes_in: 0, - bytes_out: 0, - requests: 0, - }; - { - let mut sessions = self - .sessions - .write() - .unwrap_or_else(PoisonError::into_inner); - sessions.push(session.clone()); - } - Ok(session) - }) - } - - fn record_disconnect( - &self, - session_id: Uuid, - bytes_in: i64, - bytes_out: i64, - requests: i64, - ) -> BoxFuture<'_, Result> { - Box::pin(async move { - let mut sessions = self - .sessions - .write() - .unwrap_or_else(PoisonError::into_inner); - if let Some(session) = sessions - .iter_mut() - .find(|s| s.id == session_id && s.disconnected_at.is_none()) - { - session.disconnected_at = Some(Utc::now()); - session.bytes_in = bytes_in; - session.bytes_out = bytes_out; - session.requests = requests; - Ok(true) - } else { - Ok(false) - } - }) - } - - fn list_active(&self) -> BoxFuture<'_, Result, StoreError>> { - Box::pin(async move { - let sessions = self.sessions.read().unwrap_or_else(PoisonError::into_inner); - Ok(sessions - .iter() - .filter(|s| s.disconnected_at.is_none()) - .cloned() - .collect()) - }) - } - - fn list_for_user( - &self, - user_id: Uuid, - limit: i64, - ) -> BoxFuture<'_, Result, StoreError>> { - Box::pin(async move { - let sessions = self.sessions.read().unwrap_or_else(PoisonError::into_inner); - let limit = usize::try_from(limit).unwrap_or(usize::MAX); - Ok(sessions - .iter() - .filter(|s| s.user_id == user_id) - .rev() - .take(limit) - .cloned() - .collect()) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn user_id() -> Uuid { - Uuid::now_v7() - } - - #[tokio::test] - async fn record_connect_and_list_active() { - let store = InMemorySessionRecorder::new(); - let uid = user_id(); - - let session = store.record_connect(uid, "tunnel-1", None).await.unwrap(); - assert_eq!(session.tunnel_id, "tunnel-1"); - assert_eq!(session.user_id, uid); - assert!(session.disconnected_at.is_none()); - - let active = store.list_active().await.unwrap(); - assert_eq!(active.len(), 1); - assert_eq!(active[0].id, session.id); - } - - #[tokio::test] - async fn record_disconnect_removes_from_active() { - let store = InMemorySessionRecorder::new(); - let uid = user_id(); - - let session = store.record_connect(uid, "tunnel-2", None).await.unwrap(); - let disconnected = store - .record_disconnect(session.id, 100, 200, 5) - .await - .unwrap(); - assert!(disconnected); - - let active = store.list_active().await.unwrap(); - assert!(active.is_empty()); - } - - #[tokio::test] - async fn record_disconnect_updates_stats() { - let store = InMemorySessionRecorder::new(); - let uid = user_id(); - - let session = store.record_connect(uid, "tunnel-3", None).await.unwrap(); - store - .record_disconnect(session.id, 1024, 2048, 10) - .await - .unwrap(); - - let all = store.list_for_user(uid, 100).await.unwrap(); - assert_eq!(all.len(), 1); - assert_eq!(all[0].bytes_in, 1024); - assert_eq!(all[0].bytes_out, 2048); - assert_eq!(all[0].requests, 10); - assert!(all[0].disconnected_at.is_some()); - } - - #[tokio::test] - async fn disconnect_nonexistent_returns_false() { - let store = InMemorySessionRecorder::new(); - let result = store - .record_disconnect(Uuid::now_v7(), 0, 0, 0) - .await - .unwrap(); - assert!(!result); - } - - #[tokio::test] - async fn disconnect_already_disconnected_returns_false() { - let store = InMemorySessionRecorder::new(); - let uid = user_id(); - - let session = store.record_connect(uid, "tunnel-4", None).await.unwrap(); - store.record_disconnect(session.id, 0, 0, 0).await.unwrap(); - - let again = store.record_disconnect(session.id, 0, 0, 0).await.unwrap(); - assert!(!again); - } - - #[tokio::test] - async fn list_for_user_respects_limit() { - let store = InMemorySessionRecorder::new(); - let uid = user_id(); - - for i in 0..5 { - store - .record_connect(uid, &format!("tunnel-{i}"), None) - .await - .unwrap(); - } - - let limited = store.list_for_user(uid, 3).await.unwrap(); - assert_eq!(limited.len(), 3); - } - - #[tokio::test] - async fn list_for_user_filters_by_user() { - let store = InMemorySessionRecorder::new(); - let uid1 = user_id(); - let uid2 = user_id(); - - store.record_connect(uid1, "a", None).await.unwrap(); - store.record_connect(uid2, "b", None).await.unwrap(); - store.record_connect(uid1, "c", None).await.unwrap(); - - let list = store.list_for_user(uid1, 100).await.unwrap(); - assert_eq!(list.len(), 2); - assert!(list.iter().all(|s| s.user_id == uid1)); - } -} diff --git a/crates/funnel-server/src/store/memory/user_store.rs b/crates/funnel-server/src/store/memory/user_store.rs deleted file mode 100644 index c2da335..0000000 --- a/crates/funnel-server/src/store/memory/user_store.rs +++ /dev/null @@ -1,143 +0,0 @@ -use std::sync::{PoisonError, RwLock}; - -use chrono::Utc; -use uuid::Uuid; - -use crate::db::users::{NewUser, User}; -use crate::store::user_store::UserStore; -use crate::store::{BoxFuture, StoreError}; - -pub struct InMemoryUserStore { - users: RwLock>, -} - -impl InMemoryUserStore { - pub const fn new() -> Self { - Self { - users: RwLock::new(Vec::new()), - } - } -} - -impl UserStore for InMemoryUserStore { - fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>> { - Box::pin(async move { - let users = self.users.read().unwrap_or_else(PoisonError::into_inner); - Ok(users.iter().find(|u| u.id == id).cloned()) - }) - } - - fn find_by_email(&self, email: &str) -> BoxFuture<'_, Result, StoreError>> { - let email = email.to_string(); - Box::pin(async move { - let users = self.users.read().unwrap_or_else(PoisonError::into_inner); - Ok(users.iter().find(|u| u.email == email).cloned()) - }) - } - - fn create(&self, new_user: NewUser) -> BoxFuture<'_, Result> { - Box::pin(async move { - let now = Utc::now(); - let user = User { - id: Uuid::now_v7(), - email: new_user.email, - name: new_user.name, - avatar_url: new_user.avatar_url, - role: "user".into(), - metadata: serde_json::json!({}), - created_at: now, - updated_at: now, - }; - { - let mut users = self.users.write().unwrap_or_else(PoisonError::into_inner); - users.push(user.clone()); - } - Ok(user) - }) - } - - fn update_profile( - &self, - id: Uuid, - name: Option<&str>, - avatar_url: Option<&str>, - ) -> BoxFuture<'_, Result> { - let name = name.map(ToString::to_string); - let avatar_url = avatar_url.map(ToString::to_string); - Box::pin(async move { - let mut users = self.users.write().unwrap_or_else(PoisonError::into_inner); - let user = users - .iter_mut() - .find(|u| u.id == id) - .ok_or(StoreError::NotFound)?; - user.name = name; - user.avatar_url = avatar_url; - user.updated_at = Utc::now(); - let snapshot = user.clone(); - drop(users); - Ok(snapshot) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_new_user(email: &str) -> NewUser { - NewUser { - email: email.to_string(), - name: Some("Test User".to_string()), - avatar_url: None, - } - } - - #[tokio::test] - async fn create_and_find_by_id() { - let store = InMemoryUserStore::new(); - let user = store.create(make_new_user("a@test.com")).await.unwrap(); - - let found = store.find_by_id(user.id).await.unwrap(); - assert_eq!(found.unwrap().email, "a@test.com"); - } - - #[tokio::test] - async fn find_by_id_missing_returns_none() { - let store = InMemoryUserStore::new(); - let found = store.find_by_id(Uuid::now_v7()).await.unwrap(); - assert!(found.is_none()); - } - - #[tokio::test] - async fn find_by_email() { - let store = InMemoryUserStore::new(); - store.create(make_new_user("b@test.com")).await.unwrap(); - - let found = store.find_by_email("b@test.com").await.unwrap(); - assert!(found.is_some()); - - let missing = store.find_by_email("nope@test.com").await.unwrap(); - assert!(missing.is_none()); - } - - #[tokio::test] - async fn update_profile_changes_fields() { - let store = InMemoryUserStore::new(); - let user = store.create(make_new_user("c@test.com")).await.unwrap(); - - let updated = store - .update_profile(user.id, Some("New Name"), Some("https://img.test/1.png")) - .await - .unwrap(); - assert_eq!(updated.name, Some("New Name".into())); - assert_eq!(updated.avatar_url, Some("https://img.test/1.png".into())); - assert!(updated.updated_at > user.updated_at); - } - - #[tokio::test] - async fn new_user_gets_default_role() { - let store = InMemoryUserStore::new(); - let user = store.create(make_new_user("d@test.com")).await.unwrap(); - assert_eq!(user.role, "user"); - } -} diff --git a/crates/funnel-server/src/store/mod.rs b/crates/funnel-server/src/store/mod.rs index 9968235..038b0b6 100644 --- a/crates/funnel-server/src/store/mod.rs +++ b/crates/funnel-server/src/store/mod.rs @@ -1,10 +1,10 @@ pub mod account_store; pub mod api_key_store; pub mod health; -pub mod memory; pub mod pg; -#[allow(dead_code)] +pub mod turso; pub mod session_recorder; +pub mod team_store; pub mod tunnel_registry; pub mod user_store; @@ -17,7 +17,6 @@ pub type BoxFuture<'a, T> = Pin + Send + 'a>>; #[derive(Debug)] pub enum StoreError { Database(sqlx::Error), - #[allow(dead_code)] NotFound, Conflict(String), Other(String), @@ -45,6 +44,11 @@ impl std::error::Error for StoreError { impl From for StoreError { fn from(e: sqlx::Error) -> Self { + if let sqlx::Error::Database(ref db_err) = e { + if db_err.is_unique_violation() { + return Self::Conflict(db_err.message().to_string()); + } + } Self::Database(e) } } diff --git a/crates/funnel-server/src/store/pg/api_key_store.rs b/crates/funnel-server/src/store/pg/api_key_store.rs index c1cdeaf..7694246 100644 --- a/crates/funnel-server/src/store/pg/api_key_store.rs +++ b/crates/funnel-server/src/store/pg/api_key_store.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; @@ -21,11 +22,13 @@ impl ApiKeyStore for PgApiKeyStore { user_id: Uuid, name: &str, scopes: &serde_json::Value, + expires_at: Option>, ) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>> { let name = name.to_string(); let scopes = scopes.clone(); Box::pin(async move { - let (plaintext, key) = api_keys::create(&self.pool, user_id, &name, &scopes).await?; + let (plaintext, key) = + api_keys::create(&self.pool, user_id, &name, &scopes, expires_at).await?; Ok((plaintext, key.into())) }) } @@ -45,4 +48,9 @@ impl ApiKeyStore for PgApiKeyStore { fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { Box::pin(async move { Ok(api_keys::revoke(&self.pool, key_id, user_id).await?) }) } + + fn revoke_by_name(&self, user_id: Uuid, name: &str) -> BoxFuture<'_, Result> { + let name = name.to_string(); + Box::pin(async move { Ok(api_keys::revoke_by_name(&self.pool, user_id, &name).await?) }) + } } diff --git a/crates/funnel-server/src/store/pg/mod.rs b/crates/funnel-server/src/store/pg/mod.rs index a556f32..fa1817c 100644 --- a/crates/funnel-server/src/store/pg/mod.rs +++ b/crates/funnel-server/src/store/pg/mod.rs @@ -1,4 +1,5 @@ pub mod account_store; pub mod api_key_store; pub mod session_recorder; +pub mod team_store; pub mod user_store; diff --git a/crates/funnel-server/src/store/pg/session_recorder.rs b/crates/funnel-server/src/store/pg/session_recorder.rs index c5134c5..597a0e3 100644 --- a/crates/funnel-server/src/store/pg/session_recorder.rs +++ b/crates/funnel-server/src/store/pg/session_recorder.rs @@ -57,4 +57,8 @@ impl SessionRecorder for PgSessionRecorder { async move { Ok(tunnel_sessions::list_for_user(&self.pool, user_id, limit).await?) }, ) } + + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(tunnel_sessions::list_all(&self.pool, limit).await?) }) + } } diff --git a/crates/funnel-server/src/store/pg/team_store.rs b/crates/funnel-server/src/store/pg/team_store.rs new file mode 100644 index 0000000..d492d8c --- /dev/null +++ b/crates/funnel-server/src/store/pg/team_store.rs @@ -0,0 +1,101 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::db::teams::{self, Team, TeamMembership}; +use crate::store::team_store::TeamStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct PgTeamStore { + pool: PgPool, +} + +impl PgTeamStore { + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl TeamStore for PgTeamStore { + fn create(&self, name: &str) -> BoxFuture<'_, Result> { + let name = name.to_string(); + Box::pin(async move { Ok(teams::create(&self.pool, &name).await?) }) + } + + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::find_by_id(&self.pool, id).await?) }) + } + + fn find_by_name(&self, name: &str) -> BoxFuture<'_, Result, StoreError>> { + let name = name.to_string(); + Box::pin(async move { Ok(teams::find_by_name(&self.pool, &name).await?) }) + } + + fn list_all(&self) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::list_all(&self.pool).await?) }) + } + + fn delete(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(teams::delete(&self.pool, id).await?) }) + } + + fn add_member( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { Ok(teams::add_member(&self.pool, team_id, user_id, &role).await?) }) + } + + fn remove_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(teams::remove_member(&self.pool, team_id, user_id).await?) }) + } + + fn list_members( + &self, + team_id: Uuid, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::list_members(&self.pool, team_id).await?) }) + } + + fn list_teams_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::list_teams_for_user(&self.pool, user_id).await?) }) + } + + fn get_team_ids_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::get_team_ids_for_user(&self.pool, user_id).await?) }) + } + + fn is_member(&self, team_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(teams::is_member(&self.pool, team_id, user_id).await?) }) + } + + fn update_member_role( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { + Ok(teams::update_member_role(&self.pool, team_id, user_id, &role).await?) + }) + } + + fn find_membership( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(teams::find_membership(&self.pool, team_id, user_id).await?) }) + } + + fn count_owners(&self, team_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(teams::count_owners(&self.pool, team_id).await?) }) + } +} diff --git a/crates/funnel-server/src/store/pg/user_store.rs b/crates/funnel-server/src/store/pg/user_store.rs index 48307c5..d0c499c 100644 --- a/crates/funnel-server/src/store/pg/user_store.rs +++ b/crates/funnel-server/src/store/pg/user_store.rs @@ -44,4 +44,29 @@ impl UserStore for PgUserStore { ) }) } + + fn update_role(&self, id: Uuid, role: &str) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { Ok(users::update_role(&self.pool, id, &role).await?) }) + } + + fn deactivate(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(users::deactivate(&self.pool, id).await?) }) + } + + fn reactivate(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(users::reactivate(&self.pool, id).await?) }) + } + + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { Ok(users::list_all(&self.pool, limit).await?) }) + } + + fn count(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(users::count(&self.pool).await?) }) + } + + fn count_admins(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(users::count_admins(&self.pool).await?) }) + } } diff --git a/crates/funnel-server/src/store/session_recorder.rs b/crates/funnel-server/src/store/session_recorder.rs index c53bb98..44fe210 100644 --- a/crates/funnel-server/src/store/session_recorder.rs +++ b/crates/funnel-server/src/store/session_recorder.rs @@ -20,6 +20,7 @@ pub trait SessionRecorder: Send + Sync { requests: i64, ) -> BoxFuture<'_, Result>; + #[allow(dead_code)] fn list_active(&self) -> BoxFuture<'_, Result, StoreError>>; fn list_for_user( @@ -27,4 +28,6 @@ pub trait SessionRecorder: Send + Sync { user_id: Uuid, limit: i64, ) -> BoxFuture<'_, Result, StoreError>>; + + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>>; } diff --git a/crates/funnel-server/src/store/team_store.rs b/crates/funnel-server/src/store/team_store.rs new file mode 100644 index 0000000..dc189d2 --- /dev/null +++ b/crates/funnel-server/src/store/team_store.rs @@ -0,0 +1,42 @@ +use uuid::Uuid; + +use super::{BoxFuture, StoreError}; +use crate::db::teams::{Team, TeamMembership}; + +pub trait TeamStore: Send + Sync { + fn create(&self, name: &str) -> BoxFuture<'_, Result>; + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>>; + fn find_by_name(&self, name: &str) -> BoxFuture<'_, Result, StoreError>>; + fn list_all(&self) -> BoxFuture<'_, Result, StoreError>>; + fn delete(&self, id: Uuid) -> BoxFuture<'_, Result>; + fn add_member( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result>; + fn remove_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result>; + fn list_members( + &self, + team_id: Uuid, + ) -> BoxFuture<'_, Result, StoreError>>; + fn list_teams_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>>; + fn get_team_ids_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>>; + fn is_member(&self, team_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result>; + fn update_member_role( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result>; + fn find_membership( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result, StoreError>>; + fn count_owners(&self, team_id: Uuid) -> BoxFuture<'_, Result>; +} diff --git a/crates/funnel-server/src/store/turso/account_store.rs b/crates/funnel-server/src/store/turso/account_store.rs new file mode 100644 index 0000000..69eafeb --- /dev/null +++ b/crates/funnel-server/src/store/turso/account_store.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; + +use chrono::Utc; +use turso::Database; +use uuid::Uuid; + +use super::{format_dt, map_err, parse_dt, parse_uuid}; +use crate::db::accounts::{Account, NewAccount}; +use crate::store::account_store::AccountStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct TursoAccountStore { + db: Arc, +} + +impl TursoAccountStore { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +fn row_to_account(row: &turso::Row) -> Result { + Ok(Account { + id: parse_uuid(&row.get::(0).map_err(map_err)?)?, + user_id: parse_uuid(&row.get::(1).map_err(map_err)?)?, + provider: row.get::(2).map_err(map_err)?, + provider_account_id: row.get::(3).map_err(map_err)?, + metadata: serde_json::from_str(&row.get::(4).map_err(map_err)?) + .map_err(|e| StoreError::Other(format!("invalid json: {e}")))?, + created_at: parse_dt(&row.get::(5).map_err(map_err)?)?, + updated_at: parse_dt(&row.get::(6).map_err(map_err)?)?, + }) +} + +impl AccountStore for TursoAccountStore { + fn find_by_provider( + &self, + provider: &str, + provider_account_id: &str, + ) -> BoxFuture<'_, Result, StoreError>> { + let provider = provider.to_string(); + let provider_account_id = provider_account_id.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, user_id, provider, provider_account_id, metadata, created_at, updated_at FROM accounts WHERE provider = ? AND provider_account_id = ?", + turso::params![provider, provider_account_id], + ) + .await + .map_err(map_err)?; + match rows.next().await.map_err(map_err)? { + Some(row) => Ok(Some(row_to_account(&row)?)), + None => Ok(None), + } + }) + } + + fn create(&self, new_account: NewAccount) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let id = Uuid::now_v7(); + let now = Utc::now(); + let now_str = format_dt(now); + let metadata_str = serde_json::to_string(&new_account.metadata) + .map_err(|e| StoreError::Other(format!("invalid json: {e}")))?; + + conn.execute( + "INSERT INTO accounts (id, user_id, provider, provider_account_id, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + turso::params![ + id.to_string(), + new_account.user_id.to_string(), + new_account.provider.clone(), + new_account.provider_account_id.clone(), + metadata_str, + now_str.clone(), + now_str + ], + ) + .await + .map_err(map_err)?; + + Ok(Account { + id, + user_id: new_account.user_id, + provider: new_account.provider, + provider_account_id: new_account.provider_account_id, + metadata: new_account.metadata, + created_at: now, + updated_at: now, + }) + }) + } + + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, user_id, provider, provider_account_id, metadata, created_at, updated_at FROM accounts WHERE user_id = ? ORDER BY created_at DESC", + turso::params![user_id.to_string()], + ) + .await + .map_err(map_err)?; + let mut accounts = Vec::new(); + while let Some(row) = rows.next().await.map_err(map_err)? { + accounts.push(row_to_account(&row)?); + } + Ok(accounts) + }) + } +} diff --git a/crates/funnel-server/src/store/turso/api_key_store.rs b/crates/funnel-server/src/store/turso/api_key_store.rs new file mode 100644 index 0000000..4acf63b --- /dev/null +++ b/crates/funnel-server/src/store/turso/api_key_store.rs @@ -0,0 +1,319 @@ +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use turso::Database; +use uuid::Uuid; + +use super::{format_dt, map_err, parse_dt, parse_optional_dt, parse_uuid}; +use crate::db::api_keys::{ApiKey, ApiKeyView}; +use crate::store::api_key_store::ApiKeyStore; +use crate::store::{BoxFuture, StoreError}; +use funnel_core::auth::token as auth; + +pub struct TursoApiKeyStore { + db: Arc, +} + +impl TursoApiKeyStore { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +fn row_to_api_key(row: &turso::Row) -> Result { + Ok(ApiKey { + id: parse_uuid(&row.get::(0).map_err(map_err)?)?, + user_id: parse_uuid(&row.get::(1).map_err(map_err)?)?, + name: row.get::(2).map_err(map_err)?, + key_hash: row.get::(3).map_err(map_err)?, + key_prefix: row.get::(4).map_err(map_err)?, + scopes: serde_json::from_str(&row.get::(5).map_err(map_err)?) + .map_err(|e| StoreError::Other(format!("invalid json: {e}")))?, + created_at: parse_dt(&row.get::(6).map_err(map_err)?)?, + revoked_at: parse_optional_dt(row.get::>(7).map_err(map_err)?)?, + expires_at: parse_optional_dt(row.get::>(8).map_err(map_err)?)?, + }) +} + +impl ApiKeyStore for TursoApiKeyStore { + fn create( + &self, + user_id: Uuid, + name: &str, + scopes: &serde_json::Value, + expires_at: Option>, + ) -> BoxFuture<'_, Result<(String, ApiKeyView), StoreError>> { + let name = name.to_string(); + let scopes = scopes.clone(); + Box::pin(async move { + let plaintext = auth::generate_api_key() + .map_err(|e| StoreError::Other(format!("failed to generate api key: {e}")))?; + let hash = auth::hash_token(&plaintext); + let prefix = auth::ApiKeyPrefix::from_key(&plaintext); + + let conn = self.db.connect().map_err(map_err)?; + let id = Uuid::now_v7(); + let now = Utc::now(); + let scopes_str = serde_json::to_string(&scopes) + .map_err(|e| StoreError::Other(format!("invalid json: {e}")))?; + let expires_str = expires_at.map(format_dt); + + conn.execute( + "INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, scopes, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + turso::params![ + id.to_string(), + user_id.to_string(), + name.clone(), + hash, + prefix.as_ref().to_string(), + scopes_str, + format_dt(now), + expires_str.unwrap_or_default() + ], + ) + .await + .map_err(map_err)?; + + let view = ApiKeyView { + id, + name, + key_prefix: prefix.as_ref().to_string(), + scopes, + created_at: now, + revoked_at: None, + expires_at, + }; + + Ok((plaintext, view)) + }) + } + + fn validate(&self, plaintext: &str) -> BoxFuture<'_, Result, StoreError>> { + let hash = auth::hash_token(plaintext); + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let now = format_dt(Utc::now()); + let mut rows = conn + .query( + "SELECT id, user_id, name, key_hash, key_prefix, scopes, created_at, revoked_at, expires_at FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at = '' OR expires_at > ?)", + turso::params![hash, now], + ) + .await + .map_err(map_err)?; + match rows.next().await.map_err(map_err)? { + Some(row) => Ok(Some(row_to_api_key(&row)?)), + None => Ok(None), + } + }) + } + + fn list_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, user_id, name, key_hash, key_prefix, scopes, created_at, revoked_at, expires_at FROM api_keys WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC", + turso::params![user_id.to_string()], + ) + .await + .map_err(map_err)?; + let mut keys = Vec::new(); + while let Some(row) = rows.next().await.map_err(map_err)? { + let key = row_to_api_key(&row)?; + keys.push(ApiKeyView::from(key)); + } + Ok(keys) + }) + } + + fn revoke(&self, key_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let now = format_dt(Utc::now()); + let rows_affected = conn + .execute( + "UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL", + turso::params![now, key_id.to_string(), user_id.to_string()], + ) + .await + .map_err(map_err)?; + Ok(rows_affected > 0) + }) + } + + fn revoke_by_name( + &self, + user_id: Uuid, + name: &str, + ) -> BoxFuture<'_, Result> { + let name = name.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let now = format_dt(Utc::now()); + let rows_affected = conn + .execute( + "UPDATE api_keys SET revoked_at = ? WHERE user_id = ? AND name = ? AND revoked_at IS NULL", + turso::params![now, user_id.to_string(), name], + ) + .await + .map_err(map_err)?; + Ok(rows_affected > 0) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::api_keys::default_scopes; + use crate::db::users::NewUser; + use crate::store::turso::open; + use crate::store::turso::user_store::TursoUserStore; + use crate::store::user_store::UserStore; + + async fn setup() -> (TursoApiKeyStore, Uuid) { + let db = open(":memory:").await.unwrap_or_else(|e| panic!("open: {e}")); + let user_store = TursoUserStore::new(Arc::clone(&db)); + let user = user_store + .create(NewUser { + email: format!("key-test-{}@test.com", Uuid::now_v7()), + name: None, + avatar_url: None, + }) + .await + .unwrap_or_else(|e| panic!("create user: {e}")); + (TursoApiKeyStore::new(db), user.id) + } + + #[tokio::test] + async fn create_and_validate() { + let (store, uid) = setup().await; + + let (plaintext, view) = store + .create(uid, "test-key", &default_scopes(), None) + .await + .unwrap(); + assert_eq!(view.name, "test-key"); + + let found = store.validate(&plaintext).await.unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().id, view.id); + } + + #[tokio::test] + async fn validate_wrong_key_returns_none() { + let (store, _uid) = setup().await; + let found = store.validate("fnl_boguskey1234567890").await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn duplicate_name_returns_conflict() { + let (store, uid) = setup().await; + + store + .create(uid, "dup", &default_scopes(), None) + .await + .unwrap(); + let result = store.create(uid, "dup", &default_scopes(), None).await; + assert!(matches!(result, Err(StoreError::Conflict(_)))); + } + + #[tokio::test] + async fn list_for_user_only_returns_matching() { + let db = open(":memory:").await.unwrap_or_else(|e| panic!("open: {e}")); + let user_store = TursoUserStore::new(Arc::clone(&db)); + let u1 = user_store + .create(NewUser { email: format!("u1-{}@t.com", Uuid::now_v7()), name: None, avatar_url: None }) + .await + .unwrap(); + let u2 = user_store + .create(NewUser { email: format!("u2-{}@t.com", Uuid::now_v7()), name: None, avatar_url: None }) + .await + .unwrap(); + let store = TursoApiKeyStore::new(db); + + store.create(u1.id, "a", &default_scopes(), None).await.unwrap(); + store.create(u1.id, "b", &default_scopes(), None).await.unwrap(); + store.create(u2.id, "c", &default_scopes(), None).await.unwrap(); + + let list = store.list_for_user(u1.id).await.unwrap(); + assert_eq!(list.len(), 2); + assert!(list.iter().all(|k| k.name == "a" || k.name == "b")); + } + + #[tokio::test] + async fn revoke_makes_key_invalid() { + let (store, uid) = setup().await; + + let (plaintext, view) = store + .create(uid, "to-revoke", &default_scopes(), None) + .await + .unwrap(); + let revoked = store.revoke(view.id, uid).await.unwrap(); + assert!(revoked); + + let found = store.validate(&plaintext).await.unwrap(); + assert!(found.is_none()); + + let list = store.list_for_user(uid).await.unwrap(); + assert!(list.is_empty()); + } + + #[tokio::test] + async fn revoke_wrong_user_returns_false() { + let (store, uid) = setup().await; + let other = Uuid::now_v7(); + + let (_plaintext, view) = store + .create(uid, "key", &default_scopes(), None) + .await + .unwrap(); + let revoked = store.revoke(view.id, other).await.unwrap(); + assert!(!revoked); + } + + #[tokio::test] + async fn can_reuse_name_after_revoke() { + let (store, uid) = setup().await; + + let (_plaintext, view) = store + .create(uid, "reuse", &default_scopes(), None) + .await + .unwrap(); + store.revoke(view.id, uid).await.unwrap(); + + let result = store.create(uid, "reuse", &default_scopes(), None).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn scopes_are_preserved() { + let (store, uid) = setup().await; + let scopes = serde_json::json!(["tunnels"]); + + let (plaintext, view) = store + .create(uid, "tunnel-only", &scopes, None) + .await + .unwrap(); + assert_eq!(view.scopes, scopes); + + let key = store.validate(&plaintext).await.unwrap().unwrap(); + assert!(key.has_scope("tunnels")); + assert!(!key.has_scope("management")); + } + + #[tokio::test] + async fn expired_key_is_rejected() { + let (store, uid) = setup().await; + let past = Utc::now() - chrono::Duration::hours(1); + + let (plaintext, _) = store + .create(uid, "expired", &default_scopes(), Some(past)) + .await + .unwrap(); + + let found = store.validate(&plaintext).await.unwrap(); + assert!(found.is_none()); + } +} diff --git a/crates/funnel-server/src/store/turso/mod.rs b/crates/funnel-server/src/store/turso/mod.rs new file mode 100644 index 0000000..c06d675 --- /dev/null +++ b/crates/funnel-server/src/store/turso/mod.rs @@ -0,0 +1,55 @@ +pub mod account_store; +pub mod api_key_store; +pub mod session_recorder; +pub mod team_store; +pub mod user_store; + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use turso::Database; +use uuid::Uuid; + +use super::StoreError; + +const SCHEMA: &str = include_str!("../../../../../migrations/001_turso.sql"); + +pub async fn open(path: &str) -> Result, StoreError> { + let db = turso::Builder::new_local(path) + .build() + .await + .map_err(map_err)?; + let conn = db.connect().map_err(map_err)?; + conn.execute_batch(SCHEMA).await.map_err(map_err)?; + Ok(Arc::new(db)) +} + +pub fn map_err(e: turso::Error) -> StoreError { + let msg = e.to_string(); + if msg.contains("UNIQUE constraint failed") { + StoreError::Conflict(msg) + } else { + StoreError::Other(msg) + } +} + +pub fn parse_uuid(s: &str) -> Result { + s.parse::() + .map_err(|e| StoreError::Other(format!("invalid uuid: {e}"))) +} + +pub fn format_dt(dt: DateTime) -> String { + dt.to_rfc3339() +} + +pub fn parse_dt(s: &str) -> Result, StoreError> { + s.parse::>() + .map_err(|e| StoreError::Other(format!("invalid datetime: {e}"))) +} + +pub fn parse_optional_dt(s: Option) -> Result>, StoreError> { + match s { + Some(s) if !s.is_empty() => Ok(Some(parse_dt(&s)?)), + _ => Ok(None), + } +} diff --git a/crates/funnel-server/src/store/turso/session_recorder.rs b/crates/funnel-server/src/store/turso/session_recorder.rs new file mode 100644 index 0000000..2346211 --- /dev/null +++ b/crates/funnel-server/src/store/turso/session_recorder.rs @@ -0,0 +1,293 @@ +use std::sync::Arc; + +use chrono::Utc; +use ipnetwork::IpNetwork; +use turso::Database; +use uuid::Uuid; + +use super::{format_dt, map_err, parse_dt, parse_optional_dt, parse_uuid}; +use crate::db::tunnel_sessions::TunnelSession; +use crate::store::session_recorder::SessionRecorder; +use crate::store::{BoxFuture, StoreError}; + +pub struct TursoSessionRecorder { + db: Arc, +} + +impl TursoSessionRecorder { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +fn row_to_session(row: &turso::Row) -> Result { + let ip_str: Option = row.get::>(3).map_err(map_err)?; + let client_ip = match ip_str { + Some(s) if !s.is_empty() => Some( + s.parse::() + .map_err(|e| StoreError::Other(format!("invalid ip: {e}")))?, + ), + _ => None, + }; + Ok(TunnelSession { + id: parse_uuid(&row.get::(0).map_err(map_err)?)?, + user_id: parse_uuid(&row.get::(1).map_err(map_err)?)?, + tunnel_id: row.get::(2).map_err(map_err)?, + client_ip, + connected_at: parse_dt(&row.get::(4).map_err(map_err)?)?, + disconnected_at: parse_optional_dt(row.get::>(5).map_err(map_err)?)?, + bytes_in: row.get::(6).map_err(map_err)?, + bytes_out: row.get::(7).map_err(map_err)?, + requests: row.get::(8).map_err(map_err)?, + }) +} + +impl SessionRecorder for TursoSessionRecorder { + fn record_connect( + &self, + user_id: Uuid, + tunnel_id: &str, + client_ip: Option, + ) -> BoxFuture<'_, Result> { + let tunnel_id = tunnel_id.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let id = Uuid::now_v7(); + let now = Utc::now(); + let ip_str = client_ip.map(|ip| ip.to_string()).unwrap_or_default(); + + conn.execute( + "INSERT INTO tunnel_sessions (id, user_id, tunnel_id, client_ip, connected_at) VALUES (?, ?, ?, ?, ?)", + turso::params![ + id.to_string(), + user_id.to_string(), + tunnel_id.clone(), + ip_str, + format_dt(now) + ], + ) + .await + .map_err(map_err)?; + + Ok(TunnelSession { + id, + user_id, + tunnel_id, + client_ip, + connected_at: now, + disconnected_at: None, + bytes_in: 0, + bytes_out: 0, + requests: 0, + }) + }) + } + + fn record_disconnect( + &self, + session_id: Uuid, + bytes_in: i64, + bytes_out: i64, + requests: i64, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let now = format_dt(Utc::now()); + let rows_affected = conn + .execute( + "UPDATE tunnel_sessions SET disconnected_at = ?, bytes_in = ?, bytes_out = ?, requests = ? WHERE id = ? AND disconnected_at IS NULL", + turso::params![now, bytes_in, bytes_out, requests, session_id.to_string()], + ) + .await + .map_err(map_err)?; + Ok(rows_affected > 0) + }) + } + + fn list_active(&self) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, user_id, tunnel_id, client_ip, connected_at, disconnected_at, bytes_in, bytes_out, requests FROM tunnel_sessions WHERE disconnected_at IS NULL ORDER BY connected_at DESC", + (), + ) + .await + .map_err(map_err)?; + let mut sessions = Vec::new(); + while let Some(row) = rows.next().await.map_err(map_err)? { + sessions.push(row_to_session(&row)?); + } + Ok(sessions) + }) + } + + fn list_for_user( + &self, + user_id: Uuid, + limit: i64, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, user_id, tunnel_id, client_ip, connected_at, disconnected_at, bytes_in, bytes_out, requests FROM tunnel_sessions WHERE user_id = ? ORDER BY connected_at DESC LIMIT ?", + turso::params![user_id.to_string(), limit], + ) + .await + .map_err(map_err)?; + let mut sessions = Vec::new(); + while let Some(row) = rows.next().await.map_err(map_err)? { + sessions.push(row_to_session(&row)?); + } + Ok(sessions) + }) + } + + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, user_id, tunnel_id, client_ip, connected_at, disconnected_at, bytes_in, bytes_out, requests FROM tunnel_sessions ORDER BY connected_at DESC LIMIT ?", + turso::params![limit], + ) + .await + .map_err(map_err)?; + let mut sessions = Vec::new(); + while let Some(row) = rows.next().await.map_err(map_err)? { + sessions.push(row_to_session(&row)?); + } + Ok(sessions) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::users::NewUser; + use crate::store::turso::open; + use crate::store::turso::user_store::TursoUserStore; + use crate::store::user_store::UserStore; + + async fn setup() -> (TursoSessionRecorder, Uuid) { + let db = open(":memory:").await.unwrap_or_else(|e| panic!("open: {e}")); + let user_store = TursoUserStore::new(Arc::clone(&db)); + let user = user_store + .create(NewUser { + email: format!("sess-{}@test.com", Uuid::now_v7()), + name: None, + avatar_url: None, + }) + .await + .unwrap_or_else(|e| panic!("create user: {e}")); + (TursoSessionRecorder::new(db), user.id) + } + + #[tokio::test] + async fn record_connect_and_list_active() { + let (store, uid) = setup().await; + + let session = store.record_connect(uid, "tunnel-1", None).await.unwrap(); + assert_eq!(session.tunnel_id, "tunnel-1"); + assert_eq!(session.user_id, uid); + assert!(session.disconnected_at.is_none()); + + let active = store.list_active().await.unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].id, session.id); + } + + #[tokio::test] + async fn record_disconnect_removes_from_active() { + let (store, uid) = setup().await; + + let session = store.record_connect(uid, "tunnel-2", None).await.unwrap(); + let disconnected = store + .record_disconnect(session.id, 100, 200, 5) + .await + .unwrap(); + assert!(disconnected); + + let active = store.list_active().await.unwrap(); + assert!(active.is_empty()); + } + + #[tokio::test] + async fn record_disconnect_updates_stats() { + let (store, uid) = setup().await; + + let session = store.record_connect(uid, "tunnel-3", None).await.unwrap(); + store + .record_disconnect(session.id, 1024, 2048, 10) + .await + .unwrap(); + + let all = store.list_for_user(uid, 100).await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].bytes_in, 1024); + assert_eq!(all[0].bytes_out, 2048); + assert_eq!(all[0].requests, 10); + assert!(all[0].disconnected_at.is_some()); + } + + #[tokio::test] + async fn disconnect_nonexistent_returns_false() { + let (store, _uid) = setup().await; + let result = store + .record_disconnect(Uuid::now_v7(), 0, 0, 0) + .await + .unwrap(); + assert!(!result); + } + + #[tokio::test] + async fn disconnect_already_disconnected_returns_false() { + let (store, uid) = setup().await; + + let session = store.record_connect(uid, "tunnel-4", None).await.unwrap(); + store.record_disconnect(session.id, 0, 0, 0).await.unwrap(); + + let again = store.record_disconnect(session.id, 0, 0, 0).await.unwrap(); + assert!(!again); + } + + #[tokio::test] + async fn list_for_user_respects_limit() { + let (store, uid) = setup().await; + + for i in 0..5 { + store + .record_connect(uid, &format!("tunnel-{i}"), None) + .await + .unwrap(); + } + + let limited = store.list_for_user(uid, 3).await.unwrap(); + assert_eq!(limited.len(), 3); + } + + #[tokio::test] + async fn list_for_user_filters_by_user() { + let db = open(":memory:").await.unwrap_or_else(|e| panic!("open: {e}")); + let user_store = TursoUserStore::new(Arc::clone(&db)); + let u1 = user_store + .create(NewUser { email: format!("u1-{}@t.com", Uuid::now_v7()), name: None, avatar_url: None }) + .await + .unwrap(); + let u2 = user_store + .create(NewUser { email: format!("u2-{}@t.com", Uuid::now_v7()), name: None, avatar_url: None }) + .await + .unwrap(); + let store = TursoSessionRecorder::new(db); + + store.record_connect(u1.id, "a", None).await.unwrap(); + store.record_connect(u2.id, "b", None).await.unwrap(); + store.record_connect(u1.id, "c", None).await.unwrap(); + + let list = store.list_for_user(u1.id, 100).await.unwrap(); + assert_eq!(list.len(), 2); + assert!(list.iter().all(|s| s.user_id == u1.id)); + } +} diff --git a/crates/funnel-server/src/store/turso/team_store.rs b/crates/funnel-server/src/store/turso/team_store.rs new file mode 100644 index 0000000..56bf62d --- /dev/null +++ b/crates/funnel-server/src/store/turso/team_store.rs @@ -0,0 +1,322 @@ +use std::sync::Arc; + +use chrono::Utc; +use turso::Database; +use uuid::Uuid; + +use super::{format_dt, map_err, parse_dt, parse_uuid}; +use crate::db::teams::{Team, TeamMembership}; +use crate::store::team_store::TeamStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct TursoTeamStore { + db: Arc, +} + +impl TursoTeamStore { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +fn row_to_team(row: &turso::Row) -> Result { + Ok(Team { + id: parse_uuid(&row.get::(0).map_err(map_err)?)?, + name: row.get::(1).map_err(map_err)?, + created_at: parse_dt(&row.get::(2).map_err(map_err)?)?, + updated_at: parse_dt(&row.get::(3).map_err(map_err)?)?, + }) +} + +fn row_to_membership(row: &turso::Row) -> Result { + Ok(TeamMembership { + id: parse_uuid(&row.get::(0).map_err(map_err)?)?, + team_id: parse_uuid(&row.get::(1).map_err(map_err)?)?, + user_id: parse_uuid(&row.get::(2).map_err(map_err)?)?, + role: row.get::(3).map_err(map_err)?, + created_at: parse_dt(&row.get::(4).map_err(map_err)?)?, + }) +} + +impl TeamStore for TursoTeamStore { + fn create(&self, name: &str) -> BoxFuture<'_, Result> { + let name = name.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let id = Uuid::now_v7(); + let now = Utc::now(); + let now_str = format_dt(now); + conn.execute( + "INSERT INTO teams (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)", + turso::params![id.to_string(), name.clone(), now_str.clone(), now_str], + ) + .await + .map_err(map_err)?; + Ok(Team { + id, + name, + created_at: now, + updated_at: now, + }) + }) + } + + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, name, created_at, updated_at FROM teams WHERE id = ?", + turso::params![id.to_string()], + ) + .await + .map_err(map_err)?; + match rows.next().await.map_err(map_err)? { + Some(row) => Ok(Some(row_to_team(&row)?)), + None => Ok(None), + } + }) + } + + fn find_by_name(&self, name: &str) -> BoxFuture<'_, Result, StoreError>> { + let name = name.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, name, created_at, updated_at FROM teams WHERE name = ?", + turso::params![name], + ) + .await + .map_err(map_err)?; + match rows.next().await.map_err(map_err)? { + Some(row) => Ok(Some(row_to_team(&row)?)), + None => Ok(None), + } + }) + } + + fn list_all(&self) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, name, created_at, updated_at FROM teams ORDER BY name", + (), + ) + .await + .map_err(map_err)?; + let mut teams = Vec::new(); + while let Some(row) = rows.next().await.map_err(map_err)? { + teams.push(row_to_team(&row)?); + } + Ok(teams) + }) + } + + fn delete(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let rows_affected = conn + .execute( + "DELETE FROM teams WHERE id = ?", + turso::params![id.to_string()], + ) + .await + .map_err(map_err)?; + Ok(rows_affected > 0) + }) + } + + fn add_member( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let id = Uuid::now_v7(); + let now = Utc::now(); + conn.execute( + "INSERT INTO team_memberships (id, team_id, user_id, role, created_at) VALUES (?, ?, ?, ?, ?)", + turso::params![ + id.to_string(), + team_id.to_string(), + user_id.to_string(), + role.clone(), + format_dt(now) + ], + ) + .await + .map_err(map_err)?; + Ok(TeamMembership { + id, + team_id, + user_id, + role, + created_at: now, + }) + }) + } + + fn remove_member( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let rows_affected = conn + .execute( + "DELETE FROM team_memberships WHERE team_id = ? AND user_id = ?", + turso::params![team_id.to_string(), user_id.to_string()], + ) + .await + .map_err(map_err)?; + Ok(rows_affected > 0) + }) + } + + fn list_members( + &self, + team_id: Uuid, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, team_id, user_id, role, created_at FROM team_memberships WHERE team_id = ? ORDER BY created_at", + turso::params![team_id.to_string()], + ) + .await + .map_err(map_err)?; + let mut members = Vec::new(); + while let Some(row) = rows.next().await.map_err(map_err)? { + members.push(row_to_membership(&row)?); + } + Ok(members) + }) + } + + fn list_teams_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT t.id, t.name, t.created_at, t.updated_at FROM teams t INNER JOIN team_memberships tm ON t.id = tm.team_id WHERE tm.user_id = ? ORDER BY t.name", + turso::params![user_id.to_string()], + ) + .await + .map_err(map_err)?; + let mut teams = Vec::new(); + while let Some(row) = rows.next().await.map_err(map_err)? { + teams.push(row_to_team(&row)?); + } + Ok(teams) + }) + } + + fn get_team_ids_for_user(&self, user_id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT team_id FROM team_memberships WHERE user_id = ?", + turso::params![user_id.to_string()], + ) + .await + .map_err(map_err)?; + let mut ids = Vec::new(); + while let Some(row) = rows.next().await.map_err(map_err)? { + ids.push(parse_uuid(&row.get::(0).map_err(map_err)?)?); + } + Ok(ids) + }) + } + + fn is_member(&self, team_id: Uuid, user_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT 1 FROM team_memberships WHERE team_id = ? AND user_id = ?", + turso::params![team_id.to_string(), user_id.to_string()], + ) + .await + .map_err(map_err)?; + Ok(rows.next().await.map_err(map_err)?.is_some()) + }) + } + + fn update_member_role( + &self, + team_id: Uuid, + user_id: Uuid, + role: &str, + ) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + conn.execute( + "UPDATE team_memberships SET role = ? WHERE team_id = ? AND user_id = ?", + turso::params![role, team_id.to_string(), user_id.to_string()], + ) + .await + .map_err(map_err)?; + + let mut rows = conn + .query( + "SELECT id, team_id, user_id, role, created_at FROM team_memberships WHERE team_id = ? AND user_id = ?", + turso::params![team_id.to_string(), user_id.to_string()], + ) + .await + .map_err(map_err)?; + match rows.next().await.map_err(map_err)? { + Some(row) => Ok(row_to_membership(&row)?), + None => Err(StoreError::NotFound), + } + }) + } + + fn find_membership( + &self, + team_id: Uuid, + user_id: Uuid, + ) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, team_id, user_id, role, created_at FROM team_memberships WHERE team_id = ? AND user_id = ?", + turso::params![team_id.to_string(), user_id.to_string()], + ) + .await + .map_err(map_err)?; + match rows.next().await.map_err(map_err)? { + Some(row) => Ok(Some(row_to_membership(&row)?)), + None => Ok(None), + } + }) + } + + fn count_owners(&self, team_id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT COUNT(*) FROM team_memberships WHERE team_id = ? AND role = 'owner'", + turso::params![team_id.to_string()], + ) + .await + .map_err(map_err)?; + let row = rows + .next() + .await + .map_err(map_err)? + .ok_or(StoreError::Other("no count row".into()))?; + Ok(row.get::(0).map_err(map_err)?) + }) + } +} diff --git a/crates/funnel-server/src/store/turso/user_store.rs b/crates/funnel-server/src/store/turso/user_store.rs new file mode 100644 index 0000000..5a30d58 --- /dev/null +++ b/crates/funnel-server/src/store/turso/user_store.rs @@ -0,0 +1,301 @@ +use std::sync::Arc; + +use chrono::Utc; +use turso::Database; +use uuid::Uuid; + +use super::{format_dt, map_err, parse_dt, parse_optional_dt, parse_uuid}; +use crate::db::users::{NewUser, User, ROLE_MEMBER}; +use crate::store::user_store::UserStore; +use crate::store::{BoxFuture, StoreError}; + +pub struct TursoUserStore { + db: Arc, +} + +impl TursoUserStore { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +fn row_to_user(row: &turso::Row) -> Result { + Ok(User { + id: parse_uuid(&row.get::(0).map_err(map_err)?)?, + email: row.get::(1).map_err(map_err)?, + name: row.get::>(2).map_err(map_err)?, + avatar_url: row.get::>(3).map_err(map_err)?, + role: row.get::(4).map_err(map_err)?, + metadata: serde_json::from_str(&row.get::(5).map_err(map_err)?) + .map_err(|e| StoreError::Other(format!("invalid json: {e}")))?, + created_at: parse_dt(&row.get::(6).map_err(map_err)?)?, + updated_at: parse_dt(&row.get::(7).map_err(map_err)?)?, + deactivated_at: parse_optional_dt(row.get::>(8).map_err(map_err)?)?, + }) +} + +impl UserStore for TursoUserStore { + fn find_by_id(&self, id: Uuid) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, email, name, avatar_url, role, metadata, created_at, updated_at, deactivated_at FROM users WHERE id = ?", + turso::params![id.to_string()], + ) + .await + .map_err(map_err)?; + match rows.next().await.map_err(map_err)? { + Some(row) => Ok(Some(row_to_user(&row)?)), + None => Ok(None), + } + }) + } + + fn find_by_email(&self, email: &str) -> BoxFuture<'_, Result, StoreError>> { + let email = email.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, email, name, avatar_url, role, metadata, created_at, updated_at, deactivated_at FROM users WHERE email = ?", + turso::params![email], + ) + .await + .map_err(map_err)?; + match rows.next().await.map_err(map_err)? { + Some(row) => Ok(Some(row_to_user(&row)?)), + None => Ok(None), + } + }) + } + + fn create(&self, new_user: NewUser) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let id = Uuid::now_v7(); + let now = Utc::now(); + let now_str = format_dt(now); + let metadata = serde_json::json!({}).to_string(); + conn.execute( + "INSERT INTO users (id, email, name, avatar_url, role, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + turso::params![ + id.to_string(), + new_user.email.clone(), + new_user.name.clone().unwrap_or_default(), + new_user.avatar_url.clone().unwrap_or_default(), + ROLE_MEMBER, + metadata, + now_str.clone(), + now_str + ], + ) + .await + .map_err(map_err)?; + Ok(User { + id, + email: new_user.email, + name: new_user.name, + avatar_url: new_user.avatar_url, + role: ROLE_MEMBER.into(), + metadata: serde_json::json!({}), + created_at: now, + updated_at: now, + deactivated_at: None, + }) + }) + } + + fn update_profile( + &self, + id: Uuid, + name: Option<&str>, + avatar_url: Option<&str>, + ) -> BoxFuture<'_, Result> { + let name = name.map(ToString::to_string); + let avatar_url = avatar_url.map(ToString::to_string); + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let now = format_dt(Utc::now()); + conn.execute( + "UPDATE users SET name = ?, avatar_url = ?, updated_at = ? WHERE id = ?", + turso::params![ + name.clone().unwrap_or_default(), + avatar_url.clone().unwrap_or_default(), + now, + id.to_string() + ], + ) + .await + .map_err(map_err)?; + let user = self.find_by_id(id).await?.ok_or(StoreError::NotFound)?; + Ok(user) + }) + } + + fn update_role(&self, id: Uuid, role: &str) -> BoxFuture<'_, Result> { + let role = role.to_string(); + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let now = format_dt(Utc::now()); + conn.execute( + "UPDATE users SET role = ?, updated_at = ? WHERE id = ?", + turso::params![role, now, id.to_string()], + ) + .await + .map_err(map_err)?; + let user = self.find_by_id(id).await?.ok_or(StoreError::NotFound)?; + Ok(user) + }) + } + + fn deactivate(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let now = format_dt(Utc::now()); + conn.execute( + "UPDATE users SET deactivated_at = ?, updated_at = ? WHERE id = ?", + turso::params![now.clone(), now, id.to_string()], + ) + .await + .map_err(map_err)?; + let user = self.find_by_id(id).await?.ok_or(StoreError::NotFound)?; + Ok(user) + }) + } + + fn reactivate(&self, id: Uuid) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let now = format_dt(Utc::now()); + conn.execute( + "UPDATE users SET deactivated_at = NULL, updated_at = ? WHERE id = ?", + turso::params![now, id.to_string()], + ) + .await + .map_err(map_err)?; + let user = self.find_by_id(id).await?.ok_or(StoreError::NotFound)?; + Ok(user) + }) + } + + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT id, email, name, avatar_url, role, metadata, created_at, updated_at, deactivated_at FROM users ORDER BY created_at DESC LIMIT ?", + turso::params![limit], + ) + .await + .map_err(map_err)?; + let mut users = Vec::new(); + while let Some(row) = rows.next().await.map_err(map_err)? { + users.push(row_to_user(&row)?); + } + Ok(users) + }) + } + + fn count(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query("SELECT COUNT(*) FROM users", ()) + .await + .map_err(map_err)?; + let row = rows + .next() + .await + .map_err(map_err)? + .ok_or(StoreError::Other("no count row".into()))?; + Ok(row.get::(0).map_err(map_err)?) + }) + } + + fn count_admins(&self) -> BoxFuture<'_, Result> { + Box::pin(async move { + let conn = self.db.connect().map_err(map_err)?; + let mut rows = conn + .query( + "SELECT COUNT(*) FROM users WHERE role = 'admin' AND deactivated_at IS NULL", + (), + ) + .await + .map_err(map_err)?; + let row = rows + .next() + .await + .map_err(map_err)? + .ok_or(StoreError::Other("no count row".into()))?; + Ok(row.get::(0).map_err(map_err)?) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::turso::open; + + async fn store() -> TursoUserStore { + let db = open(":memory:").await.unwrap_or_else(|e| panic!("open: {e}")); + TursoUserStore::new(db) + } + + fn make_new_user(email: &str) -> NewUser { + NewUser { + email: email.to_string(), + name: Some("Test User".to_string()), + avatar_url: None, + } + } + + #[tokio::test] + async fn create_and_find_by_id() { + let store = store().await; + let user = store.create(make_new_user("a@test.com")).await.unwrap(); + + let found = store.find_by_id(user.id).await.unwrap(); + assert_eq!(found.unwrap().email, "a@test.com"); + } + + #[tokio::test] + async fn find_by_id_missing_returns_none() { + let store = store().await; + let found = store.find_by_id(Uuid::now_v7()).await.unwrap(); + assert!(found.is_none()); + } + + #[tokio::test] + async fn find_by_email() { + let store = store().await; + store.create(make_new_user("b@test.com")).await.unwrap(); + + let found = store.find_by_email("b@test.com").await.unwrap(); + assert!(found.is_some()); + + let missing = store.find_by_email("nope@test.com").await.unwrap(); + assert!(missing.is_none()); + } + + #[tokio::test] + async fn update_profile_changes_fields() { + let store = store().await; + let user = store.create(make_new_user("c@test.com")).await.unwrap(); + + let updated = store + .update_profile(user.id, Some("New Name"), Some("https://img.test/1.png")) + .await + .unwrap(); + assert_eq!(updated.name, Some("New Name".into())); + assert_eq!(updated.avatar_url, Some("https://img.test/1.png".into())); + } + + #[tokio::test] + async fn new_user_gets_default_role() { + let store = store().await; + let user = store.create(make_new_user("d@test.com")).await.unwrap(); + assert_eq!(user.role, "member"); + } +} diff --git a/crates/funnel-server/src/store/user_store.rs b/crates/funnel-server/src/store/user_store.rs index 388e5f4..ff087e8 100644 --- a/crates/funnel-server/src/store/user_store.rs +++ b/crates/funnel-server/src/store/user_store.rs @@ -13,4 +13,10 @@ pub trait UserStore: Send + Sync { name: Option<&str>, avatar_url: Option<&str>, ) -> BoxFuture<'_, Result>; + fn update_role(&self, id: Uuid, role: &str) -> BoxFuture<'_, Result>; + fn deactivate(&self, id: Uuid) -> BoxFuture<'_, Result>; + fn reactivate(&self, id: Uuid) -> BoxFuture<'_, Result>; + fn list_all(&self, limit: i64) -> BoxFuture<'_, Result, StoreError>>; + fn count(&self) -> BoxFuture<'_, Result>; + fn count_admins(&self) -> BoxFuture<'_, Result>; } diff --git a/crates/funnel-server/src/tunnel/connection.rs b/crates/funnel-server/src/tunnel/connection.rs index 03e2abc..0ec275e 100644 --- a/crates/funnel-server/src/tunnel/connection.rs +++ b/crates/funnel-server/src/tunnel/connection.rs @@ -19,15 +19,24 @@ pub struct ActiveTunnel { conn: quinn::Connection, stats: Arc, connected_at: tokio::time::Instant, + owner_id: uuid::Uuid, + team_id: Option, } impl ActiveTunnel { - pub fn new(id: TunnelId, conn: quinn::Connection) -> Self { + pub fn new( + id: TunnelId, + conn: quinn::Connection, + owner_id: uuid::Uuid, + team_id: Option, + ) -> Self { Self { id, conn, stats: Arc::new(TunnelStats::new()), connected_at: tokio::time::Instant::now(), + owner_id, + team_id, } } @@ -43,6 +52,14 @@ impl ActiveTunnel { self.connected_at } + pub const fn owner_id(&self) -> uuid::Uuid { + self.owner_id + } + + pub const fn team_id(&self) -> Option { + self.team_id + } + /// open a quic bidirectional stream, send the request, and return the /// response metadata along with a counted recv stream for body streaming. pub async fn send_request( @@ -106,6 +123,47 @@ impl ActiveTunnel { } } + /// open a quic bidirectional stream, send the request metadata, then + /// return the response metadata and raw streams for bidirectional piping. + /// unlike send_request, this does not send a body or call finish(). + pub async fn send_upgrade_request( + &self, + meta: RequestMeta, + ) -> Result<(ResponseMeta, quinn::SendStream, quinn::RecvStream), SendError> { + self.stats.inc_requests(); + + let result = tokio::time::timeout(REQUEST_TIMEOUT, async { + let (mut send, mut recv) = self.conn.open_bi().await.map_err(SendError::OpenStream)?; + + frame::write_meta(&mut send, &meta) + .await + .map_err(SendError::SendMeta)?; + + let resp_meta: ResponseMeta = frame::read_meta(&mut recv) + .await + .map_err(SendError::ReadResponse)?; + + Ok::<_, SendError>((resp_meta, send, recv)) + }) + .await; + + match result { + Ok(Ok(response)) => { + metrics::counter!("funnel_requests_total", "outcome" => "success").increment(1); + Ok(response) + } + Ok(Err(e)) => { + metrics::counter!("funnel_requests_total", "outcome" => e.outcome_label()) + .increment(1); + Err(e) + } + Err(_) => { + metrics::counter!("funnel_requests_total", "outcome" => "timeout").increment(1); + Err(SendError::Timeout) + } + } + } + pub fn close(&self) { self.conn .close(quinn::VarInt::from_u32(0), b"tunnel closed"); diff --git a/crates/funnel-server/src/tunnel/manager.rs b/crates/funnel-server/src/tunnel/manager.rs index 931ca9a..010b545 100644 --- a/crates/funnel-server/src/tunnel/manager.rs +++ b/crates/funnel-server/src/tunnel/manager.rs @@ -6,6 +6,8 @@ use serde::Serialize; use funnel_core::tunnel::id::TunnelId; +use uuid::Uuid; + use super::connection::ActiveTunnel; use super::stats::TunnelStatsSnapshot; use crate::store::tunnel_registry::TunnelRegistry; @@ -19,6 +21,8 @@ pub struct TunnelInfo { pub id: String, pub uptime_secs: f64, pub stats: TunnelStatsSnapshot, + pub owner_id: Uuid, + pub team_id: Option, } impl TunnelManager { @@ -55,6 +59,8 @@ impl TunnelManager { id: entry.key().to_string(), uptime_secs: tunnel.connected_at().elapsed().as_secs_f64(), stats: tunnel.stats(), + owner_id: tunnel.owner_id(), + team_id: tunnel.team_id(), } }) .collect() diff --git a/migrations/001_create_users.sql b/migrations/001_create_users.sql deleted file mode 100644 index 97ab884..0000000 --- a/migrations/001_create_users.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email TEXT NOT NULL UNIQUE, - name TEXT, - avatar_url TEXT, - role TEXT NOT NULL DEFAULT 'user', - metadata JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX idx_users_email ON users(email); - -CREATE TABLE accounts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider TEXT NOT NULL, - provider_account_id TEXT NOT NULL, - metadata JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - UNIQUE(provider, provider_account_id) -); - -CREATE INDEX idx_accounts_user ON accounts(user_id); -CREATE INDEX idx_accounts_provider ON accounts(provider, provider_account_id); diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql new file mode 100644 index 0000000..9e4f2f7 --- /dev/null +++ b/migrations/001_initial.sql @@ -0,0 +1,88 @@ +CREATE TABLE schema_version ( + version INTEGER NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +INSERT INTO schema_version (version) VALUES (1); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + name TEXT, + avatar_url TEXT, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')), + metadata JSONB NOT NULL DEFAULT '{}', + deactivated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_users_email ON users(email); + +CREATE TABLE accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_account_id TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE(provider, provider_account_id) +); + +CREATE INDEX idx_accounts_user ON accounts(user_id); +CREATE INDEX idx_accounts_provider ON accounts(provider, provider_account_id); + +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + key_prefix TEXT NOT NULL, + scopes JSONB NOT NULL DEFAULT '["management", "tunnels"]', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX idx_api_keys_user_name_active + ON api_keys(user_id, name) WHERE revoked_at IS NULL; +CREATE INDEX idx_api_keys_user ON api_keys(user_id); +CREATE INDEX idx_api_keys_hash ON api_keys(key_hash) WHERE revoked_at IS NULL; + +CREATE TABLE tunnel_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tunnel_id TEXT NOT NULL, + client_ip INET, + connected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + disconnected_at TIMESTAMPTZ, + bytes_in BIGINT NOT NULL DEFAULT 0, + bytes_out BIGINT NOT NULL DEFAULT 0, + requests BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_tunnel_sessions_user ON tunnel_sessions(user_id); +CREATE INDEX idx_tunnel_sessions_tunnel ON tunnel_sessions(tunnel_id); +CREATE INDEX idx_tunnel_sessions_active ON tunnel_sessions(disconnected_at) + WHERE disconnected_at IS NULL; + +CREATE TABLE teams ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE team_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE(team_id, user_id) +); + +CREATE INDEX idx_team_memberships_team ON team_memberships(team_id); +CREATE INDEX idx_team_memberships_user ON team_memberships(user_id); diff --git a/migrations/001_turso.sql b/migrations/001_turso.sql new file mode 100644 index 0000000..411aada --- /dev/null +++ b/migrations/001_turso.sql @@ -0,0 +1,91 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL, + applied_at TEXT NOT NULL +); +INSERT OR IGNORE INTO schema_version (version, applied_at) + SELECT 1, datetime('now') WHERE NOT EXISTS (SELECT 1 FROM schema_version WHERE version = 1); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + avatar_url TEXT, + role TEXT NOT NULL DEFAULT 'member', + metadata TEXT NOT NULL DEFAULT '{}', + deactivated_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + +CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_account_id TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + + UNIQUE(provider, provider_account_id) +); + +CREATE INDEX IF NOT EXISTS idx_accounts_user ON accounts(user_id); +CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts(provider, provider_account_id); + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + key_prefix TEXT NOT NULL, + scopes TEXT NOT NULL DEFAULT '["management", "tunnels"]', + created_at TEXT NOT NULL, + revoked_at TEXT, + expires_at TEXT +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_user_name_active + ON api_keys(user_id, name) WHERE revoked_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id); +CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash) WHERE revoked_at IS NULL; + +CREATE TABLE IF NOT EXISTS tunnel_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tunnel_id TEXT NOT NULL, + client_ip TEXT, + connected_at TEXT NOT NULL, + disconnected_at TEXT, + bytes_in INTEGER NOT NULL DEFAULT 0, + bytes_out INTEGER NOT NULL DEFAULT 0, + requests INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_tunnel_sessions_user ON tunnel_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_tunnel_sessions_tunnel ON tunnel_sessions(tunnel_id); +CREATE INDEX IF NOT EXISTS idx_tunnel_sessions_active ON tunnel_sessions(disconnected_at) + WHERE disconnected_at IS NULL; + +CREATE TABLE IF NOT EXISTS teams ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS team_memberships ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + created_at TEXT NOT NULL, + + UNIQUE(team_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_team_memberships_team ON team_memberships(team_id); +CREATE INDEX IF NOT EXISTS idx_team_memberships_user ON team_memberships(user_id); diff --git a/migrations/002_create_api_keys.sql b/migrations/002_create_api_keys.sql deleted file mode 100644 index 0dd0507..0000000 --- a/migrations/002_create_api_keys.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE api_keys ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - key_hash TEXT NOT NULL, - key_prefix TEXT NOT NULL, - scopes JSONB NOT NULL DEFAULT '["management", "tunnels"]', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - revoked_at TIMESTAMPTZ, - - UNIQUE(user_id, name) -); - -CREATE INDEX idx_api_keys_user ON api_keys(user_id); -CREATE INDEX idx_api_keys_hash ON api_keys(key_hash) WHERE revoked_at IS NULL; diff --git a/migrations/003_create_tunnel_sessions.sql b/migrations/003_create_tunnel_sessions.sql deleted file mode 100644 index 683d4f7..0000000 --- a/migrations/003_create_tunnel_sessions.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE tunnel_sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - tunnel_id TEXT NOT NULL, - client_ip INET, - connected_at TIMESTAMPTZ NOT NULL DEFAULT now(), - disconnected_at TIMESTAMPTZ, - bytes_in BIGINT NOT NULL DEFAULT 0, - bytes_out BIGINT NOT NULL DEFAULT 0, - requests BIGINT NOT NULL DEFAULT 0 -); - -CREATE INDEX idx_tunnel_sessions_user ON tunnel_sessions(user_id); -CREATE INDEX idx_tunnel_sessions_tunnel ON tunnel_sessions(tunnel_id); -CREATE INDEX idx_tunnel_sessions_active ON tunnel_sessions(disconnected_at) - WHERE disconnected_at IS NULL; From 8465fc647f020275c8c9d50eb899f8ea2a883e3c Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Mon, 11 May 2026 11:39:11 +0200 Subject: [PATCH 33/42] refactor(client): reorganize into cmd/ and tunnel/ modules, remove quic_port from config, version API routes --- .gitignore | 8 + crates/funnel-client/src/api_client.rs | 62 +++ crates/funnel-client/src/{ => cmd}/auth.rs | 8 + crates/funnel-client/src/cmd/config.rs | 25 + crates/funnel-client/src/cmd/context.rs | 70 +++ crates/funnel-client/src/cmd/http.rs | 141 ++++++ crates/funnel-client/src/{ => cmd}/keys.rs | 27 + crates/funnel-client/src/cmd/mod.rs | 10 + .../funnel-client/src/{ => cmd}/sessions.rs | 0 crates/funnel-client/src/{ => cmd}/status.rs | 0 crates/funnel-client/src/{ => cmd}/teams.rs | 64 +++ crates/funnel-client/src/{ => cmd}/users.rs | 36 ++ crates/funnel-client/src/{ => cmd}/whoami.rs | 0 crates/funnel-client/src/config.rs | 25 +- crates/funnel-client/src/main.rs | 464 ++---------------- .../src/{tunnel.rs => tunnel/client.rs} | 4 +- .../funnel-client/src/{ => tunnel}/display.rs | 0 .../src/{ => tunnel}/forwarder.rs | 0 crates/funnel-client/src/tunnel/mod.rs | 4 + .../funnel-client/src/{ => tunnel}/runner.rs | 4 +- crates/funnel-core/src/api/mod.rs | 19 + crates/funnel-core/src/api/types.rs | 7 + crates/funnel-core/src/lib.rs | 1 + crates/funnel-e2e/tests/e2e/auth.rs | 44 +- crates/funnel-e2e/tests/e2e/harness.rs | 10 +- crates/funnel-e2e/tests/e2e/main.rs | 2 +- crates/funnel-server/src/api/info.rs | 16 + crates/funnel-server/src/api/mod.rs | 1 + crates/funnel-server/src/api/oauth.rs | 6 +- crates/funnel-server/src/app.rs | 2 + crates/funnel-server/src/main.rs | 7 +- 31 files changed, 576 insertions(+), 491 deletions(-) create mode 100644 crates/funnel-client/src/api_client.rs rename crates/funnel-client/src/{ => cmd}/auth.rs (93%) create mode 100644 crates/funnel-client/src/cmd/config.rs create mode 100644 crates/funnel-client/src/cmd/context.rs create mode 100644 crates/funnel-client/src/cmd/http.rs rename crates/funnel-client/src/{ => cmd}/keys.rs (86%) create mode 100644 crates/funnel-client/src/cmd/mod.rs rename crates/funnel-client/src/{ => cmd}/sessions.rs (100%) rename crates/funnel-client/src/{ => cmd}/status.rs (100%) rename crates/funnel-client/src/{ => cmd}/teams.rs (77%) rename crates/funnel-client/src/{ => cmd}/users.rs (78%) rename crates/funnel-client/src/{ => cmd}/whoami.rs (100%) rename crates/funnel-client/src/{tunnel.rs => tunnel/client.rs} (99%) rename crates/funnel-client/src/{ => tunnel}/display.rs (100%) rename crates/funnel-client/src/{ => tunnel}/forwarder.rs (100%) create mode 100644 crates/funnel-client/src/tunnel/mod.rs rename crates/funnel-client/src/{ => tunnel}/runner.rs (97%) create mode 100644 crates/funnel-core/src/api/mod.rs create mode 100644 crates/funnel-core/src/api/types.rs create mode 100644 crates/funnel-server/src/api/info.rs diff --git a/.gitignore b/.gitignore index 240de45..7b57287 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,14 @@ Thumbs.db # Logs *.log +# Binaries +/bin/ + +# Database +*.db +*.db-shm +*.db-wal + # Temporary *.tmp *.temp diff --git a/crates/funnel-client/src/api_client.rs b/crates/funnel-client/src/api_client.rs new file mode 100644 index 0000000..1369b0a --- /dev/null +++ b/crates/funnel-client/src/api_client.rs @@ -0,0 +1,62 @@ +use funnel_core::api::Endpoint; +use funnel_core::protocol::PROTOCOL_VERSION; +use serde::Serialize; +use serde::de::DeserializeOwned; + +pub struct ApiClient { + base_url: String, + http: reqwest::Client, + token: Option, +} + +impl ApiClient { + pub fn new(server_url: &str, token: Option) -> Self { + Self { + base_url: server_url.trim_end_matches('/').to_string(), + http: reqwest::Client::new(), + token, + } + } + + fn url(&self, path: &str) -> String { + format!("{}/api/v{PROTOCOL_VERSION}{path}", self.base_url) + } + + fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match self.token { + Some(ref token) => builder.header("authorization", format!("Bearer {token}")), + None => builder, + } + } + + pub async fn request( + &self, + endpoint: &Endpoint<(), Resp>, + ) -> anyhow::Result { + let url = self.url(endpoint.path); + let builder = self.http.request(endpoint.method.clone(), &url); + let resp = self.apply_auth(builder).send().await?; + handle_response(resp).await + } + + #[allow(dead_code)] + pub async fn request_with( + &self, + endpoint: &Endpoint, + body: &Req, + ) -> anyhow::Result { + let url = self.url(endpoint.path); + let builder = self.http.request(endpoint.method.clone(), &url).json(body); + let resp = self.apply_auth(builder).send().await?; + handle_response(resp).await + } +} + +async fn handle_response(resp: reqwest::Response) -> anyhow::Result { + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("server returned {status}: {body}"); + } + Ok(resp.json().await?) +} diff --git a/crates/funnel-client/src/auth.rs b/crates/funnel-client/src/cmd/auth.rs similarity index 93% rename from crates/funnel-client/src/auth.rs rename to crates/funnel-client/src/cmd/auth.rs index 68c6d53..64c79a7 100644 --- a/crates/funnel-client/src/auth.rs +++ b/crates/funnel-client/src/cmd/auth.rs @@ -17,6 +17,14 @@ struct CallbackParams { token: String, } +pub fn logout(ctx_override: Option<&str>) -> anyhow::Result<()> { + let cfg = config::load()?; + let name = ctx_override.unwrap_or(&cfg.current_context).to_string(); + config::clear_token(&name)?; + println!("logged out from context '{name}'"); + Ok(()) +} + pub async fn login(context_name: &str, provider: &str) -> anyhow::Result<()> { let cfg = config::load()?; let resolved = config::resolve(&cfg, Some(context_name))?; diff --git a/crates/funnel-client/src/cmd/config.rs b/crates/funnel-client/src/cmd/config.rs new file mode 100644 index 0000000..378cb61 --- /dev/null +++ b/crates/funnel-client/src/cmd/config.rs @@ -0,0 +1,25 @@ +use crate::config; + +#[derive(clap::Subcommand)] +pub enum Command { + /// show current configuration + Show, + /// print config file path + Path, +} + +pub fn run(command: &Command) -> anyhow::Result<()> { + match command { + Command::Show => { + let cfg = config::load()?; + let content = toml::to_string_pretty(&cfg)?; + println!("# {}\n", config::config_path().display()); + println!("{content}"); + Ok(()) + } + Command::Path => { + println!("{}", config::config_path().display()); + Ok(()) + } + } +} diff --git a/crates/funnel-client/src/cmd/context.rs b/crates/funnel-client/src/cmd/context.rs new file mode 100644 index 0000000..11dd104 --- /dev/null +++ b/crates/funnel-client/src/cmd/context.rs @@ -0,0 +1,70 @@ +use crate::config; + +#[derive(clap::Subcommand)] +pub enum Command { + /// list all contexts + List, + /// switch to a different context + Use { + /// context name to switch to + name: String, + }, + /// create a new context + Create { + /// context name + name: String, + /// server url + #[arg(long)] + server: String, + }, + /// delete a context + Delete { + /// context name to delete + name: String, + }, +} + +pub fn run(command: Command) -> anyhow::Result<()> { + match command { + Command::List => { + let cfg = config::load()?; + if cfg.contexts.is_empty() { + println!("no contexts configured"); + println!(" create one with: funnel context create --server "); + return Ok(()); + } + for (name, ctx) in &cfg.contexts { + let marker = if name == &cfg.current_context { + " *" + } else { + "" + }; + let token_status = if ctx.token.is_some() { + "authenticated" + } else { + "no token" + }; + println!("{name}{marker}"); + println!(" server: {}", ctx.server); + println!(" status: {token_status}"); + println!(); + } + Ok(()) + } + Command::Use { name } => { + config::set_current_context(&name)?; + println!("switched to context '{name}'"); + Ok(()) + } + Command::Create { name, server } => { + config::create_context(&name, &server)?; + println!("created context '{name}' ({server})"); + Ok(()) + } + Command::Delete { name } => { + config::delete_context(&name)?; + println!("deleted context '{name}'"); + Ok(()) + } + } +} diff --git a/crates/funnel-client/src/cmd/http.rs b/crates/funnel-client/src/cmd/http.rs new file mode 100644 index 0000000..21d7d17 --- /dev/null +++ b/crates/funnel-client/src/cmd/http.rs @@ -0,0 +1,141 @@ +use std::sync::Arc; + +use tokio_util::sync::CancellationToken; + +use funnel_core::tunnel::id::TunnelId; + +use crate::api_client; +use crate::config; +use crate::tunnel::{client::TunnelClient, display::TunnelDisplay, runner}; + +#[derive(clap::Args)] +pub struct Args { + /// local address or port to forward to (e.g. "3000" or "localhost:3000") + pub address: String, + + /// tunnel server url (overrides config) + #[arg(short, long)] + pub server: Option, + + /// tunnel id (subdomain), generated if omitted + #[arg(short, long)] + pub id: Option, + + /// authentication token (overrides config) + #[arg(short, long)] + pub token: Option, + + /// quic port on the server (overrides discovery) + #[arg(long)] + pub quic_port: Option, + + /// skip tls certificate verification (for development) + #[arg(long)] + pub insecure: bool, + + /// associate tunnel with a team + #[arg(long)] + pub team: Option, +} + +pub async fn run(ctx_override: Option<&str>, args: Args) -> anyhow::Result<()> { + let local_addr = normalize_address(&args.address); + + let cfg = config::load().unwrap_or_default(); + let resolved = config::resolve(&cfg, ctx_override).ok(); + + let server_url = args + .server + .or_else(|| resolved.as_ref().map(|r| r.server.clone())) + .filter(|s| !s.is_empty()) + .map(|s| { + if s.contains("://") { + s + } else { + format!("http://{s}") + } + }) + .ok_or_else(|| { + anyhow::anyhow!( + "no server configured. use --server or run: funnel context create default --server " + ) + })?; + + let token = args + .token + .or_else(|| resolved.as_ref().and_then(|r| r.token.clone())); + + let quic_port = if let Some(port) = args.quic_port { + port + } else { + let client = api_client::ApiClient::new(&server_url, token.clone()); + let info = client.request(&funnel_core::api::INFO).await?; + info.quic_port + }; + + let tunnel_id = match args.id { + Some(raw) => TunnelId::new(raw)?, + None => TunnelId::generate(), + }; + + let public_url = + runner::build_public_url(&server_url, &tunnel_id).unwrap_or_else(|| "".to_string()); + + println!("funnel\n"); + println!(" public url {public_url}"); + println!(" forwarding {local_addr}"); + println!(" tunnel id {tunnel_id}"); + if let Some(ref team_name) = args.team { + println!(" team {team_name}"); + } + println!(); + + let display = Arc::new(TunnelDisplay::new()); + + let client = TunnelClient::new( + tunnel_id, + &server_url, + local_addr, + token, + quic_port, + args.insecure, + args.team, + )?; + + let shutdown = CancellationToken::new(); + let shutdown_signal = shutdown.clone(); + + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + shutdown_signal.cancel(); + }); + + runner::run(&client, shutdown, &display).await; + + display.finish(); + + Ok(()) +} + +fn normalize_address(addr: &str) -> String { + if addr.contains(':') { + addr.to_string() + } else { + format!("localhost:{addr}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_port_only() { + assert_eq!(normalize_address("3000"), "localhost:3000"); + } + + #[test] + fn normalize_full_address() { + assert_eq!(normalize_address("127.0.0.1:8080"), "127.0.0.1:8080"); + } +} diff --git a/crates/funnel-client/src/keys.rs b/crates/funnel-client/src/cmd/keys.rs similarity index 86% rename from crates/funnel-client/src/keys.rs rename to crates/funnel-client/src/cmd/keys.rs index 2e12925..80e721d 100644 --- a/crates/funnel-client/src/keys.rs +++ b/crates/funnel-client/src/cmd/keys.rs @@ -1,6 +1,33 @@ use funnel_core::protocol::PROTOCOL_VERSION; use serde::{Deserialize, Serialize}; +#[derive(clap::Subcommand)] +pub enum Command { + /// list api keys + List, + /// create a new api key + Create { + /// name for the new key + name: String, + /// comma separated scopes (defaults to management,tunnels) + #[arg(long)] + scopes: Option, + }, + /// revoke an api key + Revoke { + /// key id to revoke + id: String, + }, +} + +pub async fn run(server: &str, token: &str, command: Command) -> anyhow::Result<()> { + match command { + Command::List => list(server, token).await, + Command::Create { name, scopes } => create(server, token, &name, scopes.as_deref()).await, + Command::Revoke { id } => revoke(server, token, &id).await, + } +} + #[derive(Deserialize)] struct ApiKeyView { id: String, diff --git a/crates/funnel-client/src/cmd/mod.rs b/crates/funnel-client/src/cmd/mod.rs new file mode 100644 index 0000000..d9aa70e --- /dev/null +++ b/crates/funnel-client/src/cmd/mod.rs @@ -0,0 +1,10 @@ +pub mod auth; +pub mod config; +pub mod context; +pub mod http; +pub mod keys; +pub mod sessions; +pub mod status; +pub mod teams; +pub mod users; +pub mod whoami; diff --git a/crates/funnel-client/src/sessions.rs b/crates/funnel-client/src/cmd/sessions.rs similarity index 100% rename from crates/funnel-client/src/sessions.rs rename to crates/funnel-client/src/cmd/sessions.rs diff --git a/crates/funnel-client/src/status.rs b/crates/funnel-client/src/cmd/status.rs similarity index 100% rename from crates/funnel-client/src/status.rs rename to crates/funnel-client/src/cmd/status.rs diff --git a/crates/funnel-client/src/teams.rs b/crates/funnel-client/src/cmd/teams.rs similarity index 77% rename from crates/funnel-client/src/teams.rs rename to crates/funnel-client/src/cmd/teams.rs index 248adb0..f0afb4a 100644 --- a/crates/funnel-client/src/teams.rs +++ b/crates/funnel-client/src/cmd/teams.rs @@ -1,6 +1,70 @@ use funnel_core::protocol::PROTOCOL_VERSION; use serde::{Deserialize, Serialize}; +#[derive(clap::Subcommand)] +pub enum Command { + /// list teams + List, + /// create a new team + Create { + /// team name + name: String, + }, + /// delete a team + Delete { + /// team id + id: String, + }, + /// list team members + Members { + /// team id + id: String, + }, + /// add a member to a team + AddMember { + /// team id + team_id: String, + /// user id to add + user_id: String, + }, + /// remove a member from a team + RemoveMember { + /// team id + team_id: String, + /// user id to remove + user_id: String, + }, + /// set a member's role in a team + SetRole { + /// team id + team_id: String, + /// user id + user_id: String, + /// role (owner or member) + role: String, + }, +} + +pub async fn run(server: &str, token: &str, command: Command) -> anyhow::Result<()> { + match command { + Command::List => list(server, token).await, + Command::Create { name } => create(server, token, &name).await, + Command::Delete { id } => delete(server, token, &id).await, + Command::Members { id } => members(server, token, &id).await, + Command::AddMember { team_id, user_id } => { + add_member(server, token, &team_id, &user_id).await + } + Command::RemoveMember { team_id, user_id } => { + remove_member(server, token, &team_id, &user_id).await + } + Command::SetRole { + team_id, + user_id, + role, + } => set_role(server, token, &team_id, &user_id, &role).await, + } +} + #[derive(Deserialize)] struct Team { id: String, diff --git a/crates/funnel-client/src/users.rs b/crates/funnel-client/src/cmd/users.rs similarity index 78% rename from crates/funnel-client/src/users.rs rename to crates/funnel-client/src/cmd/users.rs index 7dccd9a..42882a8 100644 --- a/crates/funnel-client/src/users.rs +++ b/crates/funnel-client/src/cmd/users.rs @@ -1,6 +1,42 @@ use funnel_core::protocol::PROTOCOL_VERSION; use serde::{Deserialize, Serialize}; +#[derive(clap::Subcommand)] +pub enum Command { + /// list all users + List { + /// maximum number of users to show + #[arg(long, default_value_t = 50)] + limit: u32, + }, + /// set a user's role + SetRole { + /// user id + id: String, + /// new role (admin or member) + role: String, + }, + /// deactivate a user + Deactivate { + /// user id + id: String, + }, + /// reactivate a user + Reactivate { + /// user id + id: String, + }, +} + +pub async fn run(server: &str, token: &str, command: Command) -> anyhow::Result<()> { + match command { + Command::List { limit } => list(server, token, limit).await, + Command::SetRole { id, role } => set_role(server, token, &id, &role).await, + Command::Deactivate { id } => deactivate(server, token, &id).await, + Command::Reactivate { id } => reactivate(server, token, &id).await, + } +} + #[derive(Deserialize)] struct User { id: String, diff --git a/crates/funnel-client/src/whoami.rs b/crates/funnel-client/src/cmd/whoami.rs similarity index 100% rename from crates/funnel-client/src/whoami.rs rename to crates/funnel-client/src/cmd/whoami.rs diff --git a/crates/funnel-client/src/config.rs b/crates/funnel-client/src/config.rs index fcaa088..286112a 100644 --- a/crates/funnel-client/src/config.rs +++ b/crates/funnel-client/src/config.rs @@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize}; const CONFIG_DIR: &str = "funnel"; const CONFIG_FILE: &str = "config.toml"; -pub const DEFAULT_QUIC_PORT: u16 = 4433; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FunnelConfig { @@ -24,39 +23,22 @@ impl Default for FunnelConfig { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Context { #[serde(default)] pub server: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub token: Option, - #[serde(default = "default_quic_port")] - pub quic_port: u16, -} - -impl Default for Context { - fn default() -> Self { - Self { - server: String::new(), - token: None, - quic_port: DEFAULT_QUIC_PORT, - } - } } fn default_context_name() -> String { "default".into() } -const fn default_quic_port() -> u16 { - DEFAULT_QUIC_PORT -} - pub struct ResolvedContext { pub name: String, pub server: String, pub token: Option, - pub quic_port: u16, } pub fn config_path() -> PathBuf { @@ -128,7 +110,6 @@ pub fn resolve( name: name.to_string(), server, token, - quic_port: ctx.quic_port, }) } @@ -173,7 +154,7 @@ pub fn set_current_context(name: &str) -> anyhow::Result<()> { save(&config) } -pub fn create_context(name: &str, server: &str, quic_port: Option) -> anyhow::Result<()> { +pub fn create_context(name: &str, server: &str) -> anyhow::Result<()> { let mut config = load()?; if config.contexts.contains_key(name) { anyhow::bail!("context '{name}' already exists"); @@ -182,7 +163,6 @@ pub fn create_context(name: &str, server: &str, quic_port: Option) -> anyho name.to_string(), Context { server: server.to_string(), - quic_port: quic_port.unwrap_or(DEFAULT_QUIC_PORT), ..Default::default() }, ); @@ -238,7 +218,6 @@ mod tests { let ctx = parsed.contexts.get("default").expect("default context"); assert_eq!(ctx.server, "https://tunnel.example.com"); assert_eq!(ctx.token.as_deref(), Some("fnl_test123")); - assert_eq!(ctx.quic_port, DEFAULT_QUIC_PORT); } #[test] diff --git a/crates/funnel-client/src/main.rs b/crates/funnel-client/src/main.rs index c5ab48f..9c8ee70 100644 --- a/crates/funnel-client/src/main.rs +++ b/crates/funnel-client/src/main.rs @@ -1,24 +1,11 @@ -mod auth; +mod api_client; +mod cmd; mod config; -mod display; -mod forwarder; -mod keys; -mod runner; -mod sessions; -mod status; -mod teams; mod tunnel; -mod users; -mod whoami; - -use std::sync::Arc; use clap::{Parser, Subcommand}; -use tokio_util::sync::CancellationToken; use tracing_subscriber::EnvFilter; -use funnel_core::tunnel::id::TunnelId; - #[derive(Parser)] #[command( name = "funnel", @@ -36,34 +23,7 @@ struct Cli { #[derive(Subcommand)] enum Command { /// create an http tunnel to a local service - Http { - /// local address or port to forward to (e.g. "3000" or "localhost:3000") - address: String, - - /// tunnel server url (overrides config) - #[arg(short, long)] - server: Option, - - /// tunnel id (subdomain), generated if omitted - #[arg(short, long)] - id: Option, - - /// authentication token (overrides config) - #[arg(short, long)] - token: Option, - - /// quic port on the server (overrides config) - #[arg(long)] - quic_port: Option, - - /// skip tls certificate verification (for development) - #[arg(long)] - insecure: bool, - - /// associate tunnel with a team - #[arg(long)] - team: Option, - }, + Http(cmd::http::Args), /// log in via oauth Login { /// oauth provider name @@ -79,7 +39,7 @@ enum Command { /// manage api keys Keys { #[command(subcommand)] - command: KeysCommand, + command: cmd::keys::Command, }, /// view tunnel sessions Sessions { @@ -94,150 +54,25 @@ enum Command { /// manage users (admin only) Users { #[command(subcommand)] - command: UsersCommand, + command: cmd::users::Command, }, /// manage teams Teams { #[command(subcommand)] - command: TeamsCommand, + command: cmd::teams::Command, }, /// manage server contexts Context { #[command(subcommand)] - command: ContextCommand, + command: cmd::context::Command, }, /// view configuration Config { #[command(subcommand)] - command: ConfigCommand, - }, -} - -#[derive(Subcommand)] -enum KeysCommand { - /// list api keys - List, - /// create a new api key - Create { - /// name for the new key - name: String, - /// comma separated scopes (defaults to management,tunnels) - #[arg(long)] - scopes: Option, - }, - /// revoke an api key - Revoke { - /// key id to revoke - id: String, - }, -} - -#[derive(Subcommand)] -enum UsersCommand { - /// list all users - List { - /// maximum number of users to show - #[arg(long, default_value_t = 50)] - limit: u32, - }, - /// set a user's role - SetRole { - /// user id - id: String, - /// new role (admin or member) - role: String, - }, - /// deactivate a user - Deactivate { - /// user id - id: String, - }, - /// reactivate a user - Reactivate { - /// user id - id: String, + command: cmd::config::Command, }, } -#[derive(Subcommand)] -enum TeamsCommand { - /// list teams - List, - /// create a new team - Create { - /// team name - name: String, - }, - /// delete a team - Delete { - /// team id - id: String, - }, - /// list team members - Members { - /// team id - id: String, - }, - /// add a member to a team - AddMember { - /// team id - team_id: String, - /// user id to add - user_id: String, - }, - /// remove a member from a team - RemoveMember { - /// team id - team_id: String, - /// user id to remove - user_id: String, - }, - /// set a member's role in a team - SetRole { - /// team id - team_id: String, - /// user id - user_id: String, - /// role (owner or member) - role: String, - }, -} - -#[derive(Subcommand)] -enum ContextCommand { - /// list all contexts - List, - /// switch to a different context - Use { - /// context name to switch to - name: String, - }, - /// create a new context - Create { - /// context name - name: String, - /// server url - #[arg(long)] - server: String, - /// quic port - #[arg(long)] - quic_port: Option, - }, - /// delete a context - Delete { - /// context name to delete - name: String, - }, -} - -#[derive(Subcommand)] -enum ConfigCommand { - /// show current configuration - Show, - /// print config file path - Path, -} - #[tokio::main] async fn main() -> anyhow::Result<()> { rustls::crypto::ring::default_provider() @@ -246,7 +81,7 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); - let default_filter = if matches!(cli.command, Command::Http { .. }) { + let default_filter = if matches!(cli.command, Command::Http(_)) { "error" } else { "info" @@ -258,290 +93,47 @@ async fn main() -> anyhow::Result<()> { ) .init(); - let ctx_override = cli.context.as_deref(); + let ctx = cli.context.as_deref(); match cli.command { - Command::Http { - address, - server, - id, - token, - quic_port, - insecure, - team, - } => { - run_http( - ctx_override, - address, - server, - id, - token, - quic_port, - insecure, - team, - ) - .await - } + Command::Http(args) => cmd::http::run(ctx, args).await, Command::Login { provider } => { let cfg = config::load()?; - let name = ctx_override.unwrap_or(&cfg.current_context).to_string(); - auth::login(&name, &provider).await - } - Command::Logout => { - let cfg = config::load()?; - let name = ctx_override.unwrap_or(&cfg.current_context).to_string(); - config::clear_token(&name)?; - println!("logged out from context '{name}'"); - Ok(()) + let name = ctx.unwrap_or(&cfg.current_context).to_string(); + cmd::auth::login(&name, &provider).await } + Command::Logout => cmd::auth::logout(ctx), Command::Whoami => { let cfg = config::load()?; - let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; - whoami::run(&resolved.server, &token, &resolved.name).await + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::whoami::run(&resolved.server, &token, &resolved.name).await } Command::Status => { let cfg = config::load()?; - let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; - status::run(&resolved.server, &token).await + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::status::run(&resolved.server, &token).await } Command::Keys { command } => { let cfg = config::load()?; - let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; - match command { - KeysCommand::List => keys::list(&resolved.server, &token).await, - KeysCommand::Create { name, scopes } => { - keys::create(&resolved.server, &token, &name, scopes.as_deref()).await - } - KeysCommand::Revoke { id } => keys::revoke(&resolved.server, &token, &id).await, - } + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::keys::run(&resolved.server, &token, command).await } Command::Sessions { all, limit } => { let cfg = config::load()?; - let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; - sessions::list(&resolved.server, &token, all, limit).await + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::sessions::list(&resolved.server, &token, all, limit).await } Command::Users { command } => { let cfg = config::load()?; - let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; - match command { - UsersCommand::List { limit } => { - users::list(&resolved.server, &token, limit).await - } - UsersCommand::SetRole { id, role } => { - users::set_role(&resolved.server, &token, &id, &role).await - } - UsersCommand::Deactivate { id } => { - users::deactivate(&resolved.server, &token, &id).await - } - UsersCommand::Reactivate { id } => { - users::reactivate(&resolved.server, &token, &id).await - } - } + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::users::run(&resolved.server, &token, command).await } Command::Teams { command } => { let cfg = config::load()?; - let (resolved, token) = config::resolve_authenticated(&cfg, ctx_override)?; - match command { - TeamsCommand::List => teams::list(&resolved.server, &token).await, - TeamsCommand::Create { name } => { - teams::create(&resolved.server, &token, &name).await - } - TeamsCommand::Delete { id } => { - teams::delete(&resolved.server, &token, &id).await - } - TeamsCommand::Members { id } => { - teams::members(&resolved.server, &token, &id).await - } - TeamsCommand::AddMember { team_id, user_id } => { - teams::add_member(&resolved.server, &token, &team_id, &user_id).await - } - TeamsCommand::RemoveMember { team_id, user_id } => { - teams::remove_member(&resolved.server, &token, &team_id, &user_id).await - } - TeamsCommand::SetRole { - team_id, - user_id, - role, - } => { - teams::set_role(&resolved.server, &token, &team_id, &user_id, &role).await - } - } + let (resolved, token) = config::resolve_authenticated(&cfg, ctx)?; + cmd::teams::run(&resolved.server, &token, command).await } - Command::Context { command } => run_context(command), - Command::Config { command } => run_config(&command), - } -} - -#[allow(clippy::fn_params_excessive_bools, clippy::too_many_arguments)] -async fn run_http( - ctx_override: Option<&str>, - address: String, - server_flag: Option, - id_flag: Option, - token_flag: Option, - quic_port_flag: Option, - insecure: bool, - team: Option, -) -> anyhow::Result<()> { - let local_addr = normalize_address(&address); - - let cfg = config::load().unwrap_or_default(); - let resolved = config::resolve(&cfg, ctx_override).ok(); - - let server_url = server_flag - .or_else(|| resolved.as_ref().map(|r| r.server.clone())) - .filter(|s| !s.is_empty()) - .map(|s| { - if s.contains("://") { - s - } else { - format!("http://{s}") - } - }) - .ok_or_else(|| { - anyhow::anyhow!( - "no server configured. use --server or run: funnel context create default --server " - ) - })?; - - let token = token_flag.or_else(|| resolved.as_ref().and_then(|r| r.token.clone())); - let quic_port = quic_port_flag.unwrap_or_else(|| { - resolved - .as_ref() - .map_or(config::DEFAULT_QUIC_PORT, |r| r.quic_port) - }); - - let tunnel_id = match id_flag { - Some(raw) => TunnelId::new(raw)?, - None => TunnelId::generate(), - }; - - let public_url = - runner::build_public_url(&server_url, &tunnel_id).unwrap_or_else(|| "".to_string()); - - println!("funnel\n"); - println!(" public url {public_url}"); - println!(" forwarding {local_addr}"); - println!(" tunnel id {tunnel_id}"); - if let Some(ref team_name) = team { - println!(" team {team_name}"); - } - println!(); - - let display = Arc::new(display::TunnelDisplay::new()); - - let client = tunnel::TunnelClient::new( - tunnel_id, - &server_url, - local_addr, - token, - quic_port, - insecure, - team, - )?; - - let shutdown = CancellationToken::new(); - let shutdown_signal = shutdown.clone(); - - tokio::spawn(async move { - tokio::signal::ctrl_c().await.ok(); - shutdown_signal.cancel(); - }); - - runner::run(&client, shutdown, &display).await; - - display.finish(); - - Ok(()) -} - -fn run_context(command: ContextCommand) -> anyhow::Result<()> { - match command { - ContextCommand::List => { - let cfg = config::load()?; - if cfg.contexts.is_empty() { - println!("no contexts configured"); - println!(" create one with: funnel context create --server "); - return Ok(()); - } - for (name, ctx) in &cfg.contexts { - let marker = if name == &cfg.current_context { - " *" - } else { - "" - }; - let token_status = if ctx.token.is_some() { - "authenticated" - } else { - "no token" - }; - println!("{name}{marker}"); - println!(" server: {}", ctx.server); - println!(" status: {token_status}"); - if ctx.quic_port != config::DEFAULT_QUIC_PORT { - println!(" quic: {}", ctx.quic_port); - } - println!(); - } - Ok(()) - } - ContextCommand::Use { name } => { - config::set_current_context(&name)?; - println!("switched to context '{name}'"); - Ok(()) - } - ContextCommand::Create { - name, - server, - quic_port, - } => { - config::create_context(&name, &server, quic_port)?; - println!("created context '{name}' ({server})"); - Ok(()) - } - ContextCommand::Delete { name } => { - config::delete_context(&name)?; - println!("deleted context '{name}'"); - Ok(()) - } - } -} - -fn run_config(command: &ConfigCommand) -> anyhow::Result<()> { - match command { - ConfigCommand::Show => { - let cfg = config::load()?; - let content = toml::to_string_pretty(&cfg)?; - println!("# {}\n", config::config_path().display()); - println!("{content}"); - Ok(()) - } - ConfigCommand::Path => { - println!("{}", config::config_path().display()); - Ok(()) - } - } -} - -fn normalize_address(addr: &str) -> String { - if addr.contains(':') { - addr.to_string() - } else { - format!("localhost:{addr}") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_port_only() { - assert_eq!(normalize_address("3000"), "localhost:3000"); - } - - #[test] - fn normalize_full_address() { - assert_eq!(normalize_address("127.0.0.1:8080"), "127.0.0.1:8080"); + Command::Context { command } => cmd::context::run(command), + Command::Config { command } => cmd::config::run(&command), } } diff --git a/crates/funnel-client/src/tunnel.rs b/crates/funnel-client/src/tunnel/client.rs similarity index 99% rename from crates/funnel-client/src/tunnel.rs rename to crates/funnel-client/src/tunnel/client.rs index 8c34549..127a4a9 100644 --- a/crates/funnel-client/src/tunnel.rs +++ b/crates/funnel-client/src/tunnel/client.rs @@ -16,8 +16,8 @@ use funnel_core::protocol::handshake::{Handshake, HandshakeResponse}; use funnel_core::protocol::request::RequestMeta; use funnel_core::tunnel::id::TunnelId; -use crate::display::{RequestResult, TunnelDisplay}; -use crate::forwarder::{ForwardResult, ForwardUpgradeResult, Forwarder}; +use super::display::{RequestResult, TunnelDisplay}; +use super::forwarder::{ForwardResult, ForwardUpgradeResult, Forwarder}; const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(15); const MAX_CONCURRENT_REQUESTS: usize = 128; diff --git a/crates/funnel-client/src/display.rs b/crates/funnel-client/src/tunnel/display.rs similarity index 100% rename from crates/funnel-client/src/display.rs rename to crates/funnel-client/src/tunnel/display.rs diff --git a/crates/funnel-client/src/forwarder.rs b/crates/funnel-client/src/tunnel/forwarder.rs similarity index 100% rename from crates/funnel-client/src/forwarder.rs rename to crates/funnel-client/src/tunnel/forwarder.rs diff --git a/crates/funnel-client/src/tunnel/mod.rs b/crates/funnel-client/src/tunnel/mod.rs new file mode 100644 index 0000000..d7e9e1b --- /dev/null +++ b/crates/funnel-client/src/tunnel/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod display; +pub mod forwarder; +pub mod runner; diff --git a/crates/funnel-client/src/runner.rs b/crates/funnel-client/src/tunnel/runner.rs similarity index 97% rename from crates/funnel-client/src/runner.rs rename to crates/funnel-client/src/tunnel/runner.rs index 25884d7..393db5c 100644 --- a/crates/funnel-client/src/runner.rs +++ b/crates/funnel-client/src/tunnel/runner.rs @@ -6,8 +6,8 @@ use url::Url; use funnel_core::tunnel::id::TunnelId; -use crate::display::TunnelDisplay; -use crate::tunnel::TunnelClient; +use super::client::TunnelClient; +use super::display::TunnelDisplay; const INITIAL_BACKOFF: Duration = Duration::from_secs(1); const MAX_BACKOFF: Duration = Duration::from_secs(30); diff --git a/crates/funnel-core/src/api/mod.rs b/crates/funnel-core/src/api/mod.rs new file mode 100644 index 0000000..8424531 --- /dev/null +++ b/crates/funnel-core/src/api/mod.rs @@ -0,0 +1,19 @@ +mod types; + +pub use types::*; + +use std::marker::PhantomData; + +use http::Method; + +pub struct Endpoint { + pub method: Method, + pub path: &'static str, + _marker: PhantomData Resp>, +} + +pub const INFO: Endpoint<(), ServerInfo> = Endpoint { + method: Method::GET, + path: "/info", + _marker: PhantomData, +}; diff --git a/crates/funnel-core/src/api/types.rs b/crates/funnel-core/src/api/types.rs new file mode 100644 index 0000000..fc51a1e --- /dev/null +++ b/crates/funnel-core/src/api/types.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerInfo { + pub version: u32, + pub quic_port: u16, +} diff --git a/crates/funnel-core/src/lib.rs b/crates/funnel-core/src/lib.rs index 6ae79bf..927ad3d 100644 --- a/crates/funnel-core/src/lib.rs +++ b/crates/funnel-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod api; pub mod auth; pub mod protocol; pub mod tunnel; diff --git a/crates/funnel-e2e/tests/e2e/auth.rs b/crates/funnel-e2e/tests/e2e/auth.rs index 199fc9a..2ad64df 100644 --- a/crates/funnel-e2e/tests/e2e/auth.rs +++ b/crates/funnel-e2e/tests/e2e/auth.rs @@ -9,6 +9,7 @@ type TestResult = Result<(), Box>; struct AuthTestEnv { server_process: Child, server_log: PathBuf, + turso_db_path: PathBuf, http_port: u16, seed_key: String, client: reqwest::Client, @@ -18,6 +19,11 @@ impl AuthTestEnv { async fn start() -> Result> { let http_port = free_port()?; let quic_port = free_port()?; + let turso_db_path = std::env::temp_dir().join(format!( + "funnel-auth-e2e-{}-{}.db", + std::process::id(), + http_port, + )); let (stderr_file, log_path) = log_file("auth-server")?; @@ -29,6 +35,8 @@ impl AuthTestEnv { &quic_port.to_string(), "--host", "127.0.0.1", + "--turso-db-path", + &turso_db_path.to_string_lossy(), "--seed-api-key", ]) .stdout(Stdio::piped()) @@ -46,6 +54,7 @@ impl AuthTestEnv { Ok(Self { server_process: child, server_log: log_path, + turso_db_path, http_port, seed_key, client: reqwest::Client::new(), @@ -79,6 +88,7 @@ impl Drop for AuthTestEnv { } let _ = std::fs::remove_file(&self.server_log); + let _ = std::fs::remove_file(&self.turso_db_path); } } @@ -86,12 +96,12 @@ impl Drop for AuthTestEnv { async fn keys_require_auth() -> TestResult { let env = AuthTestEnv::start().await?; - let get = env.client.get(env.url("/api/keys")).send().await?; + let get = env.client.get(env.url("/api/v1/keys")).send().await?; assert_eq!(get.status(), 401); let post = env .client - .post(env.url("/api/keys")) + .post(env.url("/api/v1/keys")) .json(&serde_json::json!({"name": "test"})) .send() .await?; @@ -99,7 +109,7 @@ async fn keys_require_auth() -> TestResult { let delete = env .client - .delete(env.url("/api/keys/00000000-0000-0000-0000-000000000000")) + .delete(env.url("/api/v1/keys/00000000-0000-0000-0000-000000000000")) .send() .await?; assert_eq!(delete.status(), 401); @@ -113,7 +123,7 @@ async fn invalid_bearer_token_rejected() -> TestResult { let resp = env .client - .get(env.url("/api/keys")) + .get(env.url("/api/v1/keys")) .header("authorization", "Bearer fnl_bogustoken1234567890") .send() .await?; @@ -128,7 +138,7 @@ async fn create_and_list_keys() -> TestResult { let resp = env .client - .post(env.url("/api/keys")) + .post(env.url("/api/v1/keys")) .header("authorization", env.auth_header()) .json(&serde_json::json!({"name": "my-key"})) .send() @@ -141,7 +151,7 @@ async fn create_and_list_keys() -> TestResult { // seed key + new key let resp = env .client - .get(env.url("/api/keys")) + .get(env.url("/api/v1/keys")) .header("authorization", env.auth_header()) .send() .await?; @@ -158,7 +168,7 @@ async fn revoke_key() -> TestResult { let resp = env .client - .post(env.url("/api/keys")) + .post(env.url("/api/v1/keys")) .header("authorization", env.auth_header()) .json(&serde_json::json!({"name": "to-revoke"})) .send() @@ -169,7 +179,7 @@ async fn revoke_key() -> TestResult { let resp = env .client - .delete(env.url(&format!("/api/keys/{key_id}"))) + .delete(env.url(&format!("/api/v1/keys/{key_id}"))) .header("authorization", env.auth_header()) .send() .await?; @@ -180,7 +190,7 @@ async fn revoke_key() -> TestResult { // only seed key remains let resp = env .client - .get(env.url("/api/keys")) + .get(env.url("/api/v1/keys")) .header("authorization", env.auth_header()) .send() .await?; @@ -197,7 +207,7 @@ async fn revoked_key_cannot_authenticate() -> TestResult { // create a second key let resp = env .client - .post(env.url("/api/keys")) + .post(env.url("/api/v1/keys")) .header("authorization", env.auth_header()) .json(&serde_json::json!({"name": "ephemeral"})) .send() @@ -209,7 +219,7 @@ async fn revoked_key_cannot_authenticate() -> TestResult { // verify it works let resp = env .client - .get(env.url("/api/keys")) + .get(env.url("/api/v1/keys")) .header("authorization", format!("Bearer {new_key}")) .send() .await?; @@ -217,7 +227,7 @@ async fn revoked_key_cannot_authenticate() -> TestResult { // revoke it env.client - .delete(env.url(&format!("/api/keys/{new_key_id}"))) + .delete(env.url(&format!("/api/v1/keys/{new_key_id}"))) .header("authorization", env.auth_header()) .send() .await?; @@ -225,7 +235,7 @@ async fn revoked_key_cannot_authenticate() -> TestResult { // revoked key should be rejected let resp = env .client - .get(env.url("/api/keys")) + .get(env.url("/api/v1/keys")) .header("authorization", format!("Bearer {new_key}")) .send() .await?; @@ -238,7 +248,7 @@ async fn revoked_key_cannot_authenticate() -> TestResult { async fn me_requires_auth() -> TestResult { let env = AuthTestEnv::start().await?; - let resp = env.client.get(env.url("/api/me")).send().await?; + let resp = env.client.get(env.url("/api/v1/me")).send().await?; assert_eq!(resp.status(), 401); Ok(()) @@ -251,7 +261,7 @@ async fn me_returns_seed_user() -> TestResult { // seed key creates a proper system user let resp = env .client - .get(env.url("/api/me")) + .get(env.url("/api/v1/me")) .header("authorization", env.auth_header()) .send() .await?; @@ -269,7 +279,7 @@ async fn oauth_authorize_returns_404_when_not_configured() -> TestResult { let resp = env .client - .get(env.url("/auth/github/authorize?cli_port=9999")) + .get(env.url("/auth/v1/github/authorize?cli_port=9999")) .send() .await?; assert_eq!(resp.status(), 404); @@ -286,7 +296,7 @@ async fn oauth_callback_returns_404_when_not_configured() -> TestResult { let resp = env .client - .get(env.url("/auth/github/callback?code=test&state=test")) + .get(env.url("/auth/v1/github/callback?code=test&state=test")) .send() .await?; assert_eq!(resp.status(), 404); diff --git a/crates/funnel-e2e/tests/e2e/harness.rs b/crates/funnel-e2e/tests/e2e/harness.rs index f82bf2b..9705a71 100644 --- a/crates/funnel-e2e/tests/e2e/harness.rs +++ b/crates/funnel-e2e/tests/e2e/harness.rs @@ -39,8 +39,9 @@ impl TestEnv { let quic_port = free_port()?; let host_header = format!("{TUNNEL_ID}.localhost:{http_port}"); let turso_db_path = std::env::temp_dir().join(format!( - "funnel-e2e-{}.db", - std::process::id() + "funnel-e2e-{}-{}.db", + std::process::id(), + http_port, )); let (server_process, server_log, seed_key) = @@ -48,7 +49,7 @@ impl TestEnv { wait_for_tcp(http_port).await; let (client_process, client_log) = - start_client_process(local_port, http_port, quic_port, TUNNEL_ID, &seed_key)?; + start_client_process(local_port, http_port, TUNNEL_ID, &seed_key)?; let client = reqwest::Client::new(); wait_for_tunnel(&client, http_port, &host_header).await; @@ -236,7 +237,6 @@ fn start_server_process( fn start_client_process( local_port: u16, server_http_port: u16, - quic_port: u16, tunnel_id: &str, token: &str, ) -> Result<(Child, PathBuf), Box> { @@ -250,8 +250,6 @@ fn start_client_process( &format!("http://127.0.0.1:{server_http_port}"), "--id", tunnel_id, - "--quic-port", - &quic_port.to_string(), "--insecure", "--token", token, diff --git a/crates/funnel-e2e/tests/e2e/main.rs b/crates/funnel-e2e/tests/e2e/main.rs index 07d0f34..f1ca647 100644 --- a/crates/funnel-e2e/tests/e2e/main.rs +++ b/crates/funnel-e2e/tests/e2e/main.rs @@ -93,7 +93,7 @@ async fn metrics_endpoint() -> TestResult { let resp = env .client - .get(env.server_url("/api/metrics")) + .get(env.server_url("/api/v1/metrics")) .send() .await?; diff --git a/crates/funnel-server/src/api/info.rs b/crates/funnel-server/src/api/info.rs new file mode 100644 index 0000000..0e0bdfb --- /dev/null +++ b/crates/funnel-server/src/api/info.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use axum::Json; +use axum::extract::State; + +use funnel_core::api::ServerInfo; +use funnel_core::protocol::PROTOCOL_VERSION; + +use crate::app::AppState; + +pub async fn handler(State(state): State>) -> Json { + Json(ServerInfo { + version: PROTOCOL_VERSION, + quic_port: state.quic_port, + }) +} diff --git a/crates/funnel-server/src/api/mod.rs b/crates/funnel-server/src/api/mod.rs index 3fe1eb8..489144e 100644 --- a/crates/funnel-server/src/api/mod.rs +++ b/crates/funnel-server/src/api/mod.rs @@ -1,5 +1,6 @@ pub mod accounts; pub mod health; +pub mod info; pub mod keys; pub mod me; pub mod oauth; diff --git a/crates/funnel-server/src/api/oauth.rs b/crates/funnel-server/src/api/oauth.rs index 4b6a240..b4f2e35 100644 --- a/crates/funnel-server/src/api/oauth.rs +++ b/crates/funnel-server/src/api/oauth.rs @@ -9,6 +9,7 @@ use crate::app::AppState; use crate::db::accounts::NewAccount; use crate::db::api_keys::default_scopes; use crate::db::users::NewUser; +use funnel_core::protocol::PROTOCOL_VERSION; #[derive(Deserialize)] pub struct AuthorizeParams { @@ -57,7 +58,7 @@ pub async fn authorize( oauth.insert_pending(token.clone(), params.cli_port); - let redirect_uri = format!("{}/auth/{}/callback", oauth.base_url, provider); + let redirect_uri = format!("{}/auth/v{PROTOCOL_VERSION}/{}/callback", oauth.base_url, provider); let url = p.authorize_url(&redirect_uri, &token); Redirect::temporary(&url).into_response() @@ -83,7 +84,7 @@ pub async fn callback( return html_error(StatusCode::BAD_REQUEST, "invalid or expired state"); }; - let redirect_uri = format!("{}/auth/{}/callback", oauth.base_url, provider); + let redirect_uri = format!("{}/auth/v{PROTOCOL_VERSION}/{}/callback", oauth.base_url, provider); let access_token = match p.exchange_code(¶ms.code, &redirect_uri).await { Ok(t) => t, @@ -347,6 +348,7 @@ mod tests { is_tls: false, oauth_state, initial_admin_email: None, + quic_port: 4433, }) } diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs index 46e70c0..adbe9b2 100644 --- a/crates/funnel-server/src/app.rs +++ b/crates/funnel-server/src/app.rs @@ -37,11 +37,13 @@ pub struct AppState { pub is_tls: bool, pub oauth_state: Option>, pub initial_admin_email: Option, + pub quic_port: u16, } pub fn build_router(state: Arc, metrics_handle: PrometheusHandle) -> Router { let api_routes = Router::new() .route("/health", get(api::health::handler)) + .route("/info", get(api::info::handler)) .route("/tunnels", get(api::tunnels::list)) .route( "/tunnels/{id}", diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index 7bf3358..233686c 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -264,6 +264,7 @@ async fn build_state( is_tls: bool, oauth_state: Option>, initial_admin_email: Option, + quic_port: u16, ) -> anyhow::Result> { let tunnels = Arc::new(TunnelManager::new()); @@ -281,6 +282,7 @@ async fn build_state( is_tls, oauth_state, initial_admin_email, + quic_port, })) } else { let db = store::turso::open(turso_db_path) @@ -308,6 +310,7 @@ async fn build_state( is_tls, oauth_state, initial_admin_email, + quic_port, })) } } @@ -316,7 +319,7 @@ async fn run_plain(cli: Cli, pool: Option) -> anyhow::Result<()> { let metrics_handle = metrics::setup()?; let oauth_state = build_oauth_state(&cli)?; let initial_admin_email = cli.initial_admin_email.clone(); - let state = build_state(pool, &cli.turso_db_path, false, oauth_state, initial_admin_email).await?; + let state = build_state(pool, &cli.turso_db_path, false, oauth_state, initial_admin_email, cli.quic_port).await?; let router = app::build_router(Arc::clone(&state), metrics_handle); if cli.seed_api_key { @@ -411,7 +414,7 @@ async fn run_with_tls(cli: Cli, pool: Option) -> anyhow::Result<() let metrics_handle = metrics::setup()?; let oauth_state = build_oauth_state(&cli)?; let initial_admin_email = cli.initial_admin_email.clone(); - let state = build_state(pool, &cli.turso_db_path, true, oauth_state, initial_admin_email).await?; + let state = build_state(pool, &cli.turso_db_path, true, oauth_state, initial_admin_email, cli.quic_port).await?; let router = app::build_router(Arc::clone(&state), metrics_handle); if cli.seed_api_key { From 5487ba01e1d62475c19e1ab9ee71ddc7258dd1ed Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Mon, 11 May 2026 16:05:49 +0200 Subject: [PATCH 34/42] feat(nix): add flake-parts packaging, OCI containers, NixOS/Home Manager modules, VM tests, and attic binary cache --- README.md | 269 ++++++++++++++++++---- crates/funnel-server/src/main.rs | 2 +- flake.lock | 50 ++++ flake.nix | 50 ++-- justfile | 36 +++ nix/containers.nix | 99 ++++++++ nix/home-manager/module.nix | 131 +++++++++++ nix/nixos/module.nix | 380 +++++++++++++++++++++++++++++++ nix/packages.nix | 75 ++++++ nix/parts/checks.nix | 26 +++ nix/parts/containers.nix | 16 ++ nix/parts/default.nix | 10 + nix/parts/dev-shells.nix | 9 + nix/parts/modules.nix | 18 ++ nix/parts/packages.nix | 8 + nix/parts/toolchain.nix | 13 ++ nix/tests/server.nix | 95 ++++++++ 17 files changed, 1205 insertions(+), 82 deletions(-) create mode 100644 nix/containers.nix create mode 100644 nix/home-manager/module.nix create mode 100644 nix/nixos/module.nix create mode 100644 nix/packages.nix create mode 100644 nix/parts/checks.nix create mode 100644 nix/parts/containers.nix create mode 100644 nix/parts/default.nix create mode 100644 nix/parts/dev-shells.nix create mode 100644 nix/parts/modules.nix create mode 100644 nix/parts/packages.nix create mode 100644 nix/parts/toolchain.nix create mode 100644 nix/tests/server.nix diff --git a/README.md b/README.md index b01a524..947dfcb 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,259 @@ -
- # funnel -a tunneling solution built with go +a self-hosted tunneling server and client. run the server on your own infrastructure, point the client at it, and expose local services through secure QUIC tunnels. + +## server + +```bash +funnel-server --port 8080 --quic-port 4433 +``` + +by default the server uses an embedded turso/libsql database at `./funnel.db`. pass `--database-url` to use postgres instead. migrations run on startup. -expose local services to the internet through websocket connections +every cli flag has a corresponding environment variable. a docker image is also available. -[documentation](https://funnel.karolbroda.com/docs) | [installation](https://funnel.karolbroda.com/docs/getting-started/installation) | [roadmap](https://funnel.karolbroda.com/docs/roadmap) +### auth -
+bootstrap with a seed key: -## overview +```bash +funnel-server --seed-api-key +``` -funnel creates tunnels from a public server to your local machine using websockets. when requests arrive at the server, they get proxied through to your local service. useful for development, testing, webhooks, and demos. +prints an api key to stdout. use it to create additional keys or configure oauth. -## quick start +github oauth: + +```bash +funnel-server \ + --base-url https://tunnel.company.com \ + --github-client-id $GITHUB_CLIENT_ID \ + --github-client-secret $GITHUB_CLIENT_SECRET +``` + +any OIDC-compatible provider works through the generic oauth flags (`--oauth-provider-name`, `--oauth-client-id`, etc). both can run simultaneously. + +the first user to log in is promoted to admin. `--initial-admin-email` controls subsequent auto-promotion. + +api keys have scopes: `tunnels` for tunnel connections, `management` for api access. + +### tls + +```bash +funnel-server \ + --enable-tls \ + --letsencrypt-email you@example.com \ + --dns-providers-config providers.json +``` + +certificates are obtained through let's encrypt DNS-01 challenges and auto-renew 30 days before expiry. dns providers: cloudflare, route53, or an exec provider that delegates to a script. + +## client ```bash curl -sSfL https://raw.githubusercontent.com/karol-broda/funnel/master/scripts/install.sh | bash ``` +linux and macos, amd64 and arm64. the script verifies checksums before installing. + +```bash +cargo install --path crates/funnel-client +``` + +### tunnel + +```bash +funnel http 3000 +``` + +connects to the server, registers a tunnel, and forwards requests to `localhost:3000`. the public url is printed on connect. + +```bash +funnel http 3000 --id my-app +funnel http 3000 --id staging --team backend +``` + +`--id` sets the subdomain. `--team` associates the tunnel with a team. + +each http request runs on its own QUIC stream. websocket upgrades are forwarded as bidirectional byte streams. + +### contexts + +the client supports named contexts for connecting to multiple servers. + +```bash +funnel context create work --server https://tunnel.company.com +funnel context use work +funnel login +``` + +tokens are stored in `~/.config/funnel/config.toml` with `0600` permissions. `FUNNEL_SERVER` and `FUNNEL_TOKEN` override the active context. + +### management + ```bash -funnel http 3000 --server http://localhost:8080 --id my-tunnel +funnel status # active tunnels +funnel whoami # current user +funnel keys create --scopes tunnels # create api key +funnel keys list # list api keys +funnel sessions # tunnel session history +funnel users list # all users (admin) +funnel users set-role admin # set role (admin) +funnel teams create backend # create team (admin) +funnel teams add-member # add team member ``` -your service is now available at `http://my-tunnel.localhost:8080`. +## nix + +the flake provides packages, OCI containers, a NixOS module, and a Home Manager module. a binary cache is available so you don't have to build from source. -see the [installation guide](https://funnel.karolbroda.com/docs/getting-started/installation) for more options. +### binary cache -## how it works +```nix +# flake.nix (or accept the nixConfig prompt when first using the flake) +nix.settings = { + substituters = ["https://cache.karolbroda.com/funnel"]; + trusted-public-keys = ["funnel:f2v8GXhe4t/c5ITHJ/LRYqcen5RWsYNEa9BKvxxxVBA="]; +}; +``` + +### packages -```mermaid -graph LR - A["local app
:3000"] --> B["funnel client"] - B -.websocket.-> C["funnel server
:8080"] - D["browser/curl"] --> E["tunnel-id.localhost:8080"] - E --> C - C -.-> B - B --> A - - style A fill:#b7bdf8,color:#24273a - style B fill:#f5bde6,color:#24273a - style C fill:#8bd5ca,color:#24273a - style D fill:#a6da95,color:#24273a - style E fill:#91d7e3,color:#24273a +```bash +nix build .#funnel-server +nix build .#funnel-client +nix run .#funnel-client -- http 3000 ``` -the client connects to the server via websocket and forwards http requests to your local app. the server routes requests based on subdomain (`tunnel-id.server:port`). +### OCI containers -more in the [architecture docs](https://funnel.karolbroda.com/docs/core-concepts/architecture). +```bash +nix build .#funnel-server-image +docker load < result +docker run -p 8080:8080 -p 4433:4433/udp funnel-server:latest +``` -## features +the images are built from nix, not from a Dockerfile. they contain only the binary, ca certificates, and timezone data. no shell, no package manager. set `DATABASE_URL` at runtime for postgres, otherwise the embedded turso database is used. + +### NixOS module + +```nix +{ + inputs.funnel.url = "github:karol-broda/funnel"; + + # configuration.nix + imports = [inputs.funnel.nixosModules.funnel-server]; + + services.funnel.server = { + enable = true; + port = 8080; + quicPort = 4433; + openFirewall = true; + + # secrets via environment file (agenix, sops-nix, or plain file) + environmentFile = config.age.secrets.funnel.path; + + tls = { + enable = true; + port = 443; + acme.email = "admin@example.com"; + dnsProvidersConfigFile = ./dns-providers.json; + }; + + auth = { + baseUrl = "https://tunnel.example.com"; + github.clientId = "Iv1.abc123"; + initialAdminEmail = "admin@example.com"; + }; + + # auto-provision a local postgres database + database.createLocally = true; + }; +} +``` -http tunneling, custom tunnel ids, auto-reconnection with exponential backoff, cross-platform (linux/macos/windows, amd64/arm64), tls via let's encrypt, api key authentication, rest api for management. +the systemd service runs with `DynamicUser`, a private `/tmp`, restricted syscalls, and the full set of hardening options. secrets go in an `environmentFile` rather than the nix store. -## docs +### Home Manager module -- [getting started](https://funnel.karolbroda.com/docs/getting-started/installation) -- [architecture](https://funnel.karolbroda.com/docs/core-concepts/architecture) -- [client cli](https://funnel.karolbroda.com/docs/reference/client-cli) -- [server cli](https://funnel.karolbroda.com/docs/reference/server-cli) -- [server api](https://funnel.karolbroda.com/docs/reference/server-api) -- [deployment](https://funnel.karolbroda.com/docs/server/deployment) -- [security](https://funnel.karolbroda.com/docs/server/security) +```nix +{ + inputs.funnel.url = "github:karol-broda/funnel"; + + imports = [inputs.funnel.homeManagerModules.funnel-client]; + + programs.funnel = { + enable = true; + settings = { + currentContext = "production"; + contexts = { + production.server = "https://tunnel.example.com"; + staging.server = "https://tunnel.staging.example.com"; + }; + }; + }; +} +``` + +when no `tokenFile` is used, the config is a read-only symlink to the nix store. when any context sets `tokenFile`, the config is generated at activation time as a regular file with `0600` permissions, so tokens never enter the store. + +you can also skip declarative token management entirely and set `FUNNEL_TOKEN` in your shell environment, or leave contexts empty and use `funnel login`. + +with sops-nix: + +```nix +sops.secrets."funnel/production-token" = {}; +sops.secrets."funnel/staging-token" = {}; + +programs.funnel = { + enable = true; + settings.contexts = { + production = { + server = "https://tunnel.example.com"; + tokenFile = config.sops.secrets."funnel/production-token".path; + }; + staging = { + server = "https://tunnel.staging.example.com"; + tokenFile = config.sops.secrets."funnel/staging-token".path; + }; + }; +}; +``` + +### overlay + +```nix +nixpkgs.overlays = [inputs.funnel.overlays.default]; +environment.systemPackages = [pkgs.funnel-client]; +``` ## development ```bash -git clone https://github.com/karol-broda/funnel.git -cd funnel -make dev-setup -make build -make test +nix develop +just build +just test +just e2e ``` -`make help` for all commands. +e2e tests start a real server and client, create a tunnel, and send http requests, websocket messages, and large payloads through it. -## contributing +NixOS VM tests run a full server in a QEMU VM and verify the health endpoint, authentication, systemd hardening, restart behavior, and client connectivity: -fork, branch, `make dev-setup`, make changes, `make build && make test`, `make fmt && make lint`, open a pr. +```bash +nix build .#checks.x86_64-linux.nixos-server +``` + +### nix recipes + +```bash +just nix-build # build server + client +just nix-images # build OCI container images +just nix-check # run clippy, fmt, and VM tests +just nix-push # push packages to the attic binary cache +just nix-push-images # push container images to the cache +just nix-docker-load # load a container image into docker +``` ## license diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index 233686c..9365dc4 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -382,7 +382,7 @@ async fn create_seed_key(state: &Arc) -> anyhow::Result<()> { .await { Ok((plaintext, _)) => { - println!("{plaintext}"); + tracing::info!(seed_api_key = %plaintext, "seed api key created"); } Err(store::StoreError::Conflict(_)) => { tracing::info!("seed key already exists, skipping"); diff --git a/flake.lock b/flake.lock index 53d7077..7fb7722 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,38 @@ { "nodes": { + "crane": { + "locked": { + "lastModified": 1778106249, + "narHash": "sha256-cM/AuKy5tMhwOOQIbha8ZRRMHVfNf7cv2aljIw+qoCg=", + "owner": "ipetkov", + "repo": "crane", + "rev": "6d015ea29630b7ad2402841386da2cb617a470a7", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1777988971, + "narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1766399428, @@ -16,8 +49,25 @@ "type": "github" } }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1777168982, + "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, "root": { "inputs": { + "crane": "crane", + "flake-parts": "flake-parts", "nixpkgs": "nixpkgs", "rust-overlay": "rust-overlay", "systems": "systems" diff --git a/flake.nix b/flake.nix index 7d5fec3..e84395a 100644 --- a/flake.nix +++ b/flake.nix @@ -1,51 +1,33 @@ { - description = "rust development flake"; + description = "funnel | expose local services through secure tunnels"; + + nixConfig = { + extra-substituters = ["https://cache.karolbroda.com/funnel"]; + extra-trusted-public-keys = ["funnel:f2v8GXhe4t/c5ITHJ/LRYqcen5RWsYNEa9BKvxxxVBA="]; + }; inputs = { nixpkgs = { url = "github:NixOS/nixpkgs/nixos-25.05"; }; + flake-parts = { + url = "github:hercules-ci/flake-parts"; + }; rust-overlay = { url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; + crane = { + url = "github:ipetkov/crane"; + }; systems = { url = "github:nix-systems/default"; }; }; - outputs = { - self, - nixpkgs, - rust-overlay, - systems, - }: let - supportedSystems = import systems; - forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system); - - mkPkgs = system: - import nixpkgs { - inherit system; - overlays = [ - rust-overlay.overlays.default - ]; - }; - - mkModules = system: - import ./nix { - pkgs = mkPkgs system; - }; - in { - devShells = forAllSystems ( - system: let - modules = mkModules system; - in { - inherit (modules.shells) default nightly wasm; - } - ); - - overlays = { - default = rust-overlay.overlays.default; + outputs = inputs @ {flake-parts, ...}: + flake-parts.lib.mkFlake {inherit inputs;} { + systems = import inputs.systems; + imports = [./nix/parts]; }; - }; } diff --git a/justfile b/justfile index 551ab5f..c95ebe6 100644 --- a/justfile +++ b/justfile @@ -47,3 +47,39 @@ audit: # find unused dependencies machete: cargo machete + +# build nix packages +nix-build: + nix build .#funnel-server .#funnel-client + +# build oci container images +nix-images: + nix build .#funnel-server-image .#funnel-client-image + +# run nix checks (clippy, fmt, nixos vm test) +nix-check: + nix flake check + +# push all outputs to the attic binary cache +nix-push: + #!/usr/bin/env bash + set -euo pipefail + echo "building and pushing funnel-server..." + nix build .#funnel-server --no-link --print-out-paths | xargs attic push funnel + echo "building and pushing funnel-client..." + nix build .#funnel-client --no-link --print-out-paths | xargs attic push funnel + echo "done" + +# push container images to attic binary cache +nix-push-images: + #!/usr/bin/env bash + set -euo pipefail + echo "building and pushing funnel-server-image..." + nix build .#funnel-server-image --no-link --print-out-paths | xargs attic push funnel + echo "building and pushing funnel-client-image..." + nix build .#funnel-client-image --no-link --print-out-paths | xargs attic push funnel + echo "done" + +# load a container image into docker +nix-docker-load target="funnel-server-image": + nix build .#{{ target }} && docker load < result diff --git a/nix/containers.nix b/nix/containers.nix new file mode 100644 index 0000000..97121e4 --- /dev/null +++ b/nix/containers.nix @@ -0,0 +1,99 @@ +{ + pkgs, + packages, +}: let + user = "funnel"; + uid = "1000"; + gid = "1000"; + + passwdFile = pkgs.writeTextDir "etc/passwd" '' + root:x:0:0:root:/root:/bin/false + ${user}:x:${uid}:${gid}:${user}:/home/${user}:/bin/false + ''; + + groupFile = pkgs.writeTextDir "etc/group" '' + root:x:0: + ${user}:x:${gid}: + ''; + + nssSwitchFile = pkgs.writeTextDir "etc/nsswitch.conf" '' + hosts: files dns + ''; + + dataDir = pkgs.runCommand "funnel-data-dirs" {} '' + mkdir -p $out/var/lib/funnel/certs + mkdir -p $out/tmp + ''; + + homeDir = pkgs.runCommand "funnel-home-dirs" {} '' + mkdir -p $out/home/${user}/.config/funnel + ''; +in { + funnel-server-image = pkgs.dockerTools.buildLayeredImage { + name = "funnel-server"; + tag = "latest"; + + contents = [ + packages.funnel-server + pkgs.cacert + pkgs.tzdata + passwdFile + groupFile + nssSwitchFile + dataDir + ]; + + config = { + Cmd = ["${packages.funnel-server}/bin/funnel-server"]; + User = "${uid}:${gid}"; + WorkingDir = "/var/lib/funnel"; + ExposedPorts = { + "8080/tcp" = {}; + "8443/tcp" = {}; + "4433/udp" = {}; + }; + Env = [ + "FUNNEL_HOST=0.0.0.0" + "FUNNEL_PORT=8080" + "FUNNEL_TLS_PORT=8443" + "FUNNEL_QUIC_PORT=4433" + "FUNNEL_CERT_DIR=/var/lib/funnel/certs" + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + "TZDIR=${pkgs.tzdata}/share/zoneinfo" + ]; + Volumes = { + "/var/lib/funnel" = {}; + }; + Labels = { + "org.opencontainers.image.source" = "https://github.com/karol-broda/funnel"; + "org.opencontainers.image.description" = "Funnel tunnel server"; + }; + }; + }; + + funnel-client-image = pkgs.dockerTools.buildLayeredImage { + name = "funnel-client"; + tag = "latest"; + + contents = [ + packages.funnel-client + pkgs.cacert + passwdFile + groupFile + nssSwitchFile + homeDir + ]; + + config = { + Entrypoint = ["${packages.funnel-client}/bin/funnel"]; + User = "${uid}:${gid}"; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + ]; + Labels = { + "org.opencontainers.image.source" = "https://github.com/karol-broda/funnel"; + "org.opencontainers.image.description" = "Funnel tunnel client"; + }; + }; + }; +} diff --git a/nix/home-manager/module.nix b/nix/home-manager/module.nix new file mode 100644 index 0000000..d5d511b --- /dev/null +++ b/nix/home-manager/module.nix @@ -0,0 +1,131 @@ +self: { + config, + lib, + pkgs, + ... +}: let + cfg = config.programs.funnel; + tomlFormat = pkgs.formats.toml {}; + + hasContexts = cfg.settings.contexts != {}; + hasTokenFiles = lib.any (ctx: ctx.tokenFile != null) (lib.attrValues cfg.settings.contexts); + + staticConfigContents = + { + current_context = cfg.settings.currentContext; + } + // lib.optionalAttrs hasContexts { + contexts = lib.mapAttrs (_: ctx: + {server = ctx.server;} + // lib.optionalAttrs (ctx.token != null) {token = ctx.token;}) + cfg.settings.contexts; + }; + + activationScript = let + contextEntries = lib.concatStrings (lib.mapAttrsToList (name: ctx: '' + printf '[contexts.${name}]\nserver = "%s"\n' '${ctx.server}' >> "$config_path" + ${lib.optionalString (ctx.tokenFile != null) '' + printf 'token = "%s"\n' "$(cat '${ctx.tokenFile}')" >> "$config_path" + ''} + ${lib.optionalString (ctx.token != null && ctx.tokenFile == null) '' + printf 'token = "%s"\n' '${ctx.token}' >> "$config_path" + ''} + printf '\n' >> "$config_path" + '') + cfg.settings.contexts); + in '' + config_path="${config.xdg.configHome}/funnel/config.toml" + $DRY_RUN_CMD mkdir -p "$(dirname "$config_path")" + printf 'current_context = "%s"\n\n' '${cfg.settings.currentContext}' > "$config_path" + ${contextEntries} + $DRY_RUN_CMD chmod 600 "$config_path" + ''; +in { + options.programs.funnel = { + enable = lib.mkEnableOption "funnel tunnel client"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.funnel-client; + description = "The funnel client package to use."; + }; + + settings = { + currentContext = lib.mkOption { + type = lib.types.str; + default = "default"; + description = "Active context name."; + }; + + contexts = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule { + options = { + server = lib.mkOption { + type = lib.types.str; + description = "Server URL for this context."; + }; + + token = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Auth token. Stored in the Nix store — use tokenFile instead + for secrets managed by sops-nix or agenix. + ''; + }; + + tokenFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to a file containing the auth token. + Read at activation time, never enters the Nix store. + Use with sops-nix: config.sops.secrets."funnel/token".path + ''; + }; + }; + }); + default = {}; + description = '' + Server contexts. When any context sets tokenFile, the config is + generated at activation time (mutable file with 0600 permissions). + Otherwise it's a read-only symlink to the Nix store. + ''; + }; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = {}; + example = { + RUST_LOG = "debug"; + }; + description = '' + Environment variables set in the user's session for the funnel client. + Useful for FUNNEL_SERVER, FUNNEL_CONTEXT, RUST_LOG. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = + lib.mapAttrsToList (name: ctx: { + assertion = !(ctx.token != null && ctx.tokenFile != null); + message = "programs.funnel.settings.contexts.${name}: set token or tokenFile, not both."; + }) + cfg.settings.contexts; + + home.packages = [cfg.package]; + + home.sessionVariables = cfg.environment; + + # static config when no tokenFile is used (symlink to nix store) + xdg.configFile."funnel/config.toml" = lib.mkIf (hasContexts && !hasTokenFiles) { + source = tomlFormat.generate "funnel-config.toml" staticConfigContents; + }; + + # dynamic config when tokenFile is used (generated at activation, 0600) + home.activation.generateFunnelConfig = lib.mkIf (hasContexts && hasTokenFiles) + (lib.hm.dag.entryAfter ["writeBoundary" "sopsNix"] activationScript); + }; +} diff --git a/nix/nixos/module.nix b/nix/nixos/module.nix new file mode 100644 index 0000000..aea75c2 --- /dev/null +++ b/nix/nixos/module.nix @@ -0,0 +1,380 @@ +self: { + config, + lib, + pkgs, + ... +}: let + cfg = config.services.funnel.server; + + needsPrivilegedPorts = + cfg.port < 1024 + || cfg.tls.port < 1024 + || cfg.quicPort < 1024; +in { + options.services.funnel.server = { + enable = lib.mkEnableOption "funnel tunnel server"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.funnel-server; + description = "The funnel-server package to use."; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = "Address to bind the HTTP server to."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "HTTP port for the API and proxy."; + }; + + quicPort = lib.mkOption { + type = lib.types.port; + default = 4433; + description = "UDP port for QUIC tunnel connections."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Open firewall ports for HTTP, TLS, and QUIC."; + }; + + seedApiKey = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Create a seed API key on startup (printed to journal)."; + }; + + logLevel = lib.mkOption { + type = lib.types.str; + default = "info"; + description = '' + Log level filter (RUST_LOG syntax). + Examples: "info", "debug", "funnel_server=debug,tower_http=trace" + ''; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Environment file for secrets (DATABASE_URL, GITHUB_CLIENT_SECRET, etc.). + Compatible with agenix, sops-nix, or plain files. + ''; + }; + + environmentFiles = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = []; + description = '' + List of environment files loaded by systemd. + Use this when secrets come from multiple sources. + ''; + }; + + database = { + postgresUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + PostgreSQL connection URL. When null, the embedded Turso database is used. + For secrets, set DATABASE_URL via environmentFile instead. + ''; + }; + + maxConnections = lib.mkOption { + type = lib.types.ints.positive; + default = 10; + description = "Maximum PostgreSQL connection pool size."; + }; + + tursoPath = lib.mkOption { + type = lib.types.str; + default = "/var/lib/funnel/funnel.db"; + description = "Path to the embedded Turso/libsql database file."; + }; + + createLocally = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to create a local PostgreSQL database. + When enabled, a database named 'funnel' is created automatically + and the server connects to it via unix socket. + ''; + }; + }; + + tls = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable TLS with automatic Let's Encrypt certificates."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8443; + description = "HTTPS port."; + }; + + certDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/funnel/certs"; + description = "Directory for TLS certificate storage."; + }; + + acme = { + email = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Email for ACME registration. Required when TLS is enabled."; + }; + + staging = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Use Let's Encrypt staging environment."; + }; + }; + + dnsProvidersConfigFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to DNS providers JSON config. Required when TLS is enabled."; + }; + }; + + auth = { + baseUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Public base URL. Required when any OAuth provider is configured."; + }; + + initialAdminEmail = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Email auto-promoted to admin on first login."; + }; + + github = { + clientId = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "GitHub OAuth client ID."; + }; + }; + + oauth = { + providerName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Generic OAuth provider name (e.g. 'gitlab', 'google')."; + }; + + clientId = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Generic OAuth client ID."; + }; + + authorizeUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "OAuth authorization endpoint."; + }; + + tokenUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "OAuth token exchange endpoint."; + }; + + userinfoUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "OAuth userinfo endpoint."; + }; + + scopes = lib.mkOption { + type = lib.types.str; + default = "openid email profile"; + description = "OAuth scopes (space-separated)."; + }; + + idField = lib.mkOption { + type = lib.types.str; + default = "sub"; + description = "Userinfo JSON field for user ID."; + }; + + emailField = lib.mkOption { + type = lib.types.str; + default = "email"; + description = "Userinfo JSON field for email."; + }; + + nameField = lib.mkOption { + type = lib.types.str; + default = "name"; + description = "Userinfo JSON field for display name."; + }; + + avatarField = lib.mkOption { + type = lib.types.str; + default = "picture"; + description = "Userinfo JSON field for avatar URL."; + }; + }; + }; + + extraEnvironment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = {}; + description = "Additional environment variables for the server process."; + }; + + serviceConfig = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = {}; + description = '' + Extra systemd service configuration merged into serviceConfig. + Use this to override hardening settings or add resource limits. + Example: { MemoryMax = "512M"; CPUQuota = "200%"; } + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.tls.enable -> cfg.tls.acme.email != null; + message = "services.funnel.server.tls.acme.email is required when TLS is enabled."; + } + { + assertion = cfg.tls.enable -> cfg.tls.dnsProvidersConfigFile != null; + message = "services.funnel.server.tls.dnsProvidersConfigFile is required when TLS is enabled."; + } + { + assertion = + (cfg.auth.github.clientId != null || cfg.auth.oauth.providerName != null) + -> cfg.auth.baseUrl != null; + message = "services.funnel.server.auth.baseUrl is required when OAuth is configured."; + } + { + assertion = cfg.database.createLocally -> cfg.database.postgresUrl == null; + message = "services.funnel.server.database.postgresUrl must be null when createLocally is true."; + } + ]; + + services.postgresql = lib.mkIf cfg.database.createLocally { + enable = true; + ensureDatabases = ["funnel"]; + ensureUsers = [ + { + name = "funnel"; + ensureDBOwnership = true; + } + ]; + }; + + systemd.services.funnel-server = { + description = "Funnel tunnel server"; + after = + ["network-online.target"] + ++ lib.optionals (cfg.database.postgresUrl != null || cfg.database.createLocally) ["postgresql.service"]; + wants = ["network-online.target"]; + wantedBy = ["multi-user.target"]; + + environment = + { + FUNNEL_HOST = cfg.host; + FUNNEL_PORT = toString cfg.port; + FUNNEL_QUIC_PORT = toString cfg.quicPort; + FUNNEL_DB_MAX_CONNECTIONS = toString cfg.database.maxConnections; + FUNNEL_TURSO_DB_PATH = cfg.database.tursoPath; + FUNNEL_CERT_DIR = cfg.tls.certDir; + FUNNEL_TLS_PORT = toString cfg.tls.port; + RUST_LOG = cfg.logLevel; + OAUTH_SCOPES = cfg.auth.oauth.scopes; + OAUTH_ID_FIELD = cfg.auth.oauth.idField; + OAUTH_EMAIL_FIELD = cfg.auth.oauth.emailField; + OAUTH_NAME_FIELD = cfg.auth.oauth.nameField; + OAUTH_AVATAR_FIELD = cfg.auth.oauth.avatarField; + } + // lib.optionalAttrs cfg.tls.enable {FUNNEL_ENABLE_TLS = "true";} + // lib.optionalAttrs cfg.tls.acme.staging {FUNNEL_ACME_STAGING = "true";} + // lib.optionalAttrs cfg.seedApiKey {FUNNEL_SEED_API_KEY = "true";} + // lib.optionalAttrs (cfg.database.postgresUrl != null) {DATABASE_URL = cfg.database.postgresUrl;} + // lib.optionalAttrs cfg.database.createLocally {DATABASE_URL = "postgres:///funnel?host=/run/postgresql";} + // lib.optionalAttrs (cfg.tls.acme.email != null) {LETSENCRYPT_EMAIL = cfg.tls.acme.email;} + // lib.optionalAttrs (cfg.tls.dnsProvidersConfigFile != null) {DNS_PROVIDERS_CONFIG = toString cfg.tls.dnsProvidersConfigFile;} + // lib.optionalAttrs (cfg.auth.baseUrl != null) {BASE_URL = cfg.auth.baseUrl;} + // lib.optionalAttrs (cfg.auth.initialAdminEmail != null) {FUNNEL_INITIAL_ADMIN_EMAIL = cfg.auth.initialAdminEmail;} + // lib.optionalAttrs (cfg.auth.github.clientId != null) {GITHUB_CLIENT_ID = cfg.auth.github.clientId;} + // lib.optionalAttrs (cfg.auth.oauth.providerName != null) {OAUTH_PROVIDER_NAME = cfg.auth.oauth.providerName;} + // lib.optionalAttrs (cfg.auth.oauth.clientId != null) {OAUTH_CLIENT_ID = cfg.auth.oauth.clientId;} + // lib.optionalAttrs (cfg.auth.oauth.authorizeUrl != null) {OAUTH_AUTHORIZE_URL = cfg.auth.oauth.authorizeUrl;} + // lib.optionalAttrs (cfg.auth.oauth.tokenUrl != null) {OAUTH_TOKEN_URL = cfg.auth.oauth.tokenUrl;} + // lib.optionalAttrs (cfg.auth.oauth.userinfoUrl != null) {OAUTH_USERINFO_URL = cfg.auth.oauth.userinfoUrl;} + // cfg.extraEnvironment; + + serviceConfig = + { + ExecStart = lib.getExe cfg.package; + Restart = "always"; + RestartSec = 5; + + DynamicUser = !cfg.database.createLocally; + User = lib.mkIf cfg.database.createLocally "funnel"; + Group = lib.mkIf cfg.database.createLocally "funnel"; + StateDirectory = "funnel"; + StateDirectoryMode = "0750"; + + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + RestrictNamespaces = true; + LockPersonality = true; + RestrictRealtime = true; + SystemCallFilter = ["@system-service" "~@privileged"]; + + EnvironmentFile = + lib.optional (cfg.environmentFile != null) cfg.environmentFile + ++ cfg.environmentFiles; + } + // lib.optionalAttrs needsPrivilegedPorts { + CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"]; + AmbientCapabilities = ["CAP_NET_BIND_SERVICE"]; + } + // cfg.serviceConfig; + }; + + users = lib.mkIf cfg.database.createLocally { + users.funnel = { + isSystemUser = true; + group = "funnel"; + }; + groups.funnel = {}; + }; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = + [cfg.port] + ++ lib.optionals cfg.tls.enable [cfg.tls.port]; + allowedUDPPorts = [cfg.quicPort]; + }; + }; +} diff --git a/nix/packages.nix b/nix/packages.nix new file mode 100644 index 0000000..67cebf5 --- /dev/null +++ b/nix/packages.nix @@ -0,0 +1,75 @@ +{ + pkgs, + craneLib, +}: let + src = let + sqlFilter = path: _type: + builtins.match ".*/migrations/.*\\.sql$" path != null; + in + pkgs.lib.cleanSourceWith { + src = ../.; + filter = path: type: + (craneLib.filterCargoSources path type) || (sqlFilter path type); + }; + + commonArgs = { + inherit src; + pname = "funnel"; + version = "0.1.0"; + strictDeps = true; + + buildInputs = + [pkgs.openssl pkgs.zlib] + ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ + pkgs.darwin.apple_sdk.frameworks.Security + pkgs.darwin.apple_sdk.frameworks.SystemConfiguration + pkgs.darwin.apple_sdk.frameworks.CoreFoundation + pkgs.darwin.apple_sdk.frameworks.CoreServices + pkgs.libiconv + ]; + + nativeBuildInputs = [pkgs.pkg-config]; + + OPENSSL_NO_VENDOR = "1"; + OPENSSL_DIR = "${pkgs.openssl.dev}"; + OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib"; + OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include"; + }; + + cargoArtifacts = craneLib.buildDepsOnly (commonArgs + // { + pname = "funnel-workspace"; + }); +in { + inherit src commonArgs cargoArtifacts; + + funnel-server = craneLib.buildPackage (commonArgs + // { + inherit cargoArtifacts; + pname = "funnel-server"; + cargoExtraArgs = "--package funnel-server"; + doCheck = false; + + meta = { + description = "Funnel tunnel server"; + mainProgram = "funnel-server"; + }; + }); + + funnel-client = craneLib.buildPackage (commonArgs + // { + inherit cargoArtifacts; + pname = "funnel-client"; + cargoExtraArgs = "--package funnel-client"; + doCheck = false; + + postInstall = '' + ln -s $out/bin/funnel-client $out/bin/funnel + ''; + + meta = { + description = "Funnel tunnel client"; + mainProgram = "funnel"; + }; + }); +} diff --git a/nix/parts/checks.nix b/nix/parts/checks.nix new file mode 100644 index 0000000..7d06427 --- /dev/null +++ b/nix/parts/checks.nix @@ -0,0 +1,26 @@ +{inputs, ...}: { + perSystem = { + pkgs, + lib, + system, + craneLib, + built, + ... + }: { + checks = + { + clippy = craneLib.cargoClippy (built.commonArgs + // { + inherit (built) cargoArtifacts; + cargoClippyExtraArgs = "--all-targets"; + }); + fmt = craneLib.cargoFmt {inherit (built.commonArgs) src pname version;}; + } + // lib.optionalAttrs pkgs.stdenv.isLinux { + nixos-server = import ../tests/server.nix { + inherit pkgs system; + self = inputs.self; + }; + }; + }; +} diff --git a/nix/parts/containers.nix b/nix/parts/containers.nix new file mode 100644 index 0000000..515b6a5 --- /dev/null +++ b/nix/parts/containers.nix @@ -0,0 +1,16 @@ +{...}: { + perSystem = { + pkgs, + built, + ... + }: let + containers = import ../containers.nix { + inherit pkgs; + packages = built; + }; + in { + packages = { + inherit (containers) funnel-server-image funnel-client-image; + }; + }; +} diff --git a/nix/parts/default.nix b/nix/parts/default.nix new file mode 100644 index 0000000..6f8554f --- /dev/null +++ b/nix/parts/default.nix @@ -0,0 +1,10 @@ +{ + imports = [ + ./toolchain.nix + ./packages.nix + ./containers.nix + ./checks.nix + ./dev-shells.nix + ./modules.nix + ]; +} diff --git a/nix/parts/dev-shells.nix b/nix/parts/dev-shells.nix new file mode 100644 index 0000000..741bbc3 --- /dev/null +++ b/nix/parts/dev-shells.nix @@ -0,0 +1,9 @@ +{...}: { + perSystem = {pkgs, ...}: let + devModules = import ../. {inherit pkgs;}; + in { + devShells = { + inherit (devModules.shells) default nightly wasm; + }; + }; +} diff --git a/nix/parts/modules.nix b/nix/parts/modules.nix new file mode 100644 index 0000000..3a0458d --- /dev/null +++ b/nix/parts/modules.nix @@ -0,0 +1,18 @@ +{inputs, ...}: { + flake = { + nixosModules = { + funnel-server = import ../nixos/module.nix inputs.self; + default = inputs.self.nixosModules.funnel-server; + }; + + homeManagerModules = { + funnel-client = import ../home-manager/module.nix inputs.self; + default = inputs.self.homeManagerModules.funnel-client; + }; + + overlays.default = final: prev: { + funnel-server = inputs.self.packages.${prev.system}.funnel-server; + funnel-client = inputs.self.packages.${prev.system}.funnel-client; + }; + }; +} diff --git a/nix/parts/packages.nix b/nix/parts/packages.nix new file mode 100644 index 0000000..df41303 --- /dev/null +++ b/nix/parts/packages.nix @@ -0,0 +1,8 @@ +{...}: { + perSystem = {built, ...}: { + packages = { + inherit (built) funnel-server funnel-client; + default = built.funnel-client; + }; + }; +} diff --git a/nix/parts/toolchain.nix b/nix/parts/toolchain.nix new file mode 100644 index 0000000..eee0569 --- /dev/null +++ b/nix/parts/toolchain.nix @@ -0,0 +1,13 @@ +{inputs, ...}: { + perSystem = {system, ...}: let + pkgs = import inputs.nixpkgs { + inherit system; + overlays = [inputs.rust-overlay.overlays.default]; + }; + toolchain = pkgs.rust-bin.stable.latest.default; + craneLib = (inputs.crane.mkLib pkgs).overrideToolchain toolchain; + built = import ../packages.nix {inherit pkgs craneLib;}; + in { + _module.args = {inherit pkgs craneLib built;}; + }; +} diff --git a/nix/tests/server.nix b/nix/tests/server.nix new file mode 100644 index 0000000..1c61f49 --- /dev/null +++ b/nix/tests/server.nix @@ -0,0 +1,95 @@ +{ + pkgs, + self, + system, +}: +pkgs.nixosTest { + name = "funnel-server"; + + nodes = { + server = { + config, + lib, + ... + }: { + imports = [self.nixosModules.funnel-server]; + + services.funnel.server = { + enable = true; + port = 8080; + quicPort = 4433; + seedApiKey = true; + }; + + environment.systemPackages = [pkgs.jq]; + networking.firewall.allowedTCPPorts = [8080]; + networking.firewall.allowedUDPPorts = [4433]; + }; + + client = {lib, ...}: { + environment.systemPackages = [ + self.packages.${system}.funnel-client + pkgs.curl + ]; + }; + }; + + testScript = '' + import json + + server.start() + server.wait_for_unit("funnel-server.service") + server.wait_for_open_port(8080) + + # health endpoint + health = json.loads(server.succeed("curl -sf http://localhost:8080/api/v1/health")) + assert health["status"] == "healthy", f"expected healthy, got {health['status']}" + assert health["uptime_secs"] >= 0, "uptime should be non-negative" + + # info endpoint returns correct protocol version and quic port + info = json.loads(server.succeed("curl -sf http://localhost:8080/api/v1/info")) + assert info["version"] == 1, f"expected version 1, got {info['version']}" + assert info["quic_port"] == 4433, f"expected quic_port 4433, got {info['quic_port']}" + + # unauthenticated requests to protected endpoints return 401 + status = server.succeed("curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/api/v1/tunnels").strip() + assert status == "401", f"expected 401, got {status}" + + # seed api key is emitted as a structured log field + seed_key = server.succeed( + "journalctl -u funnel-server --output=cat" + " | grep '^{'" + " | jq -r 'select(.fields.seed_api_key != null) | .fields.seed_api_key'" + " | head -1" + ).strip() + assert len(seed_key) >= 40, f"seed key too short ({len(seed_key)} chars): {seed_key}" + + # authenticated request to /me succeeds with the seed key + me_status = server.succeed( + f"curl -s -o /dev/null -w '%{{http_code}}' -H 'Authorization: Bearer {seed_key}' http://localhost:8080/api/v1/me" + ).strip() + assert me_status == "200", f"expected 200 from /me, got {me_status}" + + # service runs as dynamic user (not root) + server.succeed("systemctl show funnel-server -p DynamicUser | grep -q 'yes'") + + # state directory exists + server.succeed("test -d /var/lib/funnel") + + # service restarts after kill + server.succeed("systemctl kill -s KILL funnel-server") + server.wait_for_unit("funnel-server.service") + server.wait_for_open_port(8080) + + # client can reach server over the network + client.start() + client.wait_for_unit("multi-user.target") + + client_health = json.loads(client.succeed("curl -sf http://server:8080/api/v1/health")) + assert client_health["status"] == "healthy" + + # client context creation works + client.succeed("funnel context create test --server http://server:8080") + client.succeed("test -f /root/.config/funnel/config.toml") + ''; +} From c7d9696f4f363300788dfb1310b981e53817609a Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Mon, 11 May 2026 17:36:58 +0200 Subject: [PATCH 35/42] feat(ci): add GitHub Actions workflows, versioning, and cargo-deny --- .github/workflows/ci.yml | 47 ++++++++++++ .github/workflows/release.yml | 126 +++++++++++++++++++++++++++++++ .gitignore | 2 +- .goreleaser.yaml | 115 ---------------------------- Cargo.toml | 1 + crates/funnel-client/Cargo.toml | 4 +- crates/funnel-client/build.rs | 30 ++++++++ crates/funnel-client/src/main.rs | 15 +++- crates/funnel-core/Cargo.toml | 4 +- crates/funnel-e2e/Cargo.toml | 4 +- crates/funnel-server/Cargo.toml | 4 +- crates/funnel-server/build.rs | 30 ++++++++ crates/funnel-server/src/main.rs | 18 ++++- crates/funnel-web/Cargo.toml | 4 +- deny.toml | 39 ++++++++++ docker/server/Dockerfile | 32 -------- docker/server/entrypoint.sh | 16 ---- nix/packages.nix | 2 + nix/parts/toolchain.nix | 3 +- 19 files changed, 324 insertions(+), 172 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .goreleaser.yaml create mode 100644 crates/funnel-client/build.rs create mode 100644 crates/funnel-server/build.rs create mode 100644 deny.toml delete mode 100644 docker/server/Dockerfile delete mode 100644 docker/server/entrypoint.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb7c6a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Nix Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@v22 + with: + extra-conf: accept-flake-config = true + - uses: DeterminateSystems/magic-nix-cache-action@v13 + - run: nix flake check + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@v22 + with: + extra-conf: accept-flake-config = true + - uses: DeterminateSystems/magic-nix-cache-action@v13 + - run: nix develop -c cargo nextest run --workspace --exclude funnel-e2e + - run: nix develop -c cargo test --doc --workspace + + deny: + name: Deny (${{ matrix.check }}) + runs-on: ubuntu-latest + strategy: + matrix: + check: [advisories, "bans licenses sources"] + continue-on-error: ${{ matrix.check == 'advisories' }} + steps: + - uses: actions/checkout@v6 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check ${{ matrix.check }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e3d8fe4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,126 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + packages: write + +jobs: + build: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-linux + os: ubuntu-latest + - target: aarch64-linux + os: ubuntu-24.04-arm + - target: x86_64-darwin + os: macos-13 + - target: aarch64-darwin + os: macos-latest + steps: + - uses: actions/checkout@v6 + + - uses: DeterminateSystems/nix-installer-action@v22 + with: + extra-conf: accept-flake-config = true + + - uses: DeterminateSystems/magic-nix-cache-action@v13 + + - name: Build packages + run: | + nix build .#funnel-server -o result-server + nix build .#funnel-client -o result-client + + - name: Create archive + run: | + mkdir -p dist + cp -L result-server/bin/funnel-server dist/ + cp -L result-client/bin/funnel-client dist/ + cp -L result-client/bin/funnel dist/ + cd dist + tar czf ../funnel-${{ matrix.target }}.tar.gz * + cd .. + shasum -a 256 funnel-${{ matrix.target }}.tar.gz > funnel-${{ matrix.target }}.tar.gz.sha256 + + - name: Upload to release + uses: softprops/action-gh-release@v3 + with: + files: | + funnel-${{ matrix.target }}.tar.gz + funnel-${{ matrix.target }}.tar.gz.sha256 + + - name: Build and stage container image + if: contains(matrix.target, 'linux') + run: | + nix build .#funnel-server-image -o result-container + cp -L result-container funnel-server-image.tar.gz + + - name: Upload container artifact + if: contains(matrix.target, 'linux') + uses: actions/upload-artifact@v7 + with: + name: container-${{ matrix.target }} + path: funnel-server-image.tar.gz + retention-days: 1 + + push-container: + name: Push Container + needs: build + runs-on: ubuntu-latest + steps: + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - uses: actions/download-artifact@v8 + with: + name: container-x86_64-linux + path: container-amd64 + + - uses: actions/download-artifact@v8 + with: + name: container-aarch64-linux + path: container-arm64 + + - name: Push arch-specific images + env: + REGISTRY: ghcr.io/${{ github.repository_owner }}/funnel-server + VERSION: ${{ steps.version.outputs.version }} + run: | + docker load < container-amd64/funnel-server-image.tar.gz + docker tag funnel-server:latest "$REGISTRY:$VERSION-amd64" + docker push "$REGISTRY:$VERSION-amd64" + docker rmi funnel-server:latest + + docker load < container-arm64/funnel-server-image.tar.gz + docker tag funnel-server:latest "$REGISTRY:$VERSION-arm64" + docker push "$REGISTRY:$VERSION-arm64" + docker rmi funnel-server:latest + + - name: Create and push manifests + env: + REGISTRY: ghcr.io/${{ github.repository_owner }}/funnel-server + VERSION: ${{ steps.version.outputs.version }} + run: | + docker manifest create "$REGISTRY:$VERSION" \ + "$REGISTRY:$VERSION-amd64" \ + "$REGISTRY:$VERSION-arm64" + docker manifest push "$REGISTRY:$VERSION" + + docker manifest create "$REGISTRY:latest" \ + "$REGISTRY:$VERSION-amd64" \ + "$REGISTRY:$VERSION-arm64" + docker manifest push "$REGISTRY:latest" diff --git a/.gitignore b/.gitignore index 7b57287..d0cacc1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ # Environment .env .direnv -result +result* # IDE .vscode/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index a00e955..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,115 +0,0 @@ -# This is an example .goreleaser.yml file with some sensible defaults. -# Make sure to check the documentation at https://goreleaser.com - -# The lines below are called `modelines`. See `:help modeline` -# Feel free to remove those if you don't want/need to use them. -# yaml-language-server: $schema=https://goreleaser.com/static/schema.json -# vim: set ts=2 sw=2 tw=0 fo=cnqoj - -version: 2 -project_name: funnel - -# before: -# hooks: -# # commands for go workspaces. -# - go work sync -# - go work tidy -# # you may remove this if you don't need go generate. -# # this is failing with `go work: unknown command`, so we disable it for now. -# # - go work exec -v -- go generate ./... - -builds: - - id: funnel - main: ./cmd/funnel - binary: funnel - env: &env - - CGO_ENABLED=0 - goos: - - linux - - darwin - goarch: - - amd64 - - arm64 - ldflags: &ldflags - - -s -w - - -X github.com/karol-broda/funnel/version.Version={{.Version}} - - -X github.com/karol-broda/funnel/version.BuildDate={{.Date}} - - -X github.com/karol-broda/funnel/version.GitCommit={{.Commit}} - - -X github.com/karol-broda/funnel/version.GitTag={{.Tag}} - mod_timestamp: '{{ .CommitTimestamp }}' - - id: server - main: ./cmd/server - binary: server - env: *env - goos: - - linux - - darwin - goarch: - - amd64 - - arm64 - ldflags: *ldflags - mod_timestamp: '{{ .CommitTimestamp }}' - -archives: - - id: funnel - builds: - - funnel - name_template: '{{ .ProjectName }}_{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' - - id: server - builds: - - server - name_template: '{{ .ProjectName }}_{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' - -dockers: - - goos: linux - goarch: amd64 - ids: - - server - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-amd64" - dockerfile: docker/server/Dockerfile - extra_files: - - docker/server/entrypoint.sh - use: buildx - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - goos: linux - goarch: arm64 - ids: - - server - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-arm64" - dockerfile: docker/server/Dockerfile - extra_files: - - docker/server/entrypoint.sh - use: buildx - build_flag_templates: - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" - -docker_manifests: - - name_template: "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}" - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-amd64" - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-arm64" - - name_template: "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:latest" - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-amd64" - - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/funnel-server:{{ .Tag }}-arm64" - -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" - -release: - draft: true diff --git a/Cargo.toml b/Cargo.toml index 81e235b..6801496 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ ] [workspace.package] +version = "0.1.0" edition = "2024" license = "MIT" repository = "https://github.com/karol-broda/funnel" diff --git a/crates/funnel-client/Cargo.toml b/crates/funnel-client/Cargo.toml index 244a3b8..9713bc0 100644 --- a/crates/funnel-client/Cargo.toml +++ b/crates/funnel-client/Cargo.toml @@ -1,7 +1,9 @@ [package] name = "funnel-client" -version = "0.1.0" +version.workspace = true edition.workspace = true +license.workspace = true +publish = false [lints] workspace = true diff --git a/crates/funnel-client/build.rs b/crates/funnel-client/build.rs new file mode 100644 index 0000000..ed258b1 --- /dev/null +++ b/crates/funnel-client/build.rs @@ -0,0 +1,30 @@ +fn main() { + let hash = run("git", &["rev-parse", "--short", "HEAD"]) + .or_else(|| std::env::var("FUNNEL_GIT_HASH").ok()) + .unwrap_or_else(|| "unknown".to_string()); + + println!("cargo:rustc-env=FUNNEL_GIT_HASH={hash}"); + + // only rerun when git ref changes (new commit, branch switch) or + // when FUNNEL_GIT_HASH is set externally (nix, ci) + if let Some(git_dir) = run("git", &["rev-parse", "--git-dir"]) { + let head_path = format!("{git_dir}/HEAD"); + println!("cargo:rerun-if-changed={head_path}"); + if let Ok(head_ref) = std::fs::read_to_string(&head_path) { + if let Some(ref_path) = head_ref.strip_prefix("ref: ") { + println!("cargo:rerun-if-changed={git_dir}/{}", ref_path.trim()); + } + } + } + println!("cargo:rerun-if-env-changed=FUNNEL_GIT_HASH"); +} + +fn run(cmd: &str, args: &[&str]) -> Option { + std::process::Command::new(cmd) + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} diff --git a/crates/funnel-client/src/main.rs b/crates/funnel-client/src/main.rs index 9c8ee70..a5b1d09 100644 --- a/crates/funnel-client/src/main.rs +++ b/crates/funnel-client/src/main.rs @@ -6,10 +6,23 @@ mod tunnel; use clap::{Parser, Subcommand}; use tracing_subscriber::EnvFilter; +fn build_version() -> &'static str { + Box::leak( + format!( + "{} ({}) protocol v{}", + env!("CARGO_PKG_VERSION"), + env!("FUNNEL_GIT_HASH"), + funnel_core::protocol::PROTOCOL_VERSION, + ) + .into_boxed_str(), + ) +} + #[derive(Parser)] #[command( name = "funnel", - about = "expose local services through secure tunnels" + about = "expose local services through secure tunnels", + version = build_version(), )] struct Cli { /// context to use (overrides current_context in config) diff --git a/crates/funnel-core/Cargo.toml b/crates/funnel-core/Cargo.toml index 9d4ce22..9368648 100644 --- a/crates/funnel-core/Cargo.toml +++ b/crates/funnel-core/Cargo.toml @@ -1,7 +1,9 @@ [package] name = "funnel-core" -version = "0.1.0" +version.workspace = true edition.workspace = true +license.workspace = true +publish = false [lints] workspace = true diff --git a/crates/funnel-e2e/Cargo.toml b/crates/funnel-e2e/Cargo.toml index eadf9e0..cc7ba78 100644 --- a/crates/funnel-e2e/Cargo.toml +++ b/crates/funnel-e2e/Cargo.toml @@ -1,7 +1,9 @@ [package] name = "funnel-e2e" -version = "0.1.0" +version.workspace = true edition.workspace = true +license.workspace = true +publish = false [lints] workspace = true diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index cdf8899..91cbd96 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -1,7 +1,9 @@ [package] name = "funnel-server" -version = "0.1.0" +version.workspace = true edition.workspace = true +license.workspace = true +publish = false [lints] workspace = true diff --git a/crates/funnel-server/build.rs b/crates/funnel-server/build.rs new file mode 100644 index 0000000..ed258b1 --- /dev/null +++ b/crates/funnel-server/build.rs @@ -0,0 +1,30 @@ +fn main() { + let hash = run("git", &["rev-parse", "--short", "HEAD"]) + .or_else(|| std::env::var("FUNNEL_GIT_HASH").ok()) + .unwrap_or_else(|| "unknown".to_string()); + + println!("cargo:rustc-env=FUNNEL_GIT_HASH={hash}"); + + // only rerun when git ref changes (new commit, branch switch) or + // when FUNNEL_GIT_HASH is set externally (nix, ci) + if let Some(git_dir) = run("git", &["rev-parse", "--git-dir"]) { + let head_path = format!("{git_dir}/HEAD"); + println!("cargo:rerun-if-changed={head_path}"); + if let Ok(head_ref) = std::fs::read_to_string(&head_path) { + if let Some(ref_path) = head_ref.strip_prefix("ref: ") { + println!("cargo:rerun-if-changed={git_dir}/{}", ref_path.trim()); + } + } + } + println!("cargo:rerun-if-env-changed=FUNNEL_GIT_HASH"); +} + +fn run(cmd: &str, args: &[&str]) -> Option { + std::process::Command::new(cmd) + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) +} diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index 9365dc4..af244da 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -25,8 +25,24 @@ use auth::oauth::{OAuthProvider, OAuthState}; use store::health::UptimeHealthReporter; use tunnel::manager::TunnelManager; +fn build_version() -> &'static str { + Box::leak( + format!( + "{} ({}) protocol v{}", + env!("CARGO_PKG_VERSION"), + env!("FUNNEL_GIT_HASH"), + funnel_core::protocol::PROTOCOL_VERSION, + ) + .into_boxed_str(), + ) +} + #[derive(Parser)] -#[command(name = "funnel-server", about = "Funnel tunnel server")] +#[command( + name = "funnel-server", + about = "Funnel tunnel server", + version = build_version(), +)] struct Cli { /// Port to listen on #[arg(short, long, default_value_t = 8080, env = "FUNNEL_PORT")] diff --git a/crates/funnel-web/Cargo.toml b/crates/funnel-web/Cargo.toml index 6945d8e..a26e5af 100644 --- a/crates/funnel-web/Cargo.toml +++ b/crates/funnel-web/Cargo.toml @@ -1,7 +1,9 @@ [package] name = "funnel-web" -version = "0.1.0" +version.workspace = true edition.workspace = true +license.workspace = true +publish = false [lints] workspace = true diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..06ea632 --- /dev/null +++ b/deny.toml @@ -0,0 +1,39 @@ +[graph] +targets = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", +] + +[advisories] +ignore = [] + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "OpenSSL", + "Zlib", + "MPL-2.0", + "CDLA-Permissive-2.0", + "BSL-1.0", +] + +[licenses.private] +ignore = true + +[bans] +multiple-versions = "warn" +wildcards = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile deleted file mode 100644 index c0916e1..0000000 --- a/docker/server/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -ARG ALPINE_VERSION=latest - -FROM alpine:${ALPINE_VERSION} - -ARG APP_USER=funnel -ARG APP_GROUP=funnel -ARG APP_HOME=/var/lib/funnel - -ENV FUNNEL_HOST="0.0.0.0" -ENV FUNNEL_PORT="8080" -ENV FUNNEL_TLS_PORT="8443" -ENV APP_NAME=funnel-server - -RUN addgroup -S "${APP_GROUP}" && adduser -S "${APP_USER}" -G "${APP_GROUP}" -RUN mkdir -p "${APP_HOME}/certs" && chown -R "${APP_USER}:${APP_GROUP}" "${APP_HOME}" - -# goreleaser places the binary (named 'server' from the builds.binary config) -# and the entrypoint.sh (from the dockers.extra_files config) -# in the root of the build context. -COPY server /usr/local/bin/funnel-server -COPY docker/server/entrypoint.sh /usr/local/bin/entrypoint.sh - -RUN chmod +x /usr/local/bin/entrypoint.sh - -USER ${APP_USER} - -EXPOSE ${FUNNEL_PORT} -EXPOSE ${FUNNEL_TLS_PORT} - -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] - -CMD ["/usr/local/bin/funnel-server", "--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file diff --git a/docker/server/entrypoint.sh b/docker/server/entrypoint.sh deleted file mode 100644 index 7d8bbe5..0000000 --- a/docker/server/entrypoint.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -# this script is a wrapper around the main application binary. -# it allows us to use environment variables to configure the application's command-line flags. - -set -e - -# if the first argument is a flag, assume the user wants to run the server. -# in this case, we prepend the server command to the arguments. -if [ "${1#-}" != "$1" ]; then - set -- /usr/local/bin/funnel-server "$@" -fi - -# execute the command. -# this will be "funnel-server --host ..." if the user runs the container without arguments, -# or a command like "sh" if the user wants an interactive shell. -exec "$@" \ No newline at end of file diff --git a/nix/packages.nix b/nix/packages.nix index 67cebf5..d60c192 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -1,6 +1,7 @@ { pkgs, craneLib, + gitHash ? "unknown", }: let src = let sqlFilter = path: _type: @@ -30,6 +31,7 @@ nativeBuildInputs = [pkgs.pkg-config]; + FUNNEL_GIT_HASH = gitHash; OPENSSL_NO_VENDOR = "1"; OPENSSL_DIR = "${pkgs.openssl.dev}"; OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib"; diff --git a/nix/parts/toolchain.nix b/nix/parts/toolchain.nix index eee0569..b909853 100644 --- a/nix/parts/toolchain.nix +++ b/nix/parts/toolchain.nix @@ -6,7 +6,8 @@ }; toolchain = pkgs.rust-bin.stable.latest.default; craneLib = (inputs.crane.mkLib pkgs).overrideToolchain toolchain; - built = import ../packages.nix {inherit pkgs craneLib;}; + gitHash = inputs.self.shortRev or inputs.self.dirtyShortRev or "unknown"; + built = import ../packages.nix {inherit pkgs craneLib gitHash;}; in { _module.args = {inherit pkgs craneLib built;}; }; From 663f15e91545f993f26e9fc04f3f9bd74e0de4d2 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Mon, 11 May 2026 17:59:07 +0200 Subject: [PATCH 36/42] docs: add roadmap and remove stale Go-era files --- TLS_SETUP.md | 265 --------------- dns-provider-template.op.json | 16 - dns-providers.schema.json | 43 --- go.work | 10 - go.work.sum | 593 ---------------------------------- roadmap.md | 156 +++++++++ 6 files changed, 156 insertions(+), 927 deletions(-) delete mode 100644 TLS_SETUP.md delete mode 100644 dns-provider-template.op.json delete mode 100644 dns-providers.schema.json delete mode 100644 go.work delete mode 100644 go.work.sum create mode 100644 roadmap.md diff --git a/TLS_SETUP.md b/TLS_SETUP.md deleted file mode 100644 index bd78b9d..0000000 --- a/TLS_SETUP.md +++ /dev/null @@ -1,265 +0,0 @@ -# tls setup guide - -this guide explains how to configure and use the tunnel server with automatic let's encrypt tls certificates using multiple dns providers. - -## overview - -the tunnel server now supports automatic tls certificate management using let's encrypt with dns-01 challenges. this allows you to: - -- generate wildcard certificates (`*.example.com`) -- support multiple domains across different dns providers -- handle multiple aws accounts/tenants -- automatically renew certificates before expiration -- use sni (server name indication) for multi-domain support - -## prerequisites - -1. **domain control**: you must control the dns for the domains you want certificates for -2. **dns provider access**: api credentials for your dns provider(s) -3. **email address**: required for let's encrypt registration - -## supported dns providers - -the server dynamically supports **any dns provider** available in the [go-acme/lego](https://go-acme.github.io/lego/dns/index.html) library. you are no longer limited to a hardcoded list. - -to use a provider, you must specify its name in the `provider` field and provide the required credentials in the `config` map. the keys in the `config` map **must match the environment variable names** that the specific lego provider expects. - -for a complete list of providers and their required environment variables, see the official lego dns provider documentation: [https://go-acme.github.io/lego/dns/index.html](https://go-acme.github.io/lego/dns/index.html) - -for example, to use `gandiv5`, you would find its required environment variable (`gandiv5_api_key`) and use that as the key in your config. - -### 1. dns providers configuration - -create a `dns-providers.json` file. the structure of this file is defined by the `dns-providers.schema.json` schema. - -here is a basic example using route53 and cloudflare: - -```json -{ - "providers": [ - { - "name": "aws-production", - "provider": "route53", - "domains": ["example.com", "*.example.com"], - "config": { - "aws_access_key_id": "your-access-key", - "aws_secret_access_key": "your-secret-key", - "aws_region": "us-east-1" - } - }, - { - "name": "cloudflare-main", - "provider": "cloudflare", - "domains": ["myapp.org", "*.myapp.org"], - "config": { - "cloudflare_dns_api_token": "your-api-token" - } - } - ] -} -``` - -### 2. route53 configuration options - -for aws route53, you can configure: - -```json -{ - "name": "aws-tenant", - "provider": "route53", - "domains": ["example.com", "*.example.com"], - "config": { - "aws_access_key_id": "akiaiosfodnn7example", - "aws_secret_access_key": "wjalrxutnfemi/k7mdeng/bpxrficyexamplekey", - "aws_region": "us-east-1", - "aws_session_token": "optional-session-token", - "aws_assume_role_arn": "arn:aws:iam::123456789012:role/route53access" - } -} -``` - -**note**: `aws_profile` is handled automatically by the aws sdk through environment variables and is not needed in the config. - -### 3. cloudflare configuration options - -for cloudflare, you can use either: - -**api token (recommended)**: -```json -{ - "name": "cloudflare-token", - "provider": "cloudflare", - "domains": ["example.com", "*.example.com"], - "config": { - "cloudflare_dns_api_token": "your-scoped-api-token" - } -} -``` - -**global api key**: -```json -{ - "name": "cloudflare-global", - "provider": "cloudflare", - "domains": ["example.com", "*.example.com"], - "config": { - "cloudflare_email": "your-email@example.com", - "cloudflare_api_key": "your-global-api-key" - } -} -``` - -### example: digitalocean - -to configure digitalocean, you would find its documentation requires `do_auth_token`. your configuration would look like this: - -```json -{ - "name": "digitalocean-main", - "provider": "digitalocean", - "domains": ["do-domain.com", "*.do-domain.com"], - "config": { - "do_auth_token": "your-digitalocean-api-token" - } -} -``` - -## running the server - -### basic tls setup - -```bash -./tunnel-server \ - --enable-tls \ - --email your-email@example.com \ - --dns-config ./dns-providers.json \ - --cert-dir ./certs \ - --tls-port 443 \ - --port 80 -``` - -### development/testing - -for development, you might want to use different ports: - -```bash -./tunnel-server \ - --enable-tls \ - --email your-email@example.com \ - --dns-config ./dns-providers.json \ - --tls-port 8443 \ - --port 8080 -``` - -### production setup - -for production, typically you'd run on standard ports: - -```bash -sudo ./tunnel-server \ - --enable-tls \ - --email your-email@example.com \ - --dns-config /etc/funnel/dns-providers.json \ - --cert-dir /var/lib/funnel/certs \ - --tls-port 443 \ - --port 80 -``` - -## how it works - -### certificate management - -1. **on-demand generation**: certificates are generated automatically when a client connects with sni -2. **wildcard support**: the system automatically requests both the specific domain and wildcard (`*.domain.com`) -3. **caching**: certificates are cached in memory and persisted to disk -4. **auto-renewal**: certificates are automatically renewed when they expire within 30 days - -### dns challenge process - -1. client connects with sni for `subdomain.example.com` -2. system checks if a valid certificate exists -3. if not, it determines the appropriate dns provider based on domain mapping -4. creates dns txt record `_acme-challenge.subdomain.example.com` -5. let's encrypt validates the challenge -6. certificate is issued and cached -7. dns txt record is cleaned up - -### multi-provider routing - -the system uses a "provider multiplexer" that routes dns challenges to the correct provider: - -- exact domain match: `example.com` → provider for `example.com` -- wildcard match: `sub.example.com` → provider for `*.example.com` -- parent domain match: `deep.sub.example.com` → provider for `example.com` - -## security considerations - -1. **credential storage**: dns provider credentials are stored in the configuration file - ensure proper file permissions -2. **certificate storage**: certificates are stored in the specified cert directory with appropriate permissions -3. **tls configuration**: the server uses secure tls settings with modern cipher suites -4. **rate limits**: be aware of let's encrypt rate limits (50 certificates per registered domain per week) - -## troubleshooting - -### common issues - -1. **dns propagation**: dns changes can take time to propagate. the system waits for propagation automatically. -2. **rate limits**: if you hit let's encrypt rate limits, you'll need to wait or use the staging environment for testing. -3. **dns provider errors**: check your dns provider credentials and permissions. - -### logging - -the system provides detailed logging for certificate operations: - -```bash -# enable debug logging -export log_level=debug -./tunnel-server --enable-tls ... -``` - -### testing with let's encrypt staging - -for testing, you can modify the code to use let's encrypt staging environment by changing: - -```go -legoconfig.cadirurl = lego.ledirectorystaging -``` - -## file structure - -``` -./ -├── tunnel-server # main executable -├── dns-providers.json # dns providers configuration -├── dns-providers.schema.json # example configuration -├── certs/ # certificate storage directory -│ ├── example.com.crt # certificate files -│ ├── example.com.key # private key files -│ └── ... -└── tls_setup.md # this guide -``` - -## example systemd service - -```ini -[unit] -description=tunnel server with tls -after=network.target - -[service] -type=simple -user=tunnel -workingdirectory=/opt/tunnel-server -execstart=/opt/tunnel-server/tunnel-server \ - --enable-tls \ - --email admin@example.com \ - --dns-config /opt/tunnel-server/dns-providers.json \ - --cert-dir /var/lib/tunnel-server/certs \ - --tls-port 443 \ - --port 80 -restart=always -restartsec=5 - -[install] -wantedby=multi-user.target -``` \ No newline at end of file diff --git a/dns-provider-template.op.json b/dns-provider-template.op.json deleted file mode 100644 index 6e0b82b..0000000 --- a/dns-provider-template.op.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/karol-broda/funnel/refs/heads/master/dns-providers.schema.json", - "providers": [ - { - "name": "aws", - "provider": "route53", - "domains": ["op://Funnel/DNS Credentials/domains/0", "op://Funnel/DNS Credentials/domains/1"], - "config": { - "AWS_ACCESS_KEY_ID": "op://Funnel/DNS credentials/aws access key", - "AWS_SECRET_ACCESS_KEY": "op://Funnel/DNS credentials/aws secret access key", - "AWS_REGION": "us-east-1", - "AWS_HOSTED_ZONE_ID": "op://Funnel/DNS Credentials/hosted zone id" - } - } - ] -} \ No newline at end of file diff --git a/dns-providers.schema.json b/dns-providers.schema.json deleted file mode 100644 index 7191f3a..0000000 --- a/dns-providers.schema.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "funnel tunnel server dns provider configuration", - "description": "configuration file for dns providers used for let's encrypt dns-01 challenges.", - "type": "object", - "properties": { - "providers": { - "description": "an array of dns provider configurations.", - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "description": "a unique, friendly name for this provider configuration (e.g., 'aws-production').", - "type": "string" - }, - "provider": { - "description": "the name of the dns provider (e.g., 'route53', 'cloudflare'). must match a provider supported by the lego library.", - "type": "string" - }, - "domains": { - "description": "a list of domains (including wildcards) managed by this provider.", - "type": "array", - "items": { - "type": "string", - "pattern": "^(\\*\\.)?([a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$" - }, - "minitems": 1 - }, - "config": { - "description": "a key-value map of credentials and settings for this provider.", - "type": "object", - "additionalproperties": { - "type": "string" - } - } - }, - "required": ["name", "provider", "domains", "config"] - } - } - }, - "required": ["providers"] -} \ No newline at end of file diff --git a/go.work b/go.work deleted file mode 100644 index 41caeb7..0000000 --- a/go.work +++ /dev/null @@ -1,10 +0,0 @@ -go 1.24.4 - -use ( - ./client - ./cmd/funnel - ./cmd/server - ./server - ./shared - ./version -) diff --git a/go.work.sum b/go.work.sum deleted file mode 100644 index efd2e41..0000000 --- a/go.work.sum +++ /dev/null @@ -1,593 +0,0 @@ -cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= -cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= -cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= -cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/accessapproval v1.8.1 h1:WC6pA5Gyqkrvdc18AHvriShwk8wgMe9EWvBAQSLxTc8= -cloud.google.com/go/accessapproval v1.8.1/go.mod h1:3HAtm2ertsWdwgjSGObyas6fj3ZC/3zwV2WVZXO53sU= -cloud.google.com/go/accesscontextmanager v1.9.1 h1:+C7HM05/h80znK+8VNu25wAimueda6/NGNdus+jxaHI= -cloud.google.com/go/accesscontextmanager v1.9.1/go.mod h1:wUVSoz8HmG7m9miQTh6smbyYuNOJrvZukK5g6WxSOp0= -cloud.google.com/go/aiplatform v1.68.0 h1:EPPqgHDJpBZKRvv+OsB3cr0jYz3EL2pZ+802rBPcG8U= -cloud.google.com/go/aiplatform v1.68.0/go.mod h1:105MFA3svHjC3Oazl7yjXAmIR89LKhRAeNdnDKJczME= -cloud.google.com/go/analytics v0.25.1 h1:tMlK9KGTwHYASagAHXXbIPUVCRknA0Yv4jquim5HdRE= -cloud.google.com/go/analytics v0.25.1/go.mod h1:hrAWcN/7tqyYwF/f60Nph1yz5UE3/PxOPzzFsJgtU+Y= -cloud.google.com/go/apigateway v1.7.1 h1:BeR+5NtpGxsUoK8wa/IPkanORjqZdlyNmXZ8ke3tOhc= -cloud.google.com/go/apigateway v1.7.1/go.mod h1:5JBcLrl7GHSGRzuDaISd5u0RKV05DNFiq4dRdfrhCP0= -cloud.google.com/go/apigeeconnect v1.7.1 h1:yMWIb/lv69K7Qz6Brv63u6gIACefIPKQSiI2aFXnJxo= -cloud.google.com/go/apigeeconnect v1.7.1/go.mod h1:olkn1lOhIA/aorreenFzfEcEXmFN2pyAwkaUFbug9ZY= -cloud.google.com/go/apigeeregistry v0.9.1 h1:AfMllcPbJ+qMgbYK2bC5QDPd8SmE8wQ5msiDILuxVm4= -cloud.google.com/go/apigeeregistry v0.9.1/go.mod h1:XCwK9CS65ehi26z7E8/Vl4PEX5c/JJxpfxlB1QEyrZw= -cloud.google.com/go/appengine v1.9.1 h1:mQMmn1Dv0DDLsDjYxfS+cVwQa8+ue++ymVeD1jkXze0= -cloud.google.com/go/appengine v1.9.1/go.mod h1:jtguveqRWFfjrk3k/7SlJz1FpDBZhu5CWSRu+HBgClk= -cloud.google.com/go/area120 v0.9.1 h1:YfDWbKHRHmhpd8ejTmAeK6eYi3n0qJKvPNEj1ON19PY= -cloud.google.com/go/area120 v0.9.1/go.mod h1:foV1BSrnjVL/KydBnAlUQFSy85kWrMwGSmRfIraC+JU= -cloud.google.com/go/artifactregistry v1.15.1 h1:ANE2nBEqP2vGGA/5plRRUpatT3E/3ydSK8Z+lXiV69s= -cloud.google.com/go/artifactregistry v1.15.1/go.mod h1:ExJb4VN+IMTQWO5iY+mjcY19Rz9jUxCVGZ1YuyAgPBw= -cloud.google.com/go/asset v1.20.2 h1:wAGSAzAmMC/KEFGZ6Z0zv3jOlz1fjBxuO7SiRX9FMuQ= -cloud.google.com/go/asset v1.20.2/go.mod h1:IM1Kpzzo3wq7R/GEiktitzZyXx2zVpWqs9/5EGYs0GY= -cloud.google.com/go/assuredworkloads v1.12.1 h1:B+hWc62fYL8NdntPjx0rzJJ67qx99w6dCeIVDpHf7QE= -cloud.google.com/go/assuredworkloads v1.12.1/go.mod h1:nBnkK2GZNSdtjU3ER75oC5fikub5/+QchbolKgnMI/I= -cloud.google.com/go/automl v1.14.1 h1:IrNnM7oClTzfFcf5XgaZCGwicETU2aCmrGzE8U2DlVs= -cloud.google.com/go/automl v1.14.1/go.mod h1:BocG5mhT32cjmf5CXxVsdSM04VXzJW7chVT7CpSL2kk= -cloud.google.com/go/baremetalsolution v1.3.1 h1:Zbsrhw8vm4Byki+ynVuACZ6jxYiKzi1f8Hac5zXGD8Y= -cloud.google.com/go/baremetalsolution v1.3.1/go.mod h1:D1djGGmBl4M6VlyjOMc1SEzDYlO4EeEG1TCUv5mCPi0= -cloud.google.com/go/batch v1.11.1 h1:50TRhaaZv7QDCb60KcZUPkGx1oO46srDp5076wZkgI8= -cloud.google.com/go/batch v1.11.1/go.mod h1:4GbJXfdxU8GH6uuo8G47y5tEFOgTLCL9pMKCUcn7VxE= -cloud.google.com/go/beyondcorp v1.1.1 h1:owviaab14M9ySEvCj3EZdfzkRLnE+5j4JIkqVaQtEUU= -cloud.google.com/go/beyondcorp v1.1.1/go.mod h1:L09o0gLkgXMxCZs4qojrgpI2/dhWtasMc71zPPiHMn4= -cloud.google.com/go/bigquery v1.63.1 h1:/6syiWrSpardKNxdvldS5CUTRJX1iIkSPXCjLjiGL+g= -cloud.google.com/go/bigquery v1.63.1/go.mod h1:ufaITfroCk17WTqBhMpi8CRjsfHjMX07pDrQaRKKX2o= -cloud.google.com/go/bigtable v1.33.0 h1:2BDaWLRAwXO14DJL/u8crbV2oUbMZkIa2eGq8Yao1bk= -cloud.google.com/go/bigtable v1.33.0/go.mod h1:HtpnH4g25VT1pejHRtInlFPnN5sjTxbQlsYBjh9t5l0= -cloud.google.com/go/billing v1.19.1 h1:BtbMCM9QDWiszfNXEAcq0MB6vgCuc0/yzP3vye2Kz3U= -cloud.google.com/go/billing v1.19.1/go.mod h1:c5l7ORJjOLH/aASJqUqNsEmwrhfjWZYHX+z0fIhuVpo= -cloud.google.com/go/binaryauthorization v1.9.1 h1:fVtOG5rVU0eaVh2G2ORdT7nigsnK1R1JpqfGzW861OM= -cloud.google.com/go/binaryauthorization v1.9.1/go.mod h1:jqBzP68bfzjoiMFT6Q1EdZtKJG39zW9ywwzHuv7V8ms= -cloud.google.com/go/certificatemanager v1.9.1 h1:fULhIdwsz3SoZfiXw8XaxSJBpRTR0xwsJleO+wEbbKA= -cloud.google.com/go/certificatemanager v1.9.1/go.mod h1:a6bXZULtd6iQTRuSVs1fopcHLMJ/T3zSpIB7aJaq/js= -cloud.google.com/go/channel v1.19.0 h1:YdCa/Y6lhGVeR058gQGhTunEuR9zVuheukKL+pcldgI= -cloud.google.com/go/channel v1.19.0/go.mod h1:8BEvuN5hWL4tT0rmJR4N8xsZHdfGof+KwemjQH6oXsw= -cloud.google.com/go/cloudbuild v1.18.0 h1:82f6g0AzacK1bbO0E5ZqixWc4nRzWu4ichIQ0QKNtAQ= -cloud.google.com/go/cloudbuild v1.18.0/go.mod h1:KCHWGIoS/5fj+By9YmgIQnUiDq8P6YURWOjX3hoc6As= -cloud.google.com/go/clouddms v1.8.1 h1:vf5R4/FoLHxEP2BBKEafLHfYFWa6Zd9gwrXe/FjrwUg= -cloud.google.com/go/clouddms v1.8.1/go.mod h1:bmW2eDFH1LjuwkHcKKeeppcmuBGS0r6Qz6TXanehKP0= -cloud.google.com/go/cloudtasks v1.13.1 h1:s1JTLBD+WbzQwxYPAwa2WIxPT3kOiv7MSKyvSEgNQtg= -cloud.google.com/go/cloudtasks v1.13.1/go.mod h1:dyRD7tEEkLMbHLagb7UugkDa77UVJp9d/6O9lm3ModI= -cloud.google.com/go/compute v1.28.1 h1:XwPcZjgMCnU2tkwY10VleUjSAfpTj9RDn+kGrbYsi8o= -cloud.google.com/go/compute v1.28.1/go.mod h1:b72iXMY4FucVry3NR3Li4kVyyTvbMDE7x5WsqvxjsYk= -cloud.google.com/go/contactcenterinsights v1.15.0 h1:jHwyL2TQTaLauRRz5Uv7/sL7PNAK1VAMy/UIT9vsFzk= -cloud.google.com/go/contactcenterinsights v1.15.0/go.mod h1:6bJGBQrJsnATv2s6Dh/c6HCRanq2kCZ0kIIjRV1G0mI= -cloud.google.com/go/container v1.40.0 h1:JVoEg/4RvoGW37r2Eja/cTBc3X9c2loGWYq7QDsRDuI= -cloud.google.com/go/container v1.40.0/go.mod h1:wNI1mOUivm+ZkpHMbouutgbD4sQxyphMwK31X5cThY4= -cloud.google.com/go/containeranalysis v0.13.1 h1:opZRo0HEVLm4ylTbbXw/H68M3vQjdkYOSMfUY63+D+0= -cloud.google.com/go/containeranalysis v0.13.1/go.mod h1:bmd9H880BNR4Hc8JspEg8ge9WccSQfO+/N+CYvU3sEA= -cloud.google.com/go/datacatalog v1.22.1 h1:i0DyKb/o7j+0vgaFtimcRFjYsD6wFw1jpnODYUyiYRs= -cloud.google.com/go/datacatalog v1.22.1/go.mod h1:MscnJl9B2lpYlFoxRjicw19kFTwEke8ReKL5Y/6TWg8= -cloud.google.com/go/dataflow v0.10.1 h1:RoVpCZ1BjJBH/5mzaXCgNg+l9FgTIYQ7C9xBRGvhkzo= -cloud.google.com/go/dataflow v0.10.1/go.mod h1:zP4/tNjONFRcS4NcI9R94YDQEkPalimdbPkijVNJt/g= -cloud.google.com/go/dataform v0.10.1 h1:FkOPrxf8sN9J2TMc4CIBhVivhMiO8D0eYN33s5A5Uo4= -cloud.google.com/go/dataform v0.10.1/go.mod h1:c5y0hIOBCfszmBcLJyxnELF30gC1qC/NeHdmkzA7TNQ= -cloud.google.com/go/datafusion v1.8.1 h1:QqiQs3mSXl4gfeHGOTbK0v1y+tUOnxWJgXm6YWvoqY0= -cloud.google.com/go/datafusion v1.8.1/go.mod h1:I5+nRt6Lob4g1eCbcxP4ayRNx8hyOZ8kA3PB/vGd9Lo= -cloud.google.com/go/datalabeling v0.9.1 h1:FrnZKagECxQy1bL+GQ1bjgwK9+szi1l7gqw7zp+Raqs= -cloud.google.com/go/datalabeling v0.9.1/go.mod h1:umplHuZX+x5DItNPV5BFBXau5TDsljLNzEj5AB5uRUM= -cloud.google.com/go/dataplex v1.19.1 h1:0pgI0DwijXZq8vyLuGnQXSi9JB6eUaVqzpzhN2veUeE= -cloud.google.com/go/dataplex v1.19.1/go.mod h1:WzoQ+vcxrAyM0cjJWmluEDVsg7W88IXXCfuy01BslKE= -cloud.google.com/go/dataproc/v2 v2.9.0 h1:9fSMjWgFKQfmfKu7V10C5foxU/2iDa8bVkiBB8uh1EU= -cloud.google.com/go/dataproc/v2 v2.9.0/go.mod h1:i4365hSwNP6Bx0SAUnzCC6VloeNxChDjJWH6BfVPcbs= -cloud.google.com/go/dataqna v0.9.1 h1:ptKKT+CNwp9Q+9Zxr+npUO7qUwKfyq/oF7/nS7CC6sc= -cloud.google.com/go/dataqna v0.9.1/go.mod h1:86DNLE33yEfNDp5F2nrITsmTYubMbsF7zQRzC3CcZrY= -cloud.google.com/go/datastore v1.19.0 h1:p5H3bUQltOa26GcMRAxPoNwoqGkq5v8ftx9/ZBB35MI= -cloud.google.com/go/datastore v1.19.0/go.mod h1:KGzkszuj87VT8tJe67GuB+qLolfsOt6bZq/KFuWaahc= -cloud.google.com/go/datastream v1.11.1 h1:YKY2qGKoxPpAvsDMtmJlIwL59SzhEm1DHM2uM4ib0TY= -cloud.google.com/go/datastream v1.11.1/go.mod h1:a4j5tnptIxdZ132XboR6uQM/ZHcuv/hLqA6hH3NJWgk= -cloud.google.com/go/deploy v1.23.0 h1:Bmh5UYEeakXtjggRkjVIawXfSBbQsTgDlm96pCw9D3k= -cloud.google.com/go/deploy v1.23.0/go.mod h1:O7qoXcg44Ebfv9YIoFEgYjPmrlPsXD4boYSVEiTqdHY= -cloud.google.com/go/dialogflow v1.58.0 h1:RTpoVCJHkgNLK8Co/f7F8ipyg3h8fJIaQzdaAbyg788= -cloud.google.com/go/dialogflow v1.58.0/go.mod h1:sWcyFLdUrg+TWBJVq/OtwDyjcyDOfirTF0Gx12uKy7o= -cloud.google.com/go/dlp v1.19.0 h1:AJB26PpDG0gOkf6wxQqbBXs9G+jOVnCjCagOlNiroKM= -cloud.google.com/go/dlp v1.19.0/go.mod h1:cr8dKBq8un5LALiyGkz4ozcwzt3FyTlOwA4/fFzJ64c= -cloud.google.com/go/documentai v1.34.0 h1:gmBmrTLzbpZkllu2xExISZg2Hh/ai0y605SWdheWHvI= -cloud.google.com/go/documentai v1.34.0/go.mod h1:onJlbHi4ZjQTsANSZJvW7fi2M8LZJrrupXkWDcy4gLY= -cloud.google.com/go/domains v0.10.1 h1:HvZOm7Bx1fQY/MHQAbE5f8YwfJlc0NJVOGh0A0eWckc= -cloud.google.com/go/domains v0.10.1/go.mod h1:RjDl3K8iq/ZZHMVqfZzRuBUr5t85gqA6LEXQBeBL5F4= -cloud.google.com/go/edgecontainer v1.3.1 h1:loDGWu/sdqnCP3Xlvj4OWHL7i0wocbcLg8ApQ9BE66E= -cloud.google.com/go/edgecontainer v1.3.1/go.mod h1:qyz5+Nk/UAs6kXp6wiux9I2U4A2R624K15QhHYovKKM= -cloud.google.com/go/errorreporting v0.3.1 h1:E/gLk+rL7u5JZB9oq72iL1bnhVlLrnfslrgcptjJEUE= -cloud.google.com/go/errorreporting v0.3.1/go.mod h1:6xVQXU1UuntfAf+bVkFk6nld41+CPyF2NSPCyXE3Ztk= -cloud.google.com/go/essentialcontacts v1.7.1 h1:qeZAOxqWFfD7sDd1vKYaNhjGh1eckkCkSJyx/OC5egE= -cloud.google.com/go/essentialcontacts v1.7.1/go.mod h1:F/MMWNLRW7b42WwWklOsnx4zrMOWDYWqWykBf1jXKPY= -cloud.google.com/go/eventarc v1.14.1 h1:Tw1DsE1OO9NZ3LZlAtxsi4otVl5qjQ3Y3QD9dCxtAyo= -cloud.google.com/go/eventarc v1.14.1/go.mod h1:NG0YicE+z9MDcmh2u4tlzLDVLRjq5UHZlibyQlPhcxY= -cloud.google.com/go/filestore v1.9.1 h1:s8DPPSV80FzIB7rduoMJAgknktms9hZGE3+X9KFUlK8= -cloud.google.com/go/filestore v1.9.1/go.mod h1:g/FNHBABpxjL1M9nNo0nW6vLYIMVlyOKhBKtYGgcKUI= -cloud.google.com/go/firestore v1.17.0 h1:iEd1LBbkDZTFsLw3sTH50eyg4qe8eoG6CjocmEXO9aQ= -cloud.google.com/go/firestore v1.17.0/go.mod h1:69uPx1papBsY8ZETooc71fOhoKkD70Q1DwMrtKuOT/Y= -cloud.google.com/go/functions v1.19.1 h1:eWjTZohtJX/9rckZYXaYVViGi06JkNJRKvm0aO+ce+g= -cloud.google.com/go/functions v1.19.1/go.mod h1:18RszySpwRg6aH5UTTVsRfdCwDooSf/5mvSnU7NAk4A= -cloud.google.com/go/gkebackup v1.6.1 h1:bV1go067LF5XaobFXXvgW2rsuvR974ajirDjD9oXFWg= -cloud.google.com/go/gkebackup v1.6.1/go.mod h1:CEnHQCsNBn+cyxcxci0qbAPYe8CkivNEitG/VAZ08ms= -cloud.google.com/go/gkeconnect v0.11.1 h1:X7UpDP2Qg8JfaQ6vsJeFsTo4NcrGprk9Tg4Pf7MK8Qg= -cloud.google.com/go/gkeconnect v0.11.1/go.mod h1:Vu3UoOI2c0amGyv4dT/EmltzscPH41pzS4AXPqQLej0= -cloud.google.com/go/gkehub v0.15.1 h1:VMXUz3q9Vfhe+dtSjb/yqmiDmGbcEUTuXDyk0pj2GyU= -cloud.google.com/go/gkehub v0.15.1/go.mod h1:cyUwa9iFQYd/pI7IQYl6A+OF6M8uIbhmJr090v9Z4UU= -cloud.google.com/go/gkemulticloud v1.4.0 h1:t2HXXYrICui+rZXScietjU1YdrQDLXpfqqrTo7zWSYQ= -cloud.google.com/go/gkemulticloud v1.4.0/go.mod h1:rg8YOQdRKEtMimsiNCzZUP74bOwImhLRv9wQ0FwBUP4= -cloud.google.com/go/gsuiteaddons v1.7.1 h1:YLh58kzaK+1Q/CHe8Cjp3hf9ZjNdJkQMavjrJUDgi9o= -cloud.google.com/go/gsuiteaddons v1.7.1/go.mod h1:SxM63xEPFf0p/plgh4dP82mBSKtp2RWskz5DpVo9jh8= -cloud.google.com/go/iam v1.2.1 h1:QFct02HRb7H12J/3utj0qf5tobFh9V4vR6h9eX5EBRU= -cloud.google.com/go/iam v1.2.1/go.mod h1:3VUIJDPpwT6p/amXRC5GY8fCCh70lxPygguVtI0Z4/g= -cloud.google.com/go/iap v1.10.1 h1:YF4jmMwEWXYrbfZZz024ozBXnWxUxJHzmkM6ccIzM0A= -cloud.google.com/go/iap v1.10.1/go.mod h1:UKetCEzOZ4Zj7l9TSN/wzRNwbgIYzm4VM4bStaQ/tFc= -cloud.google.com/go/ids v1.5.1 h1:UkHpZnlW46WulDVNtzKN+SEntZoOoHoG/Ob1GtuVCGQ= -cloud.google.com/go/ids v1.5.1/go.mod h1:d/9jTtY506mTxw/nHH3UN4TFo80jhAX+tESwzj42yFo= -cloud.google.com/go/iot v1.8.1 h1:PySjOJ2Nni1IDk0LqcNhUCKOGe0yPP4rM/Nc5yA/cjI= -cloud.google.com/go/iot v1.8.1/go.mod h1:FNceQ9/EGvbE2az7RGoGPY0aqrsyJO3/LqAL0h83fZw= -cloud.google.com/go/kms v1.20.0 h1:uKUvjGqbBlI96xGE669hcVnEMw1Px/Mvfa62dhM5UrY= -cloud.google.com/go/kms v1.20.0/go.mod h1:/dMbFF1tLLFnQV44AoI2GlotbjowyUfgVwezxW291fM= -cloud.google.com/go/language v1.14.1 h1:lyBks2W2k7bVPvfEECH08eMOP3Vd7zkHCATt/Vy0sLM= -cloud.google.com/go/language v1.14.1/go.mod h1:WaAL5ZdLLBjiorXl/8vqgb6/Fyt2qijl96c1ZP/vdc8= -cloud.google.com/go/lifesciences v0.10.1 h1:sGTR+IW9I85VhP789GMHNYOyCo7dkmvWRYh0uOfmWdo= -cloud.google.com/go/lifesciences v0.10.1/go.mod h1:5D6va5/Gq3gtJPKSsE6vXayAigfOXK2eWLTdFUOTCDs= -cloud.google.com/go/logging v1.12.0 h1:ex1igYcGFd4S/RZWOCU51StlIEuey5bjqwH9ZYjHibk= -cloud.google.com/go/logging v1.12.0/go.mod h1:wwYBt5HlYP1InnrtYI0wtwttpVU1rifnMT7RejksUAM= -cloud.google.com/go/longrunning v0.6.1 h1:lOLTFxYpr8hcRtcwWir5ITh1PAKUD/sG2lKrTSYjyMc= -cloud.google.com/go/longrunning v0.6.1/go.mod h1:nHISoOZpBcmlwbJmiVk5oDRz0qG/ZxPynEGs1iZ79s0= -cloud.google.com/go/managedidentities v1.7.1 h1:9hC4E7JnWn/jSUls022Sj9ri+vriGnLzvDXo0cs1zcA= -cloud.google.com/go/managedidentities v1.7.1/go.mod h1:iK4qqIBOOfePt5cJR/Uo3+uol6oAVIbbG7MGy917cYM= -cloud.google.com/go/maps v1.14.0 h1:bLT2nvuOm4ye6YRgIJQ0L9zbKcbBj+TCg8k2g3c2Qlk= -cloud.google.com/go/maps v1.14.0/go.mod h1:UepOes9un0UP7i8JBiaqgh8jqUaZAHVRXCYjrVlhSC8= -cloud.google.com/go/mediatranslation v0.9.1 h1:7X1cA4TWO0+r1RT0JTT0RE+SyO41eoFUmBDw17Oi9T8= -cloud.google.com/go/mediatranslation v0.9.1/go.mod h1:vQH1amULNhSGryBjbjLb37g54rxrOwVxywS8WvUCsIU= -cloud.google.com/go/memcache v1.11.1 h1:2FGuyd3WY7buNDAkMBdmeIOheNWA3gwaXrttLrEdabI= -cloud.google.com/go/memcache v1.11.1/go.mod h1:3zF+dEqmEmElHuO4NtHiShekQY5okQtssjPBv7jpmZ8= -cloud.google.com/go/metastore v1.14.1 h1:kGx+IUSSYCVn8LisCT4fpxCC9rauEVonzi7RlygdqWY= -cloud.google.com/go/metastore v1.14.1/go.mod h1:WDvsAcbQLl9M4xL+eIpbKogH7aEaPWMhO9aRBcFOnJE= -cloud.google.com/go/monitoring v1.21.1 h1:zWtbIoBMnU5LP9A/fz8LmWMGHpk4skdfeiaa66QdFGc= -cloud.google.com/go/monitoring v1.21.1/go.mod h1:Rj++LKrlht9uBi8+Eb530dIrzG/cU/lB8mt+lbeFK1c= -cloud.google.com/go/networkconnectivity v1.15.1 h1:EizN+cFGHzRAyiFTK8jT1PqTo+cSnbc2IGh6OmllS7Y= -cloud.google.com/go/networkconnectivity v1.15.1/go.mod h1:tYAcT4Ahvq+BiePXL/slYipf/8FF0oNJw3MqFhBnSPI= -cloud.google.com/go/networkmanagement v1.14.1 h1:0x3hVI6xbp3N/choffKPHMSxbzaPdHSD92cBElebXEk= -cloud.google.com/go/networkmanagement v1.14.1/go.mod h1:3Ds8FZ3ZHjTVEedsBoZi9ef9haTE14iS6swTSqM39SI= -cloud.google.com/go/networksecurity v0.10.1 h1:dHN1la6xnta3E4QtWGqtc8ZAPKIZH5m8UQceIIuXZIs= -cloud.google.com/go/networksecurity v0.10.1/go.mod h1:tatO1hYJ9nNChLHOFdsjex5FeqZBlPQgKdKOex7REpU= -cloud.google.com/go/notebooks v1.12.1 h1:0g61C2qdWcq2p8OFH3NiLzyneS1LFfsveC5+MnpM4p8= -cloud.google.com/go/notebooks v1.12.1/go.mod h1:RJCyRkLjj8UnvLEKaDl9S6//xUCa+r+d/AsxZnYBl50= -cloud.google.com/go/optimization v1.7.1 h1:E3/1qRZvGxqQpapaac/EKuzusxUauXLnpirWWXXzP5k= -cloud.google.com/go/optimization v1.7.1/go.mod h1:s2AjwwQEv6uExFmgS4Bf1gidI07w7jCzvvs8exqR1yk= -cloud.google.com/go/orchestration v1.11.0 h1:yyi0kM47UZaJ3EEFYsBwfrkvqyPmvHwsoc3asxDmLuo= -cloud.google.com/go/orchestration v1.11.0/go.mod h1:s3L89jinQaUHclqgWYw8JhBbzGSidVt5rVBxGrXeheI= -cloud.google.com/go/orgpolicy v1.14.0 h1:UuLmi1+94lIS3tCoeuinuwx4oxdx58nECiAvfwCW0SM= -cloud.google.com/go/orgpolicy v1.14.0/go.mod h1:S6Pveh1JOxpSbs6+2ToJG7h3HwqC6Uf1YQ6JYG7wdM8= -cloud.google.com/go/osconfig v1.14.1 h1:67ISL0vZVfq0se+1cPRMYgwTjsES2k9vmSmn8ZS0O5g= -cloud.google.com/go/osconfig v1.14.1/go.mod h1:Rk62nyQscgy8x4bICaTn0iWiip5EpwEfG2UCBa2TP/s= -cloud.google.com/go/oslogin v1.14.1 h1:HPPg7FWPwt7pKrbl+8VFI9UuJTbVrG2rSMHl4HkDAG4= -cloud.google.com/go/oslogin v1.14.1/go.mod h1:mM/isJYnohyD3EfM12Fhy8uye46gxA1WjHRCwbkmlVw= -cloud.google.com/go/phishingprotection v0.9.1 h1:oUEGd4dttG5gIUmICdCh8A1U9iVQiw0TGwvYIGQ2I7U= -cloud.google.com/go/phishingprotection v0.9.1/go.mod h1:LRiflQnCpYKCMhsmhNB3hDbW+AzQIojXYr6q5+5eRQk= -cloud.google.com/go/policytroubleshooter v1.11.1 h1:/b3wruB/KvmCpy9Jfducc8TQmM3bsoPaeCs5z7TRodA= -cloud.google.com/go/policytroubleshooter v1.11.1/go.mod h1:9nJIpgQ2vloJbB8y1JkPL5vxtaSdJnJYPCUvt6PpfRs= -cloud.google.com/go/privatecatalog v0.10.1 h1:Ew51FHLLQsUYUDJY57eMB/mVUOoWLIji957MRw4kumw= -cloud.google.com/go/privatecatalog v0.10.1/go.mod h1:mFmn5bjE9J8MEjQuu1fOc4AxOP2MoEwDLMJk04xqQCQ= -cloud.google.com/go/pubsub v1.44.0 h1:pLaMJVDTlnUDIKT5L0k53YyLszfBbGoUBo/IqDK/fEI= -cloud.google.com/go/pubsub v1.44.0/go.mod h1:BD4a/kmE8OePyHoa1qAHEw1rMzXX+Pc8Se54T/8mc3I= -cloud.google.com/go/pubsublite v1.8.2 h1:jLQozsEVr+c6tOU13vDugtnaBSUy/PD5zK6mhm+uF1Y= -cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI= -cloud.google.com/go/recaptchaenterprise/v2 v2.17.2 h1:tHFLYu+8w0jjjGf63D4qgVEKS9R3lw4XP4Q1P4df2g8= -cloud.google.com/go/recaptchaenterprise/v2 v2.17.2/go.mod h1:iigNZOnUpf++xlm8RdMZJTX/PihYVMrHidRLjHuekec= -cloud.google.com/go/recommendationengine v0.9.1 h1:TQne3UMow6joFVRtTpd9kDYyYr3Jkpq+o0vJkpQgZYI= -cloud.google.com/go/recommendationengine v0.9.1/go.mod h1:FfWa3OnsnDab4unvTZM2VJmvoeGn1tnntF3n+vmfyzU= -cloud.google.com/go/recommender v1.13.1 h1:aQIUpMynK1pU1Q+EiuL7VJssLLjLwnfhL7px0vgM6xA= -cloud.google.com/go/recommender v1.13.1/go.mod h1:l+n8rNMC6jZacckzLvVG/2LzKawlwAJYNO8Vl2pBlxc= -cloud.google.com/go/redis v1.17.1 h1:E7TeGsvyoFB+m59bqFKrQ5GSH7+uW8cUDk6Y7iqGjJ0= -cloud.google.com/go/redis v1.17.1/go.mod h1:YJHeYfSoW/agIMeCvM5rszxu75mVh5DOhbu3AEZEIQM= -cloud.google.com/go/resourcemanager v1.10.1 h1:fO/QoSJ1lepmTM9dCbSXYWgTIhecmQkpY0mM1X9OGN0= -cloud.google.com/go/resourcemanager v1.10.1/go.mod h1:A/ANV/Sv7y7fcjd4LSH7PJGTZcWRkO/69yN5UhYUmvE= -cloud.google.com/go/resourcesettings v1.8.1 h1:whJgmR9I5V9TSZiaoCPVDgbYD1jghYoauHVfBG8TvHI= -cloud.google.com/go/resourcesettings v1.8.1/go.mod h1:6V87tIXUpvJMskim6YUa+TRDTm7v6OH8FxLOIRYosl4= -cloud.google.com/go/retail v1.19.0 h1:OrXxtP/asKi7vFReWmQH5kXrMRPZ2R9Zw92x8O93PMA= -cloud.google.com/go/retail v1.19.0/go.mod h1:QMhO+nkvN6Mns1lu6VXmteY0I3mhwPj9bOskn6PK5aY= -cloud.google.com/go/run v1.6.0 h1:LRJvntufFKJ0Jcwt7BbIHwf/0Ipq4twzyJcH1qSEs84= -cloud.google.com/go/run v1.6.0/go.mod h1:DXkPPa8bZ0jfRGLT+EKIlPbHvosBYBMdxTgo9EBbXZE= -cloud.google.com/go/scheduler v1.11.1 h1:uGaM4mRrGkJ0LLBMyxD8qbvIko4y+UlSOwJQqRd/lW8= -cloud.google.com/go/scheduler v1.11.1/go.mod h1:ptS76q0oOS8hCHOH4Fb/y8YunPEN8emaDdtw0D7W1VE= -cloud.google.com/go/secretmanager v1.14.1 h1:xlWSIg8rtBn5qCr2f3XtQP19+5COyf/ll49SEvi/0vM= -cloud.google.com/go/secretmanager v1.14.1/go.mod h1:L+gO+u2JA9CCyXpSR8gDH0o8EV7i/f0jdBOrUXcIV0U= -cloud.google.com/go/security v1.18.1 h1:w7XbMR90Ir0y8NUxKJ3uyRHuHYWPUxVI5Z/sGqbrdAQ= -cloud.google.com/go/security v1.18.1/go.mod h1:5P1q9rqwt0HuVeL9p61pTqQ6Lgio1c64jL2ZMWZV21Y= -cloud.google.com/go/securitycenter v1.35.1 h1:unUyFDeSHv89W7FPBMk10mf3R7+taAJ+1ow+0zpCzGw= -cloud.google.com/go/securitycenter v1.35.1/go.mod h1:UDeknPuHWi15TaxrJCIv3aN1VDTz9nqWVUmW2vGayTo= -cloud.google.com/go/servicedirectory v1.12.1 h1:LjbIXEZiyqsIADrj6Y81FnbSlaHPQHJ8UDQQnUegowc= -cloud.google.com/go/servicedirectory v1.12.1/go.mod h1:d2H6joDMjnTQ4cUUCZn6k9NgZFbXjLVJbHETjoJR9k0= -cloud.google.com/go/shell v1.8.1 h1:etoJal+LB7Pn8+5vE2aAh6QcFbBmerIOh5MxNDoXykw= -cloud.google.com/go/shell v1.8.1/go.mod h1:jaU7OHeldDhTwgs3+clM0KYEDYnBAPevUI6wNLf7ycE= -cloud.google.com/go/spanner v1.70.0 h1:nj6p/GJTgMDiSQ1gQ034ItsKuJgHiMOjtOlONOg8PSo= -cloud.google.com/go/spanner v1.70.0/go.mod h1:X5T0XftydYp0K1adeJQDJtdWpbrOeJ7wHecM4tK6FiE= -cloud.google.com/go/speech v1.25.1 h1:iGZJS3wrdkje/Vqiacx1+r+zVwUZoXVMdklYIVsvfNw= -cloud.google.com/go/speech v1.25.1/go.mod h1:WgQghvghkZ1htG6BhYn98mP7Tg0mti8dBFDLMVXH/vM= -cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w= -cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= -cloud.google.com/go/storagetransfer v1.11.1 h1:Hd7H1zXGQGEWyWXxWVXDMuNCGasNQim1y9CIaMZIBX8= -cloud.google.com/go/storagetransfer v1.11.1/go.mod h1:xnJo9pWysRIha8MgZxhrBEwLYbEdvdmEedhNsP5NINM= -cloud.google.com/go/talent v1.7.1 h1:J3iZU+HPfoD18Lx8JsgIpwe8llQ9Fu/evcQudQCB+pk= -cloud.google.com/go/talent v1.7.1/go.mod h1:X8UKtTgcP+h51MtDO/b+y3X1GxTTc7gPJ2y0aX3X1hM= -cloud.google.com/go/texttospeech v1.8.1 h1:LpX9xKoGObltmT6+RGxqUeSJIq0uqPzo+fcbbOmujbY= -cloud.google.com/go/texttospeech v1.8.1/go.mod h1:WoTykB+4mfSDDYPuk7smrdXNRGoJJS6dXRR6l4XqD9g= -cloud.google.com/go/tpu v1.7.1 h1:MP2GYTVEPkg1KlhY3A4CF9Do8eklQOOfgbIYNINcVaE= -cloud.google.com/go/tpu v1.7.1/go.mod h1:kgvyq1Z1yuBJSk5ihUaYxX58YMioCYg1UPuIHSxBX3M= -cloud.google.com/go/trace v1.11.1 h1:UNqdP+HYYtnm6lb91aNA5JQ0X14GnxkABGlfz2PzPew= -cloud.google.com/go/trace v1.11.1/go.mod h1:IQKNQuBzH72EGaXEodKlNJrWykGZxet2zgjtS60OtjA= -cloud.google.com/go/translate v1.12.1 h1:Vws9BGpVcaOeI6HodyWdvysUzHUBFvk7ymHu1tzFvuM= -cloud.google.com/go/translate v1.12.1/go.mod h1:5f4RvC7/hh76qSl6LYuqOJaKbIzEpR1Sj+CMA6gSgIk= -cloud.google.com/go/video v1.23.1 h1:U+fu5Jwi3q8WDDOh1hr8kcdXVUJGmP3vWsZ13jwkWFA= -cloud.google.com/go/video v1.23.1/go.mod h1:ncFS3D2plMLhXkWkob/bH4bxQkubrpAlln5x7RWluXA= -cloud.google.com/go/videointelligence v1.12.1 h1:4XScHLWL/1Q1FVczlxiZT+kSynUQPUktIUTqpIkOMeU= -cloud.google.com/go/videointelligence v1.12.1/go.mod h1:C9bQom4KOeBl7IFPj+NiOS6WKEm1P6OOkF/ahFfE1Eg= -cloud.google.com/go/vision/v2 v2.9.1 h1:jpK/E7/SJXpbnQVgfr2nGsIIzSQ9GkOsBf2iak1O8nc= -cloud.google.com/go/vision/v2 v2.9.1/go.mod h1:keORalKMowhEZB5hEWi1XSVnGALMjLlRwZbDiCPFuQY= -cloud.google.com/go/vmmigration v1.8.1 h1:dyK3bFJVx28FInAkzeLVANpChwWgAmiaUM4GNtEQS/Q= -cloud.google.com/go/vmmigration v1.8.1/go.mod h1:MB7vpxl6Oz2w+CecyITUTDFkhWSMQmRTgREwkBZFyZk= -cloud.google.com/go/vmwareengine v1.3.1 h1:CCdTFQnOatMPbtbMnCja//K4slk5Tjt0u3XEb1T9Qlw= -cloud.google.com/go/vmwareengine v1.3.1/go.mod h1:mSYu3wnGKJqvvhIhs7VA47/A/kLoMiJz3gfQAh7cfaI= -cloud.google.com/go/vpcaccess v1.8.1 h1:e1wJ1wQGMqOf44Gw44PU9G6NYITKm0f2We4eKzMwyEs= -cloud.google.com/go/vpcaccess v1.8.1/go.mod h1:cWlLCpLOuMH8oaNmobaymgmLesasLd9w1isrKpiGwIc= -cloud.google.com/go/webrisk v1.10.1 h1:mYYjXXMILCwIEqtChUDNGamMBgJKnoJXa9Os2e76uzk= -cloud.google.com/go/webrisk v1.10.1/go.mod h1:VzmUIag5P6V71nVAuzc7Hu0VkIDKjDa543K7HOulH/k= -cloud.google.com/go/websecurityscanner v1.7.1 h1:VyJObL4Pzd4ypF2814rKlesrVibrf1WpZ2yp4jJvKyw= -cloud.google.com/go/websecurityscanner v1.7.1/go.mod h1:vAZ6hyqECDhgF+gyVRGzfXMrURQN5NH75Y9yW/7sSHU= -cloud.google.com/go/workflows v1.13.1 h1:DkxrZ4HyXvjQLZWsYAUOV1w7d2a43XscM9dmkIGmrDc= -cloud.google.com/go/workflows v1.13.1/go.mod h1:xNdYtD6Sjoug+khNCAtBMK/rdh8qkjyL6aBas2XlkNc= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUuKccphEpTJnWhS2jkQyMt6Y7pJCD7fY= -github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= -github.com/DataDog/datadog-go v3.2.0+incompatible h1:qSG2N4FghB1He/r2mFrWKCaL7dXCilEuNEeAn20fdD4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= -github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= -github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw= -github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= -github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= -github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= -github.com/Shopify/sarama v1.30.1 h1:z47lP/5PBw2UVKf1lvfS5uWXaJws6ggk9PLnKEHtZiQ= -github.com/Shopify/toxiproxy v2.1.4+incompatible h1:TKdv8HiTLgE5wdJuEML90aBgNWsokNbMijUGhmcoBJc= -github.com/Shopify/toxiproxy/v2 v2.1.6-0.20210914104332-15ea381dcdae h1:ePgznFqEG1v3AjMklnK8H7BSc++FDSo7xfK9K7Af+0Y= -github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE= -github.com/a-h/htmlformat v0.0.0-20250209131833-673be874c677 h1:H1EoxMpNo/TnCZEgSaKsomi+dQ05dXiVSKDN86Lap9A= -github.com/a-h/htmlformat v0.0.0-20250209131833-673be874c677/go.mod h1:FMIm5afKmEfarNbIXOaPHFY8X7fo+fRQB6I9MPG2nB0= -github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= -github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= -github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 h1:rFw4nCn9iMW+Vajsk51NtYIcwSTkXr+JGrMd36kTDJw= -github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af h1:wVe6/Ea46ZMeNkQjjBW6xcqyQA/j5e0D6GytH95g0gQ= -github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= -github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= -github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= -github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA= -github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= -github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= -github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= -github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= -github.com/aws/aws-sdk-go v1.40.45 h1:QN1nsY27ssD/JmW4s83qmSb+uL6DG4GmCDzjmJB4xUI= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= -github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1 h1:w/fPGB0t5rWwA43mux4e9ozFSH5zF1moQemlA131PWc= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 h1:lguz0bmOoGzozP9XfRJR1QIayEYo+2vP/No3OfLF0pU= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 h1:jIiopHEV22b4yQP2q36Y0OmwLbsxNWdWwfZRR5QRRO4= -github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= -github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c h1:+0HFd5KSZ/mm3JmhmrDukiId5iR6w4+BdFtfSy4yWIc= -github.com/c-bata/go-prompt v0.2.5 h1:3zg6PecEywxNn0xiqcXHD96fkbxghD+gdB2tbsYfl+Y= -github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2 h1:t8KYCwSKsOEZBFELI4Pn/phbp38iJ1RRAkDFNin1aak= -github.com/c2h5oh/datasize v0.0.0-20200825124411-48ed595a09d2/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= -github.com/casbin/casbin/v2 v2.37.0 h1:/poEwPSovi4bTOcP752/CsTQiRz2xycyVKFG7GUhbDw= -github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= -github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= -github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible h1:C29Ae4G5GtYyYMm1aztcyj/J5ckgJm2zwdDajFbx1NY= -github.com/circonus-labs/circonusllhist v0.1.3 h1:TJH+oke8D16535+jHExHj4nQvzlZrj7ug5D7I/orNUA= -github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= -github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= -github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= -github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= -github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= -github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M= -github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 h1:boJj011Hh+874zpIySeApCX4GeOjPl9qhRF3QuIZq+Q= -github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= -github.com/coreos/bbolt v1.3.2 h1:wZwiHHUieZCquLkDL0B8UhzreNWsPHooDAG3q34zk0s= -github.com/coreos/etcd v3.3.13+incompatible h1:8F3hqu9fGYLBifCmRCJsicFqDx/D68Rt3q1JMazcgBQ= -github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg= -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= -github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954 h1:RMLoZVzv4GliuWafOuPuQDKSm1SJph7uCRnnS61JAn4= -github.com/diskfs/go-diskfs v1.4.0 h1:MAybY6TPD+fmhY+a2qFhmdvMeIKvCqlgh4QIc1uCmBs= -github.com/diskfs/go-diskfs v1.4.0/go.mod h1:G8cyy+ngM+3yKlqjweMmtqvE+TxsnIo1xumbJX1AeLg= -github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= -github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= -github.com/eapache/go-resiliency v1.2.0 h1:v7g92e/KSN71Rq7vSThKaWIq68fL4YHvWyiUKorFR1Q= -github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21 h1:YEetp8/yCZMuEPMUDHG0CW/brkkEp8mzqk2+ODEitlw= -github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= -github.com/edsrzf/mmap-go v1.0.0 h1:CEBF7HpRnUCSJgGUb5h1Gm7e3VkmVDrR8lvWVLtrOFw= -github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY= -github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= -github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= -github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= -github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= -github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= -github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90 h1:WXb3TSNmHp2vHoCroCIB1foO/yQ36swABL8aOVeDpgg= -github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= -github.com/franela/goblin v0.0.0-20210519012713-85d372ac71e2 h1:cZqz+yOJ/R64LcKjNQOdARott/jP7BnUQ9Ah7KaZCvw= -github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8 h1:a9ENSRDFBUPkJ5lCgVZh26+ZbGyoVJG7yb5SSzF5H54= -github.com/go-cmd/cmd v1.0.5 h1:IK23uTRWxq6UJnNWp8nKO7mVCwnPfbaxA2lhzEKfNj0= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= -github.com/go-kit/kit v0.12.0 h1:e4o3o3IsBfAKQh5Qbbiqyfu97Ku7jrO/JbohvztANh4= -github.com/go-kit/log v0.2.0 h1:7i2K3eKTos3Vc0enKCfnVcgHh2olr/MyfboYq7cAcFw= -github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= -github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= -github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= -github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= -github.com/go-zookeeper/zk v1.0.2 h1:4mx0EYENAdX/B/rbunjlt5+4RTA/a9SMHBRuSKdGxPM= -github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d h1:lBXNCxVENCipq4D1Is42JVOP4eQjlB8TQ6H69Yx5J9Q= -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= -github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc= -github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II= -github.com/google/go-pkcs11 v0.3.0 h1:PVRnTgtArZ3QQqTGtbtjtnIkzl2iY2kt24yqbrf7td8= -github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= -github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= -github.com/google/martian/v3 v3.0.0 h1:pMen7vLs8nvgEYhywH3KDWJIJTeEr2ULsVWHWYHQyBs= -github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= -github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720 h1:zC34cGQu69FG7qzJ3WiKW244WfhDC3xxYMeNOX2gtUQ= -github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= -github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= -github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= -github.com/grpc-ecosystem/go-grpc-middleware v1.0.0 h1:Iju5GlWwrvL6UBg4zJJt3btmonfrMlCDdsejg4CZE7c= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= -github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= -github.com/hashicorp/consul/api v1.25.1 h1:CqrdhYzc8XZuPnhIYZWH45toM0LB9ZeYr/gvpLVI3PE= -github.com/hashicorp/consul/api v1.25.1/go.mod h1:iiLVwR/htV7mas/sy0O+XSuEnrdBUUydemjxcUrAt4g= -github.com/hashicorp/consul/sdk v0.8.0 h1:OJtKBtEjboEZvG6AOUdh4Z1Zbyu0WcxQ0qatRrZHTVU= -github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= -github.com/hashicorp/go-msgpack v0.5.3 h1:zKjpN5BK/P5lMYrLmBHdBULWbJ0XpYR+7NGzqkZzoD4= -github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= -github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= -github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go.net v0.0.1 h1:sNCoNyDEvN1xa+X0baata4RdcpKwcMS6DH+xwfqPgjw= -github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= -github.com/hashicorp/hc-install v0.6.2 h1:V1k+Vraqz4olgZ9UzKiAcbman9i9scg9GgSt/U3mw/M= -github.com/hashicorp/hc-install v0.6.2/go.mod h1:2JBpd+NCFKiHiu/yYCGaPyPHhZLxXTpz8oreHa/a3Ps= -github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= -github.com/hashicorp/mdns v1.0.1 h1:XFSOubp8KWB+Jd2PDyaX5xUd5bhSP/+pTDZVDMzZJM8= -github.com/hashicorp/memberlist v0.2.2 h1:5+RffWKwqJ71YPu9mWsF7ZOscZmwfasdA8kbdC7AO2g= -github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= -github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= -github.com/hashicorp/terraform-exec v0.19.0 h1:FpqZ6n50Tk95mItTSS9BjeOVUb4eg81SpgVtZNNtFSM= -github.com/hashicorp/terraform-exec v0.19.0/go.mod h1:tbxUpe3JKruE9Cuf65mycSIT8KiNPZ0FkuTE3H4urQg= -github.com/hashicorp/terraform-json v0.17.1 h1:eMfvh/uWggKmY7Pmb3T85u86E2EQg6EQHgyRwf3RkyA= -github.com/hashicorp/terraform-json v0.17.1/go.mod h1:Huy6zt6euxaY9knPAFKjUITn8QxUFIe9VuSzb4zn/0o= -github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= -github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/hudl/fargo v1.4.0 h1:ZDDILMbB37UlAVLlWcJ2Iz1XuahZZTDZfdCKeclfq2s= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639 h1:mV02weKRL81bEnm8A0HT1/CAelMQDBuQIfLw8n+d6xI= -github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab h1:HqW4xhhynfjrtEiiSGcQUd6vrK23iMam1FO8rI7mwig= -github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= -github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= -github.com/jcmturner/gofork v1.0.0 h1:J7uCkflzTEhUZ64xqKnkDxq3kzc96ajM1Gli5ktUem8= -github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= -github.com/jcmturner/gokrb5/v8 v8.4.2 h1:6ZIM6b/JJN0X8UM43ZOM6Z4SJzla+a/u7scXFJzodkA= -github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= -github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= -github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= -github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= -github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5 h1:PJr+ZMXIecYc1Ey2zucXdR73SMBtgjPgwa31099IMv0= -github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= -github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= -github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= -github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= -github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= -github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= -github.com/liquidweb/go-lwApi v0.0.0-20190605172801-52a4864d2738 h1:5s07v4iHDpnXZiikAGltqdcCDUchV/LiwleQu2K2r/o= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= -github.com/maxbrunsfeld/counterfeiter/v6 v6.8.1 h1:NicmruxkeqHjDv03SfSxqmaLuisddudfP3h5wdXFbhM= -github.com/maxbrunsfeld/counterfeiter/v6 v6.8.1/go.mod h1:eyp4DdUJAKkr9tvxR3jWhw2mDK7CWABMG5r9uyaKC7I= -github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= -github.com/mitchellh/cli v1.1.0 h1:tEElEatulEHDeedTxwckzyYMA5c86fbmNIUL1hBIiTg= -github.com/mitchellh/gox v0.4.0 h1:lfGJxY7ToLJQjHHwi0EX6uYBdK78egf954SQl13PQJc= -github.com/mitchellh/iochan v1.0.0 h1:C+X3KsSTLFVBr/tK1eYN/vs4rJcvsiLU338UhYPJWeY= -github.com/montanaflynn/stats v0.7.0 h1:r3y12KyNxj/Sb/iOE46ws+3mS1+MZca1wlHQFPsY/JU= -github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= -github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= -github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= -github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= -github.com/nats-io/jwt v1.2.2 h1:w3GMTO969dFg+UOKTmmyuu7IGdusK+7Ytlt//OYH/uU= -github.com/nats-io/jwt/v2 v2.0.3 h1:i/O6cmIsjpcQyWDYNcq2JyZ3/VTF8SJ4JWluI5OhpvI= -github.com/nats-io/nats-server/v2 v2.5.0 h1:wsnVaaXH9VRSg+A2MVg5Q727/CqxnmPLGFQ3YZYKTQg= -github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E= -github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8= -github.com/nats-io/nkeys v0.4.6 h1:IzVe95ru2CT6ta874rt9saQRkWfe2nFj1NtvYSLqMzY= -github.com/nats-io/nkeys v0.4.6/go.mod h1:4DxZNzenSVd1cYQoAa8948QY3QDjrHfcfVADymtkpts= -github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= -github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88= -github.com/openzipkin/zipkin-go v0.2.5 h1:UwtQQx2pyPIgWYHRg+epgdx1/HnBQTgN3/oIYEJTQzU= -github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= -github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= -github.com/performancecopilot/speed/v4 v4.0.0 h1:VxEDCmdkfbQYDlcr/GC9YoN9PQ6p8ulk9xVsepYy9ZY= -github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= -github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc= -github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/profile v1.2.1 h1:F++O52m40owAmADcojzM+9gyjmMOY/T4oYJkgFDH8RE= -github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= -github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= -github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk= -github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE= -github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= -github.com/prometheus/client_golang v1.11.0 h1:HNkLOAEQMIDv/K+04rukrLx6ch7msSRwf3/SASFAGtQ= -github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= -github.com/prometheus/common v0.30.0 h1:JEkYlQnpzrzQFxi6gnukFPdQ+ac82oRhzMcIduJu/Ug= -github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= -github.com/prometheus/tsdb v0.7.1 h1:YZcsG11NqnK4czYLrWd9mpEuAJIHVQLwdrleYfszMAA= -github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 h1:dq90+d51/hQRaHEqRAsQ1rE/pC1GUS4sc2rCbbFsAIY= -github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2/go.mod h1:7tZKcyumwBO6qip7RNQ5r77yrssm9bfCowcLEBcU5IA= -github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= -github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= -github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f h1:UFr9zpz4xgTnIE5yIMtWAMngCdZ9p/+q6lTbgelo80M= -github.com/sagikazarmark/crypt v0.17.0 h1:ZA/7pXyjkHoK4bW4mIdnCLvL8hd+Nrbiw7Dqk7D4qUk= -github.com/sagikazarmark/crypt v0.17.0/go.mod h1:SMtHTvdmsZMuY/bpZoqokSoChIrcJ/epOxZN58PbZDg= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= -github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= -github.com/soheilhy/cmux v0.1.4 h1:0HKaf1o97UwFjHH9o5XsHUOF+tqmdA7KEzXLpiyaw0E= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= -github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= -github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo= -github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e h1:mOtuXaRAbVZsxAHVdPR3IjfmN8T1h2iczJLynhLybf8= -github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5 h1:LnC5Kc/wtumK+WB441p7ynQJzVuNRJiqddSIE3IlSEQ= -github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926 h1:G3dpKMzFDjgEh2q1Z7zUUtKa8ViPtH+ocF0bE0g00O8= -github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= -github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= -github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= -github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= -github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= -github.com/zclconf/go-cty v1.14.0 h1:/Xrd39K7DXbHzlisFP9c4pHao4yyf+/Ug9LEz+Y/yhc= -github.com/zclconf/go-cty v1.14.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= -go.etcd.io/bbolt v1.3.2 h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk= -go.etcd.io/etcd/api/v3 v3.5.10 h1:szRajuUUbLyppkhs9K6BRtjY37l66XQQmw7oZRANE4k= -go.etcd.io/etcd/api/v3 v3.5.10/go.mod h1:TidfmT4Uycad3NM/o25fG3J07odo4GBB9hoxaodFCtI= -go.etcd.io/etcd/client/pkg/v3 v3.5.10 h1:kfYIdQftBnbAq8pUWFXfpuuxFSKzlmM5cSn76JByiT0= -go.etcd.io/etcd/client/pkg/v3 v3.5.10/go.mod h1:DYivfIviIuQ8+/lCq4vcxuseg2P2XbHygkKwFo9fc8U= -go.etcd.io/etcd/client/v2 v2.305.10 h1:MrmRktzv/XF8CvtQt+P6wLUlURaNpSDJHFZhe//2QE4= -go.etcd.io/etcd/client/v2 v2.305.10/go.mod h1:m3CKZi69HzilhVqtPDcjhSGp+kA1OmbNn0qamH80xjA= -go.etcd.io/etcd/client/v3 v3.5.10 h1:W9TXNZ+oB3MCd/8UjxHTWK5J9Nquw9fQBLJd5ne5/Ao= -go.etcd.io/etcd/client/v3 v3.5.10/go.mod h1:RVeBnDz2PUEZqTpgqwAtUd8nAPf5kjyFyND7P1VkOKc= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= -go.opentelemetry.io/proto/otlp v0.7.0 h1:rwOQPCuKAKmwGKq2aVNnYIibI6wnV7EvzgfTCzcdGg8= -go.uber.org/goleak v1.1.11-0.20210813005559-691160354723 h1:sHOAIxRGBp443oHZIPB+HsUGaksVCXVQENPxwTfQdH4= -go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= -go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk= -golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -gonum.org/v1/gonum v0.8.2 h1:CCXrcPKiGGotvnN6jfUsKk4rRqm7q09/YbKb5xCEvtM= -gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc= -gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b h1:Qh4dB5D/WpoUUp3lSod7qgoyEHbDGPUWjIbnqdqqe1k= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto/googleapis/bytestream v0.0.0-20250313205543-e70fdf4c4cb4 h1:WYmu3W5hpq5LblAdrydghP6bWFowtV6EYG+RCT0lok4= -google.golang.org/genproto/googleapis/bytestream v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:WkJpQl6Ujj3ElX4qZaNm5t6cT95ffI4K+HKQ0+1NyMw= -gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/djherbis/times.v1 v1.3.0 h1:uxMS4iMtH6Pwsxog094W0FYldiNnfY/xba00vq6C2+o= -gopkg.in/djherbis/times.v1 v1.3.0/go.mod h1:AQlg6unIsrsCEdQYhTzERy542dz6SFdQFZFv6mUY0P8= -gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= -gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= -gopkg.in/gcfg.v1 v1.2.3 h1:m8OOJ4ccYHnx2f4gQwpno8nAX5OGOh7RLaaz0pj3Ogs= -gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= -gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= -rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= -rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= -rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY= -rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4= -sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= -sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= -sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= -software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= -software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..db30c97 --- /dev/null +++ b/roadmap.md @@ -0,0 +1,156 @@ +# roadmap + +## done + +- HTTP tunneling over QUIC with per-request streams +- WebSocket forwarding +- custom tunnel IDs (subdomains) +- auto-reconnection with exponential backoff +- API key auth with scopes (`tunnels`, `management`) +- OAuth login (GitHub + generic OIDC) +- teams, roles, user management +- session tracking and bandwidth metrics +- TLS with automatic Let's Encrypt (DNS-01 via Cloudflare, Route53, exec provider) +- Prometheus metrics +- proxy headers (`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`) +- dual database backend (PostgreSQL, embedded Turso) +- REST API with OpenAPI spec +- NixOS module with systemd hardening +- Home Manager module with sops-nix support +- OCI container images +- multi-arch releases (linux/macOS, amd64/arm64) +- install script with checksum verification + +## project config file + +a `funnel.toml` in the project root that declares tunnels. `funnel up` reads it and starts everything. + +```toml +[tunnels.api] +port = 3000 +id = "my-api" +team = "backend" + +[tunnels.frontend] +port = 5173 +id = "my-frontend" +``` + +committable to the repo so anyone on the team gets the same tunnel setup with one command. + +## multiple tunnels per client + +run multiple tunnels from a single process, configured via `funnel.toml` or CLI args. both approaches share a single QUIC connection to the server. + +```bash +funnel http 3000 --id api -- http 5173 --id frontend +``` + +## shell completions + +```bash +funnel completion bash > /etc/bash_completion.d/funnel +funnel completion zsh > ~/.zfunc/_funnel +funnel completion fish > ~/.config/fish/completions/funnel.fish +``` + +also generated in the Home Manager module. + +## self-update + +```bash +funnel update +funnel update --check +``` + +checks GitHub releases. prints a notice on `funnel http` when a new version is available, suppressible via config or `FUNNEL_NO_UPDATE_CHECK`. no silent auto-updates. + +## request inspection and replay + +a local web UI for inspecting requests flowing through your tunnels. + +``` +tunnel active: https://my-api.tunnel.example.com → localhost:3000 +inspector: http://127.0.0.1:4040 +``` + +live request/response feed with headers, body, timing. filter by status code, method, path. replay requests. WebSocket frame inspection. export as curl commands. + +runs as an embedded HTTP server in the client process. + +## file serving + +```bash +funnel file ./dist +funnel file ./dist --id docs +funnel file ./report.html +``` + +embedded static file server with directory listing, content-type detection, and range requests. + +## tunnel access control + +protect individual tunnels without changing the application behind them. + +```bash +funnel http 3000 --auth user:pass +funnel http 3000 --allow-ip 10.0.0.0/8 +funnel http 3000 --expires 2h +``` + +```toml +[tunnels.api.access] +auth = "user:pass" +allow_ips = ["10.0.0.0/8", "192.168.0.0/16"] +expires = "4h" +``` + +also: OAuth-gated access (require login to reach the tunnel) and team-scoped access (only members of the tunnel's team can reach it). + +## rate limiting + +per-user, per-team, and per-tunnel rate limits enforced at the server. connection rate, request rate, and bandwidth limits. configurable server-side. + +## audit logging + +structured log of security-relevant events: tunnel created/destroyed, auth attempts, access control denials, API key operations, team membership changes. emitted as JSON, forwardable to any log aggregator. + +## OpenTelemetry + +distributed traces through the tunnel (client → server → client, per-request spans), OTLP metric export alongside existing Prometheus, and structured log export via OTLP. + +configurable via standard `OTEL_EXPORTER_OTLP_ENDPOINT` environment variables. the Prometheus endpoint stays. + +## web dashboard + +server-side web UI. active tunnel overview with live status, per-tunnel and per-team usage graphs, user and team management, API key management, session history. the Leptos crate already exists as a stub. + +## HTTP/2 proxying + +the tunnel currently proxies HTTP/1.1. HTTP/2 support enables gRPC tunneling and multiplexed browser requests. + +## TCP tunneling + +```bash +funnel tcp 5432 --id my-db +funnel tcp 22 --id my-ssh +``` + +raw TCP forwarding without HTTP framing. the server allocates a port and forwards bytes bidirectionally. + +## TLS passthrough + +```bash +funnel tls 8443 --id secure-app +``` + +forward TLS connections without terminating them at the server. routes via SNI. useful for applications that handle their own TLS or need mutual TLS. + +## UDP tunneling + +```bash +funnel udp 51820 --id wireguard +funnel udp 27015 --id game-server +``` + +QUIC already runs over UDP so the transport is a natural fit. lowest priority of the protocol additions. From 22168dd16b4312c1b09651defb43e38513ab226b Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Tue, 12 May 2026 18:21:10 +0200 Subject: [PATCH 37/42] feat(server): add OpenAPI spec generation with utoipa and Scalar UI --- Cargo.lock | 97 +++++++++++++++ Cargo.toml | 4 + crates/funnel-core/Cargo.toml | 4 + crates/funnel-core/src/api/types.rs | 1 + crates/funnel-server/Cargo.toml | 4 +- crates/funnel-server/src/api/accounts.rs | 15 ++- crates/funnel-server/src/api/health.rs | 11 +- crates/funnel-server/src/api/info.rs | 9 ++ crates/funnel-server/src/api/keys.rs | 42 ++++++- crates/funnel-server/src/api/me.rs | 14 ++- crates/funnel-server/src/api/sessions.rs | 16 ++- crates/funnel-server/src/api/teams.rs | 115 +++++++++++++++++- crates/funnel-server/src/api/tunnels.rs | 42 ++++++- crates/funnel-server/src/api/users.rs | 64 +++++++++- crates/funnel-server/src/app.rs | 20 +++ crates/funnel-server/src/db/api_keys.rs | 3 +- crates/funnel-server/src/db/teams.rs | 4 +- .../funnel-server/src/db/tunnel_sessions.rs | 3 +- crates/funnel-server/src/db/users.rs | 3 +- crates/funnel-server/src/error.rs | 4 +- crates/funnel-server/src/main.rs | 13 ++ crates/funnel-server/src/openapi.rs | 99 +++++++++++++++ crates/funnel-server/src/tunnel/manager.rs | 2 +- crates/funnel-server/src/tunnel/stats.rs | 2 +- justfile | 4 + 25 files changed, 568 insertions(+), 27 deletions(-) create mode 100644 crates/funnel-server/src/openapi.rs diff --git a/Cargo.lock b/Cargo.lock index 7b008d2..b4e4c71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -716,6 +716,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-server" version = "0.8.0" @@ -1753,6 +1774,7 @@ dependencies = [ "sha2 0.10.9", "thiserror 2.0.18", "tokio", + "utoipa", ] [[package]] @@ -1797,6 +1819,7 @@ dependencies = [ "reqwest", "rustls 0.23.40", "rustls-pemfile", + "scalar_api_reference", "serde", "serde_json", "sqlx", @@ -1808,6 +1831,7 @@ dependencies = [ "tracing-subscriber", "turso", "urlencoding", + "utoipa", "uuid", "x509-parser", ] @@ -4201,6 +4225,40 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2 0.10.9", + "walkdir", +] + [[package]] name = "rust-multipart-rfc7578_2" version = "0.9.0" @@ -4422,6 +4480,20 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scalar_api_reference" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eac3f009b73e27213aa1ca3dee1c3bcb698beb104bb90d1eb2102623f39d62d" +dependencies = [ + "axum", + "axum-extra", + "rust-embed", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "schannel" version = "0.1.29" @@ -5979,6 +6051,31 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + [[package]] name = "uuid" version = "1.23.1" diff --git a/Cargo.toml b/Cargo.toml index 6801496..4ef14fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,10 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v7", "serde"] } ipnetwork = "0.20" +# openapi +utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] } +scalar_api_reference = { version = "0.1", features = ["axum"] } + # misc pretty_assertions = "1" diff --git a/crates/funnel-core/Cargo.toml b/crates/funnel-core/Cargo.toml index 9368648..8c948e4 100644 --- a/crates/funnel-core/Cargo.toml +++ b/crates/funnel-core/Cargo.toml @@ -8,6 +8,9 @@ publish = false [lints] workspace = true +[features] +openapi = ["utoipa"] + [dependencies] base64 = "0.22" getrandom = "0.3" @@ -19,6 +22,7 @@ serde.workspace = true sha2 = "0.10" thiserror.workspace = true tokio = { version = "1", features = ["io-util"] } +utoipa = { workspace = true, optional = true } [dev-dependencies] pretty_assertions.workspace = true diff --git a/crates/funnel-core/src/api/types.rs b/crates/funnel-core/src/api/types.rs index fc51a1e..3991a10 100644 --- a/crates/funnel-core/src/api/types.rs +++ b/crates/funnel-core/src/api/types.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct ServerInfo { pub version: u32, pub quic_port: u16, diff --git a/crates/funnel-server/Cargo.toml b/crates/funnel-server/Cargo.toml index 91cbd96..22a4da6 100644 --- a/crates/funnel-server/Cargo.toml +++ b/crates/funnel-server/Cargo.toml @@ -9,7 +9,7 @@ publish = false workspace = true [dependencies] -funnel-core.workspace = true +funnel-core = { workspace = true, features = ["openapi"] } anyhow.workspace = true axum.workspace = true @@ -37,6 +37,8 @@ tower-http.workspace = true tracing.workspace = true tracing-subscriber = { workspace = true, features = ["json"] } urlencoding = "2" +utoipa.workspace = true +scalar_api_reference.workspace = true uuid.workspace = true # tls diff --git a/crates/funnel-server/src/api/accounts.rs b/crates/funnel-server/src/api/accounts.rs index ce64b20..f258a49 100644 --- a/crates/funnel-server/src/api/accounts.rs +++ b/crates/funnel-server/src/api/accounts.rs @@ -7,15 +7,26 @@ use uuid::Uuid; use crate::app::AppState; use crate::auth::{Management, Scoped}; -use crate::error::AppError; +use crate::error::{ApiErrorBody, AppError}; -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct AccountView { pub id: Uuid, pub provider: String, pub created_at: chrono::DateTime, } +#[utoipa::path( + get, + path = "/accounts", + operation_id = "list_accounts", + tag = "Profile", + security(("bearer" = [])), + responses( + (status = 200, description = "Linked OAuth accounts", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + ) +)] pub async fn list( State(state): State>, auth: Scoped, diff --git a/crates/funnel-server/src/api/health.rs b/crates/funnel-server/src/api/health.rs index b6b7940..33e2d88 100644 --- a/crates/funnel-server/src/api/health.rs +++ b/crates/funnel-server/src/api/health.rs @@ -6,12 +6,21 @@ use serde::Serialize; use crate::app::AppState; -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct HealthResponse { pub status: &'static str, pub uptime_secs: u64, } +#[utoipa::path( + get, + path = "/health", + operation_id = "get_health", + tag = "Server", + responses( + (status = 200, description = "Server is healthy", body = HealthResponse), + ) +)] pub async fn handler(State(state): State>) -> Json { Json(HealthResponse { status: state.health.status(), diff --git a/crates/funnel-server/src/api/info.rs b/crates/funnel-server/src/api/info.rs index 0e0bdfb..eefecc4 100644 --- a/crates/funnel-server/src/api/info.rs +++ b/crates/funnel-server/src/api/info.rs @@ -8,6 +8,15 @@ use funnel_core::protocol::PROTOCOL_VERSION; use crate::app::AppState; +#[utoipa::path( + get, + path = "/info", + operation_id = "get_server_info", + tag = "Server", + responses( + (status = 200, description = "Server version and QUIC port", body = ServerInfo), + ) +)] pub async fn handler(State(state): State>) -> Json { Json(ServerInfo { version: PROTOCOL_VERSION, diff --git a/crates/funnel-server/src/api/keys.rs b/crates/funnel-server/src/api/keys.rs index cb3582f..3f1a03c 100644 --- a/crates/funnel-server/src/api/keys.rs +++ b/crates/funnel-server/src/api/keys.rs @@ -9,21 +9,32 @@ use uuid::Uuid; use crate::app::AppState; use crate::auth::{Management, Scoped}; use crate::db::api_keys::{ApiKeyView, default_scopes}; -use crate::error::AppError; +use crate::error::{ApiErrorBody, AppError}; -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct CreateKeyRequest { pub name: String, pub scopes: Option>, pub expires_at: Option>, } -#[derive(Serialize)] +#[derive(Serialize, utoipa::ToSchema)] pub struct CreateKeyResponse { pub key: String, pub info: ApiKeyView, } +#[utoipa::path( + get, + path = "/keys", + operation_id = "list_keys", + tag = "API Keys", + security(("bearer" = [])), + responses( + (status = 200, description = "List of API keys for the current user", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + ) +)] pub async fn list( State(state): State>, auth: Scoped, @@ -32,6 +43,18 @@ pub async fn list( Ok(Json(keys)) } +#[utoipa::path( + post, + path = "/keys", + operation_id = "create_key", + tag = "API Keys", + security(("bearer" = [])), + request_body = CreateKeyRequest, + responses( + (status = 200, description = "API key created", body = CreateKeyResponse), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + ) +)] pub async fn create( State(state): State>, auth: Scoped, @@ -52,6 +75,19 @@ pub async fn create( })) } +#[utoipa::path( + delete, + path = "/keys/{id}", + operation_id = "revoke_key", + tag = "API Keys", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "API key ID")), + responses( + (status = 200, description = "API key revoked", body = Object), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 404, description = "Key not found", body = ApiErrorBody), + ) +)] pub async fn revoke( State(state): State>, auth: Scoped, diff --git a/crates/funnel-server/src/api/me.rs b/crates/funnel-server/src/api/me.rs index 3c0700e..a784c7e 100644 --- a/crates/funnel-server/src/api/me.rs +++ b/crates/funnel-server/src/api/me.rs @@ -6,8 +6,20 @@ use axum::extract::State; use crate::app::AppState; use crate::auth::{Management, Scoped}; use crate::db::users::User; -use crate::error::AppError; +use crate::error::{ApiErrorBody, AppError}; +#[utoipa::path( + get, + path = "/me", + operation_id = "get_current_user", + tag = "Profile", + security(("bearer" = [])), + responses( + (status = 200, description = "Current user profile", body = User), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 404, description = "User not found", body = ApiErrorBody), + ) +)] pub async fn handler( State(state): State>, auth: Scoped, diff --git a/crates/funnel-server/src/api/sessions.rs b/crates/funnel-server/src/api/sessions.rs index c20dad1..430c85b 100644 --- a/crates/funnel-server/src/api/sessions.rs +++ b/crates/funnel-server/src/api/sessions.rs @@ -7,9 +7,9 @@ use serde::Deserialize; use crate::app::AppState; use crate::auth::{Management, Scoped}; use crate::db::tunnel_sessions::TunnelSession; -use crate::error::AppError; +use crate::error::{ApiErrorBody, AppError}; -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::IntoParams)] pub struct ListParams { #[serde(default = "default_limit")] limit: i64, @@ -21,6 +21,18 @@ const fn default_limit() -> i64 { 50 } +#[utoipa::path( + get, + path = "/sessions", + operation_id = "list_sessions", + tag = "Profile", + security(("bearer" = [])), + params(ListParams), + responses( + (status = 200, description = "Tunnel session history", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + ) +)] pub async fn list( State(state): State>, auth: Scoped, diff --git a/crates/funnel-server/src/api/teams.rs b/crates/funnel-server/src/api/teams.rs index 5a82617..f15b8d6 100644 --- a/crates/funnel-server/src/api/teams.rs +++ b/crates/funnel-server/src/api/teams.rs @@ -8,7 +8,7 @@ use uuid::Uuid; use crate::app::AppState; use crate::auth::{Management, RequireAdmin, Scoped}; use crate::db::teams::{Team, TeamMembership, TEAM_ROLE_MEMBER, TEAM_ROLE_OWNER}; -use crate::error::AppError; +use crate::error::{ApiErrorBody, AppError}; async fn can_manage_team(state: &AppState, team_id: Uuid, auth: &Scoped) -> Result { if auth.is_admin() { @@ -18,6 +18,17 @@ async fn can_manage_team(state: &AppState, team_id: Uuid, auth: &Scoped), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + ) +)] pub async fn list( State(state): State>, auth: Scoped, @@ -30,12 +41,26 @@ pub async fn list( Ok(Json(teams)) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct CreateTeamRequest { pub name: String, pub owner_id: Option, } +#[utoipa::path( + post, + path = "/teams", + operation_id = "create_team", + tag = "Teams", + security(("bearer" = [])), + request_body = CreateTeamRequest, + responses( + (status = 200, description = "Team created", body = Team), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + (status = 409, description = "Team name already exists", body = ApiErrorBody), + ) +)] pub async fn create( State(state): State>, admin: RequireAdmin, @@ -50,6 +75,20 @@ pub async fn create( Ok(Json(team)) } +#[utoipa::path( + delete, + path = "/teams/{id}", + operation_id = "delete_team", + tag = "Teams", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "Team ID")), + responses( + (status = 200, description = "Team deleted", body = Object), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + (status = 404, description = "Team not found", body = ApiErrorBody), + ) +)] pub async fn delete( State(state): State>, _admin: RequireAdmin, @@ -62,6 +101,20 @@ pub async fn delete( Ok(Json(serde_json::json!({ "deleted": true }))) } +#[utoipa::path( + get, + path = "/teams/{id}/members", + operation_id = "list_team_members", + tag = "Teams", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "Team ID")), + responses( + (status = 200, description = "List of team members", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Not a team member", body = ApiErrorBody), + (status = 404, description = "Team not found", body = ApiErrorBody), + ) +)] pub async fn list_members( State(state): State>, auth: Scoped, @@ -84,11 +137,27 @@ pub async fn list_members( Ok(Json(members)) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] pub struct AddMemberRequest { pub user_id: Uuid, } +#[utoipa::path( + post, + path = "/teams/{id}/members", + operation_id = "add_team_member", + tag = "Teams", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "Team ID")), + request_body = AddMemberRequest, + responses( + (status = 200, description = "Member added", body = TeamMembership), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Not a team owner", body = ApiErrorBody), + (status = 404, description = "Team not found", body = ApiErrorBody), + (status = 409, description = "Already a member", body = ApiErrorBody), + ) +)] pub async fn add_member( State(state): State>, auth: Scoped, @@ -112,6 +181,24 @@ pub async fn add_member( Ok(Json(membership)) } +#[utoipa::path( + delete, + path = "/teams/{id}/members/{user_id}", + operation_id = "remove_team_member", + tag = "Teams", + security(("bearer" = [])), + params( + ("id" = Uuid, Path, description = "Team ID"), + ("user_id" = Uuid, Path, description = "User ID"), + ), + responses( + (status = 200, description = "Member removed", body = Object), + (status = 400, description = "Cannot remove last owner", body = ApiErrorBody), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Not a team owner", body = ApiErrorBody), + (status = 404, description = "Membership not found", body = ApiErrorBody), + ) +)] pub async fn remove_member( State(state): State>, auth: Scoped, @@ -141,11 +228,31 @@ pub async fn remove_member( Ok(Json(serde_json::json!({ "removed": true }))) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] +#[schema(as = SetTeamMemberRoleRequest)] pub struct SetRoleRequest { pub role: String, } +#[utoipa::path( + put, + path = "/teams/{id}/members/{user_id}/role", + operation_id = "set_team_member_role", + tag = "Teams", + security(("bearer" = [])), + params( + ("id" = Uuid, Path, description = "Team ID"), + ("user_id" = Uuid, Path, description = "User ID"), + ), + request_body = SetRoleRequest, + responses( + (status = 200, description = "Member role updated", body = TeamMembership), + (status = 400, description = "Invalid role or last owner", body = ApiErrorBody), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Not a team owner", body = ApiErrorBody), + (status = 404, description = "Membership not found", body = ApiErrorBody), + ) +)] pub async fn set_member_role( State(state): State>, auth: Scoped, diff --git a/crates/funnel-server/src/api/tunnels.rs b/crates/funnel-server/src/api/tunnels.rs index dc73220..82f5773 100644 --- a/crates/funnel-server/src/api/tunnels.rs +++ b/crates/funnel-server/src/api/tunnels.rs @@ -7,9 +7,21 @@ use funnel_core::tunnel::id::TunnelId; use crate::app::AppState; use crate::auth::{Management, Scoped}; -use crate::error::AppError; +use crate::error::{ApiErrorBody, AppError}; use crate::tunnel::manager::TunnelInfo; +#[utoipa::path( + get, + path = "/tunnels", + operation_id = "list_tunnels", + tag = "Tunnels", + security(("bearer" = [])), + responses( + (status = 200, description = "List of active tunnels", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Forbidden", body = ApiErrorBody), + ) +)] pub async fn list( State(state): State>, auth: Scoped, @@ -42,6 +54,20 @@ fn can_access(tunnel: &TunnelInfo, user_id: uuid::Uuid, team_ids: &[uuid::Uuid]) || tunnel.team_id.is_some_and(|tid| team_ids.contains(&tid)) } +#[utoipa::path( + get, + path = "/tunnels/{id}", + operation_id = "get_tunnel", + tag = "Tunnels", + security(("bearer" = [])), + params(("id" = String, Path, description = "Tunnel ID")), + responses( + (status = 200, description = "Tunnel details", body = TunnelInfo), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Forbidden", body = ApiErrorBody), + (status = 404, description = "Tunnel not found", body = ApiErrorBody), + ) +)] pub async fn get_tunnel( State(state): State>, auth: Scoped, @@ -76,6 +102,20 @@ pub async fn get_tunnel( Ok(Json(info)) } +#[utoipa::path( + delete, + path = "/tunnels/{id}", + operation_id = "delete_tunnel", + tag = "Tunnels", + security(("bearer" = [])), + params(("id" = String, Path, description = "Tunnel ID")), + responses( + (status = 200, description = "Tunnel disconnected", body = Object), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Forbidden", body = ApiErrorBody), + (status = 404, description = "Tunnel not found", body = ApiErrorBody), + ) +)] pub async fn delete( State(state): State>, auth: Scoped, diff --git a/crates/funnel-server/src/api/users.rs b/crates/funnel-server/src/api/users.rs index b939b65..fefd193 100644 --- a/crates/funnel-server/src/api/users.rs +++ b/crates/funnel-server/src/api/users.rs @@ -8,9 +8,9 @@ use uuid::Uuid; use crate::app::AppState; use crate::auth::RequireAdmin; use crate::db::users::{User, ROLE_ADMIN, ROLE_MEMBER}; -use crate::error::AppError; +use crate::error::{ApiErrorBody, AppError}; -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::IntoParams)] pub struct ListParams { #[serde(default = "default_limit")] limit: i64, @@ -20,6 +20,19 @@ const fn default_limit() -> i64 { 50 } +#[utoipa::path( + get, + path = "/users", + operation_id = "list_users", + tag = "Users", + security(("bearer" = [])), + params(ListParams), + responses( + (status = 200, description = "List of all users", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + ) +)] pub async fn list( State(state): State>, _admin: RequireAdmin, @@ -29,11 +42,28 @@ pub async fn list( Ok(Json(users)) } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::ToSchema)] +#[schema(as = SetUserRoleRequest)] pub struct SetRoleRequest { pub role: String, } +#[utoipa::path( + put, + path = "/users/{id}/role", + operation_id = "set_user_role", + tag = "Users", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "User ID")), + request_body = SetRoleRequest, + responses( + (status = 200, description = "User role updated", body = User), + (status = 400, description = "Invalid role or last admin", body = ApiErrorBody), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + (status = 404, description = "User not found", body = ApiErrorBody), + ) +)] pub async fn set_role( State(state): State>, _admin: RequireAdmin, @@ -69,6 +99,21 @@ pub async fn set_role( Ok(Json(user)) } +#[utoipa::path( + post, + path = "/users/{id}/deactivate", + operation_id = "deactivate_user", + tag = "Users", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "User ID")), + responses( + (status = 200, description = "User deactivated", body = User), + (status = 400, description = "Cannot deactivate last admin", body = ApiErrorBody), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + (status = 404, description = "User not found", body = ApiErrorBody), + ) +)] pub async fn deactivate( State(state): State>, _admin: RequireAdmin, @@ -93,6 +138,19 @@ pub async fn deactivate( Ok(Json(user)) } +#[utoipa::path( + post, + path = "/users/{id}/reactivate", + operation_id = "reactivate_user", + tag = "Users", + security(("bearer" = [])), + params(("id" = Uuid, Path, description = "User ID")), + responses( + (status = 200, description = "User reactivated", body = User), + (status = 401, description = "Unauthorized", body = ApiErrorBody), + (status = 403, description = "Admin role required", body = ApiErrorBody), + ) +)] pub async fn reactivate( State(state): State>, _admin: RequireAdmin, diff --git a/crates/funnel-server/src/app.rs b/crates/funnel-server/src/app.rs index adbe9b2..83b0b5e 100644 --- a/crates/funnel-server/src/app.rs +++ b/crates/funnel-server/src/app.rs @@ -10,6 +10,7 @@ use axum::routing::get; use metrics_exporter_prometheus::PrometheusHandle; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; +use scalar_api_reference::axum::scalar_response; use funnel_core::protocol::PROTOCOL_VERSION; use funnel_core::tunnel::id::TunnelId; @@ -93,6 +94,25 @@ pub fn build_router(state: Arc, metrics_handle: PrometheusHandle) -> R let api_prefix = format!("/api/v{PROTOCOL_VERSION}"); let auth_prefix = format!("/auth/v{PROTOCOL_VERSION}"); + let scalar_config = serde_json::json!({ + "url": format!("{api_prefix}/openapi.json"), + "hideClientButton": true, + "agent": { "disabled": true }, + "mcp": { "disabled": true }, + }); + + let scalar_routes = Router::new() + .route( + "/scalar", + get(move || { + let config = scalar_config.clone(); + async move { scalar_response(&config, None) } + }), + ) + .route("/openapi.json", get(crate::openapi::json_handler)); + + let api_routes = api_routes.merge(scalar_routes); + Router::new() .nest(&api_prefix, api_routes) .nest(&auth_prefix, auth_routes) diff --git a/crates/funnel-server/src/db/api_keys.rs b/crates/funnel-server/src/db/api_keys.rs index 8b8be57..1ab42eb 100644 --- a/crates/funnel-server/src/db/api_keys.rs +++ b/crates/funnel-server/src/db/api_keys.rs @@ -26,11 +26,12 @@ impl ApiKey { } } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct ApiKeyView { pub id: Uuid, pub name: String, pub key_prefix: String, + #[schema(value_type = Vec)] pub scopes: serde_json::Value, pub created_at: DateTime, pub revoked_at: Option>, diff --git a/crates/funnel-server/src/db/teams.rs b/crates/funnel-server/src/db/teams.rs index b04701b..dfba152 100644 --- a/crates/funnel-server/src/db/teams.rs +++ b/crates/funnel-server/src/db/teams.rs @@ -3,7 +3,7 @@ use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; -#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +#[derive(Debug, Clone, Serialize, sqlx::FromRow, utoipa::ToSchema)] pub struct Team { pub id: Uuid, pub name: String, @@ -14,7 +14,7 @@ pub struct Team { pub const TEAM_ROLE_OWNER: &str = "owner"; pub const TEAM_ROLE_MEMBER: &str = "member"; -#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +#[derive(Debug, Clone, Serialize, sqlx::FromRow, utoipa::ToSchema)] pub struct TeamMembership { pub id: Uuid, pub team_id: Uuid, diff --git a/crates/funnel-server/src/db/tunnel_sessions.rs b/crates/funnel-server/src/db/tunnel_sessions.rs index 4f10c30..791403f 100644 --- a/crates/funnel-server/src/db/tunnel_sessions.rs +++ b/crates/funnel-server/src/db/tunnel_sessions.rs @@ -4,11 +4,12 @@ use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; -#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +#[derive(Debug, Clone, Serialize, sqlx::FromRow, utoipa::ToSchema)] pub struct TunnelSession { pub id: Uuid, pub user_id: Uuid, pub tunnel_id: String, + #[schema(value_type = Option)] pub client_ip: Option, pub connected_at: DateTime, pub disconnected_at: Option>, diff --git a/crates/funnel-server/src/db/users.rs b/crates/funnel-server/src/db/users.rs index b6dff64..29b0e70 100644 --- a/crates/funnel-server/src/db/users.rs +++ b/crates/funnel-server/src/db/users.rs @@ -6,13 +6,14 @@ use uuid::Uuid; pub const ROLE_ADMIN: &str = "admin"; pub const ROLE_MEMBER: &str = "member"; -#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +#[derive(Debug, Clone, Serialize, sqlx::FromRow, utoipa::ToSchema)] pub struct User { pub id: Uuid, pub email: String, pub name: Option, pub avatar_url: Option, pub role: String, + #[schema(value_type = Object)] pub metadata: serde_json::Value, pub created_at: DateTime, pub updated_at: DateTime, diff --git a/crates/funnel-server/src/error.rs b/crates/funnel-server/src/error.rs index dfae9ed..2810611 100644 --- a/crates/funnel-server/src/error.rs +++ b/crates/funnel-server/src/error.rs @@ -6,8 +6,8 @@ use serde::Serialize; use crate::store::StoreError; -#[derive(Debug, Serialize)] -struct ApiErrorBody { +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub(crate) struct ApiErrorBody { error: String, } diff --git a/crates/funnel-server/src/main.rs b/crates/funnel-server/src/main.rs index af244da..00db0e3 100644 --- a/crates/funnel-server/src/main.rs +++ b/crates/funnel-server/src/main.rs @@ -4,6 +4,7 @@ mod auth; mod db; mod error; mod metrics; +mod openapi; mod proxy; mod quic; mod store; @@ -96,6 +97,10 @@ struct Cli { #[arg(long, default_value = "./funnel.db", env = "FUNNEL_TURSO_DB_PATH")] turso_db_path: String, + /// Dump the OpenAPI spec as JSON to stdout and exit + #[arg(long)] + dump_openapi: bool, + /// Email of the initial admin user (auto promotes on first login) #[arg(long, env = "FUNNEL_INITIAL_ADMIN_EMAIL")] initial_admin_email: Option, @@ -173,6 +178,14 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); + if cli.dump_openapi { + let spec = openapi::spec(); + let json = serde_json::to_string_pretty(&spec) + .map_err(|e| anyhow::anyhow!("failed to serialize openapi spec: {e}"))?; + print!("{json}"); + return Ok(()); + } + tracing::info!(host = %cli.host, port = cli.port, quic_port = cli.quic_port, "starting funnel server"); let pool = if let Some(ref database_url) = cli.database_url { diff --git a/crates/funnel-server/src/openapi.rs b/crates/funnel-server/src/openapi.rs new file mode 100644 index 0000000..4c04981 --- /dev/null +++ b/crates/funnel-server/src/openapi.rs @@ -0,0 +1,99 @@ +use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme}; +use utoipa::{Modify, OpenApi}; + +#[derive(OpenApi)] +#[openapi( + info( + title = "Funnel API", + description = "Funnel tunnel server REST API", + version = env!("CARGO_PKG_VERSION"), + license(name = "MIT"), + ), + servers( + (url = "/api/v1", description = "API v1"), + ), + paths( + crate::api::health::handler, + crate::api::info::handler, + crate::api::tunnels::list, + crate::api::tunnels::get_tunnel, + crate::api::tunnels::delete, + crate::api::keys::list, + crate::api::keys::create, + crate::api::keys::revoke, + crate::api::me::handler, + crate::api::accounts::list, + crate::api::sessions::list, + crate::api::users::list, + crate::api::users::set_role, + crate::api::users::deactivate, + crate::api::users::reactivate, + crate::api::teams::list, + crate::api::teams::create, + crate::api::teams::delete, + crate::api::teams::list_members, + crate::api::teams::add_member, + crate::api::teams::remove_member, + crate::api::teams::set_member_role, + ), + components( + schemas( + crate::api::health::HealthResponse, + funnel_core::api::ServerInfo, + crate::tunnel::manager::TunnelInfo, + crate::tunnel::stats::TunnelStatsSnapshot, + crate::db::api_keys::ApiKeyView, + crate::api::keys::CreateKeyRequest, + crate::api::keys::CreateKeyResponse, + crate::db::users::User, + crate::db::teams::Team, + crate::db::teams::TeamMembership, + crate::db::tunnel_sessions::TunnelSession, + crate::api::accounts::AccountView, + crate::error::ApiErrorBody, + crate::api::users::SetRoleRequest, + crate::api::teams::CreateTeamRequest, + crate::api::teams::AddMemberRequest, + crate::api::teams::SetRoleRequest, + ) + ), + modifiers(&SecurityAddon), + tags( + (name = "Server", description = "Server health and info"), + (name = "Tunnels", description = "Tunnel management"), + (name = "API Keys", description = "API key management"), + (name = "Profile", description = "Current user profile and sessions"), + (name = "Users", description = "User management (admin only)"), + (name = "Teams", description = "Team management"), + ), +)] +struct ApiDoc; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearer", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + + // fumadocs-openapi uses x-displayName on tags for human-readable titles, + // falling back to idToTitle() which mangles acronyms like "API" -> "A P I" + if let Some(tags) = &mut openapi.tags { + for tag in tags { + let extensions = tag.extensions.get_or_insert_with(Default::default); + extensions.insert("x-displayName".to_string(), serde_json::json!(tag.name)); + } + } + } +} + +pub fn spec() -> utoipa::openapi::OpenApi { + ApiDoc::openapi() +} + +pub async fn json_handler() -> axum::Json { + axum::Json(spec()) +} diff --git a/crates/funnel-server/src/tunnel/manager.rs b/crates/funnel-server/src/tunnel/manager.rs index 010b545..3952615 100644 --- a/crates/funnel-server/src/tunnel/manager.rs +++ b/crates/funnel-server/src/tunnel/manager.rs @@ -16,7 +16,7 @@ pub struct TunnelManager { tunnels: DashMap>, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct TunnelInfo { pub id: String, pub uptime_secs: f64, diff --git a/crates/funnel-server/src/tunnel/stats.rs b/crates/funnel-server/src/tunnel/stats.rs index 26f16af..230a42d 100644 --- a/crates/funnel-server/src/tunnel/stats.rs +++ b/crates/funnel-server/src/tunnel/stats.rs @@ -8,7 +8,7 @@ pub struct TunnelStats { requests: AtomicU64, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct TunnelStatsSnapshot { pub bytes_in: u64, pub bytes_out: u64, diff --git a/justfile b/justfile index c95ebe6..7b2c553 100644 --- a/justfile +++ b/justfile @@ -48,6 +48,10 @@ audit: machete: cargo machete +# generate openapi.json for the docs site +gen-openapi: + cargo run -p funnel-server -- --dump-openapi > docs/openapi.json + # build nix packages nix-build: nix build .#funnel-server .#funnel-client From 340fb44d4762bd09ef367b1e56d87ac776cd5563 Mon Sep 17 00:00:00 2001 From: Karol Broda Date: Tue, 12 May 2026 18:21:19 +0200 Subject: [PATCH 38/42] docs: rebuild site with new content and remove stale Go-era pages --- docs/.eslintrc.json | 6 - docs/.gitignore | 31 +- docs/README.md | 3 - docs/bun.lock | 1395 ++------- docs/content/docs/client/cli.mdx | 134 + .../docs/core-concepts/architecture.mdx | 92 - docs/content/docs/core-concepts/meta.json | 5 - .../docs/getting-started/basic-usage.mdx | 172 -- .../content/docs/getting-started/contexts.mdx | 78 + .../docs/getting-started/installation.mdx | 98 +- docs/content/docs/getting-started/meta.json | 5 - .../docs/getting-started/quickstart.mdx | 64 + docs/content/docs/index.mdx | 71 +- docs/content/docs/meta.json | 26 +- docs/content/docs/nix/flake.mdx | 58 + docs/content/docs/nix/home-manager.mdx | 74 + docs/content/docs/nix/nixos-module.mdx | 169 ++ docs/content/docs/reference/architecture.mdx | 72 + docs/content/docs/reference/client-cli.mdx | 333 --- docs/content/docs/reference/meta.json | 5 - .../docs/reference/server-api/api-keys.mdx | 29 + .../docs/reference/server-api/index.mdx | 79 +- .../docs/reference/server-api/meta.json | 8 +- .../docs/reference/server-api/profile.mdx | 29 + .../docs/reference/server-api/server.mdx | 24 + .../server-api/server/health/get.mdx | 18 - .../server-api/server/health/meta.json | 4 - .../reference/server-api/server/meta.json | 9 - .../server-api/server/metrics/get.mdx | 20 - .../server/metrics/historical/get.mdx | 20 - .../server/metrics/historical/meta.json | 4 - .../server-api/server/metrics/meta.json | 4 - .../server-api/server/server/meta.json | 4 - .../server-api/server/server/stats/get.mdx | 20 - .../server-api/server/server/stats/meta.json | 4 - .../docs/reference/server-api/teams.mdx | 49 + .../docs/reference/server-api/tunnels.mdx | 29 + .../reference/server-api/tunnels/meta.json | 7 - .../server-api/tunnels/tunnels/get.mdx | 18 - .../server-api/tunnels/tunnels/meta.json | 4 - .../tunnels/tunnels/tunnelid/delete.mdx | 18 - .../tunnels/tunnels/tunnelid/get.mdx | 18 - .../tunnels/tunnelid/historical/get.mdx | 20 - .../tunnels/tunnelid/historical/meta.json | 4 - .../tunnels/tunnels/tunnelid/meta.json | 4 - .../tunnels/tunnels/tunnelid/metrics/get.mdx | 18 - .../tunnels/tunnelid/metrics/meta.json | 4 - .../tunnels/tunnels/tunnelid/stats/get.mdx | 18 - .../tunnels/tunnels/tunnelid/stats/meta.json | 4 - .../docs/reference/server-api/users.mdx | 34 + docs/content/docs/reference/server-cli.mdx | 173 -- docs/content/docs/roadmap.mdx | 255 +- docs/content/docs/server/authentication.mdx | 122 + docs/content/docs/server/deployment.mdx | 150 +- docs/content/docs/server/meta.json | 5 - docs/content/docs/server/security.mdx | 338 --- docs/content/docs/server/teams.mdx | 59 + docs/content/docs/server/tls.mdx | 89 + docs/mdx-components.tsx | 27 + docs/next.config.mjs | 71 +- docs/openapi.json | 2598 +++++++++++------ docs/package.json | 73 +- docs/postcss.config.mjs | 2 +- docs/public/.well-known/security.txt | 6 - .../dm-serif-display-latin-400-normal.woff2 | Bin 0 -> 24744 bytes docs/public/favicon.svg | 4 - docs/public/humans.txt | 36 - docs/public/icon.svg | 5 + docs/public/robots.txt | 15 - docs/public/site.webmanifest | 16 - docs/scripts/generate-api-docs.mts | 9 + docs/scripts/generate-openapi-docs.mjs | 18 - docs/source.config.ts | 20 +- .../app/(home)/_components/feature-grid.tsx | 30 + .../src/app/(home)/_components/flow-field.tsx | 299 ++ docs/src/app/(home)/_components/flow.tsx | 89 + .../app/(home)/_components/github-icon.tsx | 7 + .../src/app/(home)/_components/hero-button.ts | 13 + .../app/(home)/_components/mesh-gradient.tsx | 35 + docs/src/app/(home)/_components/terminal.tsx | 67 + docs/src/app/(home)/layout.tsx | 14 +- docs/src/app/(home)/page.tsx | 505 ++-- docs/src/app/(legal)/layout.tsx | 11 - docs/src/app/(legal)/privacy/page.tsx | 124 - docs/src/app/(legal)/terms/page.tsx | 167 -- .../(verifications)/algolia-verify/page.tsx | 11 - docs/src/app/api/proxy/route.ts | 6 - docs/src/app/api/search/route.ts | 7 - docs/src/app/docs-og/[...slug]/route.tsx | 258 -- docs/src/app/docs-og/[[...slug]]/route.tsx | 49 + docs/src/app/docs/[[...slug]]/page.tsx | 122 +- docs/src/app/docs/layout.tsx | 41 +- docs/src/app/docs/loading.tsx | 30 - docs/src/app/error.tsx | 96 - docs/src/app/feed.xml/route.ts | 29 - docs/src/app/global-error.tsx | 93 - docs/src/app/global.css | 135 +- docs/src/app/layout.config.tsx | 14 - docs/src/app/layout.tsx | 125 +- docs/src/app/loading.tsx | 36 - docs/src/app/not-found.tsx | 92 +- docs/src/app/og/route.tsx | 158 +- docs/src/app/sitemap.ts | 34 - docs/src/components/home/install-script.tsx | 40 - docs/src/components/layout/footer.tsx | 35 - docs/src/components/mdx/last-modified.tsx | 39 - docs/src/components/mdx/mermaid.tsx | 211 -- docs/src/components/ui/aurora.tsx | 216 -- docs/src/components/ui/feature-bento.tsx | 673 ----- docs/src/lib/animation.ts | 16 + docs/src/lib/colors.ts | 9 + docs/src/lib/git.ts | 280 -- docs/src/lib/layout.shared.tsx | 21 + docs/src/lib/mermaid-themes.ts | 174 -- docs/src/lib/og.tsx | 175 ++ docs/src/lib/seo.ts | 233 -- docs/src/lib/source.ts | 46 +- docs/src/mdx-components.tsx | 16 - docs/tsconfig.json | 42 +- 119 files changed, 4801 insertions(+), 7629 deletions(-) delete mode 100644 docs/.eslintrc.json delete mode 100644 docs/README.md create mode 100644 docs/content/docs/client/cli.mdx delete mode 100644 docs/content/docs/core-concepts/architecture.mdx delete mode 100644 docs/content/docs/core-concepts/meta.json delete mode 100644 docs/content/docs/getting-started/basic-usage.mdx create mode 100644 docs/content/docs/getting-started/contexts.mdx delete mode 100644 docs/content/docs/getting-started/meta.json create mode 100644 docs/content/docs/getting-started/quickstart.mdx create mode 100644 docs/content/docs/nix/flake.mdx create mode 100644 docs/content/docs/nix/home-manager.mdx create mode 100644 docs/content/docs/nix/nixos-module.mdx create mode 100644 docs/content/docs/reference/architecture.mdx delete mode 100644 docs/content/docs/reference/client-cli.mdx delete mode 100644 docs/content/docs/reference/meta.json create mode 100644 docs/content/docs/reference/server-api/api-keys.mdx create mode 100644 docs/content/docs/reference/server-api/profile.mdx create mode 100644 docs/content/docs/reference/server-api/server.mdx delete mode 100644 docs/content/docs/reference/server-api/server/health/get.mdx delete mode 100644 docs/content/docs/reference/server-api/server/health/meta.json delete mode 100644 docs/content/docs/reference/server-api/server/meta.json delete mode 100644 docs/content/docs/reference/server-api/server/metrics/get.mdx delete mode 100644 docs/content/docs/reference/server-api/server/metrics/historical/get.mdx delete mode 100644 docs/content/docs/reference/server-api/server/metrics/historical/meta.json delete mode 100644 docs/content/docs/reference/server-api/server/metrics/meta.json delete mode 100644 docs/content/docs/reference/server-api/server/server/meta.json delete mode 100644 docs/content/docs/reference/server-api/server/server/stats/get.mdx delete mode 100644 docs/content/docs/reference/server-api/server/server/stats/meta.json create mode 100644 docs/content/docs/reference/server-api/teams.mdx create mode 100644 docs/content/docs/reference/server-api/tunnels.mdx delete mode 100644 docs/content/docs/reference/server-api/tunnels/meta.json delete mode 100644 docs/content/docs/reference/server-api/tunnels/tunnels/get.mdx delete mode 100644 docs/content/docs/reference/server-api/tunnels/tunnels/meta.json delete mode 100644 docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/delete.mdx delete mode 100644 docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/get.mdx delete mode 100644 docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/get.mdx delete mode 100644 docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/meta.json delete mode 100644 docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/meta.json delete mode 100644 docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/get.mdx delete mode 100644 docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/meta.json delete mode 100644 docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/get.mdx delete mode 100644 docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/meta.json create mode 100644 docs/content/docs/reference/server-api/users.mdx delete mode 100644 docs/content/docs/reference/server-cli.mdx create mode 100644 docs/content/docs/server/authentication.mdx delete mode 100644 docs/content/docs/server/meta.json delete mode 100644 docs/content/docs/server/security.mdx create mode 100644 docs/content/docs/server/teams.mdx create mode 100644 docs/content/docs/server/tls.mdx create mode 100644 docs/mdx-components.tsx delete mode 100644 docs/public/.well-known/security.txt create mode 100644 docs/public/dm-serif-display-latin-400-normal.woff2 delete mode 100644 docs/public/favicon.svg delete mode 100644 docs/public/humans.txt create mode 100644 docs/public/icon.svg delete mode 100644 docs/public/robots.txt delete mode 100644 docs/public/site.webmanifest create mode 100644 docs/scripts/generate-api-docs.mts delete mode 100644 docs/scripts/generate-openapi-docs.mjs create mode 100644 docs/src/app/(home)/_components/feature-grid.tsx create mode 100644 docs/src/app/(home)/_components/flow-field.tsx create mode 100644 docs/src/app/(home)/_components/flow.tsx create mode 100644 docs/src/app/(home)/_components/github-icon.tsx create mode 100644 docs/src/app/(home)/_components/hero-button.ts create mode 100644 docs/src/app/(home)/_components/mesh-gradient.tsx create mode 100644 docs/src/app/(home)/_components/terminal.tsx delete mode 100644 docs/src/app/(legal)/layout.tsx delete mode 100644 docs/src/app/(legal)/privacy/page.tsx delete mode 100644 docs/src/app/(legal)/terms/page.tsx delete mode 100644 docs/src/app/(verifications)/algolia-verify/page.tsx delete mode 100644 docs/src/app/api/proxy/route.ts delete mode 100644 docs/src/app/api/search/route.ts delete mode 100644 docs/src/app/docs-og/[...slug]/route.tsx create mode 100644 docs/src/app/docs-og/[[...slug]]/route.tsx delete mode 100644 docs/src/app/docs/loading.tsx delete mode 100644 docs/src/app/error.tsx delete mode 100644 docs/src/app/feed.xml/route.ts delete mode 100644 docs/src/app/global-error.tsx delete mode 100644 docs/src/app/layout.config.tsx delete mode 100644 docs/src/app/loading.tsx delete mode 100644 docs/src/app/sitemap.ts delete mode 100644 docs/src/components/home/install-script.tsx delete mode 100644 docs/src/components/layout/footer.tsx delete mode 100644 docs/src/components/mdx/last-modified.tsx delete mode 100644 docs/src/components/mdx/mermaid.tsx delete mode 100644 docs/src/components/ui/aurora.tsx delete mode 100644 docs/src/components/ui/feature-bento.tsx create mode 100644 docs/src/lib/animation.ts create mode 100644 docs/src/lib/colors.ts delete mode 100644 docs/src/lib/git.ts create mode 100644 docs/src/lib/layout.shared.tsx delete mode 100644 docs/src/lib/mermaid-themes.ts create mode 100644 docs/src/lib/og.tsx delete mode 100644 docs/src/lib/seo.ts delete mode 100644 docs/src/mdx-components.tsx diff --git a/docs/.eslintrc.json b/docs/.eslintrc.json deleted file mode 100644 index e8c9316..0000000 --- a/docs/.eslintrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "next/core-web-vitals", - "rules": { - "react/no-unescaped-entities": "off" - } -} \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore index 0c9ff6c..c856e18 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,30 +1,5 @@ -# deps -/node_modules - -# generated content -.contentlayer -.content-collections -.source - -# test & build -/coverage -/.next/ -/out/ -/build +.next/ +.source/ +node_modules/ *.tsbuildinfo - -# misc -.DS_Store -*.pem -/.pnp -.pnp.js -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# others -.env*.local -.vercel next-env.d.ts - -.env \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 8169b22..0000000 --- a/docs/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# funnel Documentation - -this is the documentation site for funnel, built with Next.js and fumadocs diff --git a/docs/bun.lock b/docs/bun.lock index b7b23bd..bb2b149 100644 --- a/docs/bun.lock +++ b/docs/bun.lock @@ -1,261 +1,206 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { - "name": "docs", + "name": "funnel-docs", "dependencies": { - "@catppuccin/palette": "1.7.1", - "@phosphor-icons/react": "2.1.10", - "ajv": "^8.17.1", - "fumadocs-core": "15.6.3", - "fumadocs-mdx": "11.6.10", + "@tailwindcss/postcss": "4.2.4", + "@takumi-rs/core": "1.1.2", + "@takumi-rs/core-linux-x64-gnu": "1.1.2", + "@takumi-rs/image-response": "1.1.2", + "@tsparticles/path-perlin-noise": "^3.9.1", + "@tsparticles/react": "^3.0.0", + "@tsparticles/slim": "^3.9.1", + "fumadocs-core": "16.8.5", + "fumadocs-mdx": "14.3.2", "fumadocs-openapi": "9.1.10", - "fumadocs-twoslash": "3.1.4", - "fumadocs-ui": "15.6.3", - "gsap": "3.13.0", - "katex": "0.16.22", - "mermaid": "11.8.1", - "next": "15.3.7", - "ogl": "1.0.11", - "react": "19.1.3", - "react-dom": "19.1.3", - "rehype-katex": "7.0.1", - "remark-math": "6.0.0", - "shiki": "3.9.2", - "twoslash": "0.3.2", + "fumadocs-ui": "16.8.5", + "motion": "^12.38.0", + "next": "16.2.4", + "postcss": "8.5.12", + "react": "19.2.5", + "react-dom": "19.2.5", + "tailwind-variants": "^3.2.2", + "tailwindcss": "4.2.4", }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.11", - "@types/bun": "1.2.19", - "@types/mdx": "^2.0.13", - "@types/node": "24.0.7", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "eslint": "^8", - "eslint-config-next": "15.3.7", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.11", - "typescript": "^5.8.3", + "@types/mdx": "2.0.13", + "@types/node": "20.19.19", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3", }, }, }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], - - "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - - "@antfu/utils": ["@antfu/utils@8.1.1", "", {}, "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ=="], - "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], - "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], - - "@catppuccin/palette": ["@catppuccin/palette@1.7.1", "", {}, "sha512-aRc1tbzrevOTV7nFTT9SRdF26w/MIwT4Jwt4fDMc9itRZUDXCuEDBLyz4TQMlqO9ZP8mf5Hu4Jr6D03NLFc6Gw=="], - - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], - - "@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="], - - "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.0.3", "", {}, "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="], - - "@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="], - - "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], - - "@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="], - - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.8", "", { "os": "aix", "cpu": "ppc64" }, "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.8", "", { "os": "android", "cpu": "arm" }, "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.8", "", { "os": "android", "cpu": "arm64" }, "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.8", "", { "os": "android", "cpu": "x64" }, "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.8", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.8", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.8", "", { "os": "linux", "cpu": "arm" }, "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.8", "", { "os": "linux", "cpu": "ia32" }, "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.8", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.8", "", { "os": "linux", "cpu": "none" }, "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.8", "", { "os": "linux", "cpu": "s390x" }, "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.8", "", { "os": "linux", "cpu": "x64" }, "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.8", "", { "os": "none", "cpu": "x64" }, "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.8", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.8", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.8", "", { "os": "none", "cpu": "arm64" }, "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.8", "", { "os": "sunos", "cpu": "x64" }, "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], - "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@floating-ui/dom": ["@floating-ui/dom@1.7.3", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag=="], + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.5", "", { "dependencies": { "@floating-ui/dom": "^1.7.3" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q=="], - - "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - - "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.1", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg=="], + "@fumadocs/tailwind": ["@fumadocs/tailwind@0.0.5", "", { "peerDependencies": { "@tailwindcss/oxide": "^4.0.0", "tailwindcss": "^4.0.0" }, "optionalPeers": ["@tailwindcss/oxide", "tailwindcss"] }, "sha512-ENKPWUDRmriccsrUDE4bDBq3FNr/ms3BP2rWlsAEMV1yP23pcCaan+ceGfeBUsAQjw7sj9Q3R4Kl3g/TCStPzQ=="], "@fumari/json-schema-to-typescript": ["@fumari/json-schema-to-typescript@1.1.3", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.9.3", "js-yaml": "^4.1.0", "prettier": "^3.5.3" } }, "sha512-KnaZAo5W769nOaxhPqEMTdjHdngugxmPpNS+Yr2U90iVxgmNAWwhSr8Nx3l+CUehJKNFzJi2C7clQXOfuPJegA=="], - "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="], - - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - "@iconify/utils": ["@iconify/utils@2.3.0", "", { "dependencies": { "@antfu/install-pkg": "^1.0.0", "@antfu/utils": "^8.1.0", "@iconify/types": "^2.0.0", "debug": "^4.4.0", "globals": "^15.14.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "mlly": "^1.7.4" } }, "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.0" }, "os": "darwin", "cpu": "arm64" }, "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.0" }, "os": "darwin", "cpu": "x64" }, "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.0" }, "os": "linux", "cpu": "arm" }, "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.0" }, "os": "linux", "cpu": "ppc64" }, "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.0" }, "os": "linux", "cpu": "s390x" }, "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" }, "os": "linux", "cpu": "arm64" }, "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.0" }, "os": "linux", "cpu": "x64" }, "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.3", "", { "dependencies": { "@emnapi/runtime": "^1.4.4" }, "cpu": "none" }, "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], - "@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="], - - "@mermaid-js/parser": ["@mermaid-js/parser@0.6.2", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ=="], - - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], - "@next/env": ["@next/env@15.3.7", "", {}, "sha512-1fzTVgpRoCwd0zo0/Xs5dt0vM4Ir7/uoeC3FjmuluNALQlllIPQ1zCOL6GMV8QTwK92e9htZTwqaRedzudD7Bg=="], + "@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="], - "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.3.7", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-DmX7Jd08Y88/vhEjufBNgG+NwHCAqIZ/MNNUkx3wDCToRnICwqPT6f4Sjek9WSujq6rw21BJJFMu4i1cKfMm5w=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw=="], + "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], - - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], - - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - - "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], - - "@orama/orama": ["@orama/orama@3.1.11", "", {}, "sha512-Szki0cgFiXE5F9RLx2lUyEtJllnuCSQ4B8RLDwIjXkVit6qZjoDAxH+xhJs29MjKLDz0tbPLdKFa6QrQ/qoGGA=="], - - "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], - "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], - "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], @@ -263,39 +208,39 @@ "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g=="], + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="], - "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="], + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], - "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="], + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], - "@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], - "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], @@ -317,304 +262,216 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], - - "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.12.0", "", {}, "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw=="], - "@scalar/openapi-parser": ["@scalar/openapi-parser@0.18.3", "", { "dependencies": { "@scalar/openapi-types": "0.3.7", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-formats": "^3.0.1", "jsonpointer": "^5.0.1", "leven": "^4.0.0", "yaml": "2.8.0" } }, "sha512-j0OM3Y3JTVx50m7r5ogoADe2pT/ewhAnO7knjkcm7b+vjgKRpgjUMhXsYraBemFkqd2dNa1QfTX/mDVv7wEr9A=="], "@scalar/openapi-types": ["@scalar/openapi-types@0.3.7", "", { "dependencies": { "zod": "3.24.1" } }, "sha512-QHSvHBVDze3+dUwAhIGq6l1iOev4jdoqdBK7QpfeN1Q4h+6qpVEw3EEqBiH0AXUSh/iWwObBv4uMgfIx0aNZ5g=="], - "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], + "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-kUTRVKPsB/28H5Ko6qEsyudBiWEDLst+Sfi+hwr59E0GLHV0h8RfgbQU7fdN5Lt9A8R1ulRiZyTvAizkROjwDA=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Vn/w5oyQ6TUgTVDIC/BrpXwIlfK6V6kGWDVVz2eRkF2v13YoENUvaNwxMsQU/t6oCuZKzqp9vqtEtEzKl9VegA=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], - "@shikijs/langs": ["@shikijs/langs@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2" } }, "sha512-X1Q6wRRQXY7HqAuX3I8WjMscjeGjqXCg/Sve7J2GWFORXkSrXud23UECqTBIdCSNKJioFtmUGJQNKtlMMZMn0w=="], + "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], - "@shikijs/rehype": ["@shikijs/rehype@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "3.9.2", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" } }, "sha512-obHyTWAUp5cpgpr4v7T9sjEHkLUMvBHvcpYAtdB1yuWU4/IeJ8boDMpnGUvvnxVpDwARlkvBA4Hr+BISo3zwjg=="], + "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], - "@shikijs/themes": ["@shikijs/themes@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2" } }, "sha512-6z5lBPBMRfLyyEsgf6uJDHPa6NAGVzFJqH4EAZ+03+7sedYir2yJBRu2uPZOKmj43GyhVHWHvyduLDAwJQfDjA=="], + "@shikijs/rehype": ["@shikijs/rehype@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "3.23.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" } }, "sha512-GepKJxXHbXFfAkiZZZ+4V7x71Lw3s0ALYmydUxJRdvpKjSx9FOMSaunv6WRLFBXR6qjYerUq1YZQno+2gLEPwA=="], - "@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="], + "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], - "@shikijs/twoslash": ["@shikijs/twoslash@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2", "twoslash": "^0.3.3" }, "peerDependencies": { "typescript": ">=5.5.0" } }, "sha512-LRAhdZwp+4AXyScSgWAyoo1EM6lf2iTQPivl3w9fWSBi3yp4MJZfbed/6dcemd20jW4A9olzB3j1mFAKv/CRFw=="], + "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], - "@shikijs/types": ["@shikijs/types@3.9.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw=="], + "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - - "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="], - - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.11", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.11", "@tailwindcss/oxide-darwin-arm64": "4.1.11", "@tailwindcss/oxide-darwin-x64": "4.1.11", "@tailwindcss/oxide-freebsd-x64": "4.1.11", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", "@tailwindcss/oxide-linux-x64-musl": "4.1.11", "@tailwindcss/oxide-wasm32-wasi": "4.1.11", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" } }, "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg=="], - - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.11", "", { "os": "android", "cpu": "arm64" }, "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg=="], - - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ=="], - - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw=="], - - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11", "", { "os": "linux", "cpu": "arm" }, "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.11", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@emnapi/wasi-threads": "^1.0.2", "@napi-rs/wasm-runtime": "^0.2.11", "@tybys/wasm-util": "^0.9.0", "tslib": "^2.8.0" }, "cpu": "none" }, "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.11", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "postcss": "^8.4.41", "tailwindcss": "4.1.11" } }, "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="], - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="], - "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="], - "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], - "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.4", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "postcss": "^8.5.6", "tailwindcss": "4.2.4" } }, "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg=="], - "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + "@takumi-rs/core": ["@takumi-rs/core@1.1.2", "", { "dependencies": { "@takumi-rs/helpers": "1.1.2" }, "optionalDependencies": { "@takumi-rs/core-darwin-arm64": "1.1.2", "@takumi-rs/core-darwin-x64": "1.1.2", "@takumi-rs/core-linux-arm64-gnu": "1.1.2", "@takumi-rs/core-linux-arm64-musl": "1.1.2", "@takumi-rs/core-linux-x64-gnu": "1.1.2", "@takumi-rs/core-linux-x64-musl": "1.1.2", "@takumi-rs/core-win32-arm64-msvc": "1.1.2", "@takumi-rs/core-win32-x64-msvc": "1.1.2" } }, "sha512-JI0kUis5MCPzOt2sCahBZLVJERpp1t1fXTRBaBbCGploUa/hqRA9HW6Nr+tccDM/C6wy9FDrJnI4SB56W0HfDA=="], - "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + "@takumi-rs/core-darwin-arm64": ["@takumi-rs/core-darwin-arm64@1.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CLPzikYAND2xFm0Lg5HKImCOnl+Sue4F/WIcm1Z2ykL7h2r9rWDBzcFqGhAEFj9onMJH6c60Mph8kDa1jdj2rQ=="], - "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + "@takumi-rs/core-darwin-x64": ["@takumi-rs/core-darwin-x64@1.1.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-eH351x+BJOMdLQgUoVKEIfTQbBjR6Dv7rzTLZUdWwbbwjEe38geCNsxQunHQ96nsB7M+0sGc8aJjOkd+0qoK3g=="], - "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + "@takumi-rs/core-linux-arm64-gnu": ["@takumi-rs/core-linux-arm64-gnu@1.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-2cuSnKWAfQjQwS+7aprz0LRkztPVsGqWGTto0TydVI81z7sNyC4kZWoJMTpkNb3439HB03UBStQVacgUeVOL+w=="], - "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + "@takumi-rs/core-linux-arm64-musl": ["@takumi-rs/core-linux-arm64-musl@1.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7OIdvVy6lbr49LIBe96uvlOg4Awmye5w+DgL4d0rRi1Zv7bmY23ciBCGaM+7jjkZgcThGrlZKz1Ecopx56pPqA=="], - "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + "@takumi-rs/core-linux-x64-gnu": ["@takumi-rs/core-linux-x64-gnu@1.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IXEhQjGSGye+kwawDad00kN2cLpj1rPF5u0K4KsANvWxa/dho32Nnw+RX8cCtPGiuvEKhtENxtdc7pVSx1E84g=="], - "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + "@takumi-rs/core-linux-x64-musl": ["@takumi-rs/core-linux-x64-musl@1.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Q2vuZCwQ/XmHwwbJxSKNNEKdWdhsvBMhTF8pyyGti5s44yx7W6+a0MkDdMRAkStzDhH6HeZlpknpHcUk4R4g8w=="], - "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + "@takumi-rs/core-win32-arm64-msvc": ["@takumi-rs/core-win32-arm64-msvc@1.1.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-CKpslxYCltih/lL+h7xkVzsfIZtDcXp3WGk01fudgx82Ic87b/jjRMX5KE4f/exkgrKxshRF4yCQGGeQ3vIhiw=="], - "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + "@takumi-rs/core-win32-x64-msvc": ["@takumi-rs/core-win32-x64-msvc@1.1.2", "", { "os": "win32", "cpu": "x64" }, "sha512-c+n2vPpDscMuTHZ29eJp8did5tlJ/KKBKicc51D6AdkA5jhr7MLeryzIKe3ob59iQttWYd/1j4+tUwoO6yEusg=="], - "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + "@takumi-rs/helpers": ["@takumi-rs/helpers@1.1.2", "", { "peerDependencies": { "react": "^19.2.5", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-Edvmk7UzPhlbUCYpIqeBBfq9h/lAeWAhtwIhJJ10vxv7qNWjgfIPakzNWeAYhFVj8KT5y47S3RLT3cKVniqI5g=="], - "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + "@takumi-rs/image-response": ["@takumi-rs/image-response@1.1.2", "", { "dependencies": { "takumi-js": "1.1.2" } }, "sha512-kXkGzHdFBAXH/05KEFkgVP2oJScTeWD7nORQzEiBajYthRyU44ZBctZzEBobvWfagDhfeS86sD+OZB1AjSE1KA=="], - "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + "@takumi-rs/wasm": ["@takumi-rs/wasm@1.1.2", "", { "dependencies": { "@takumi-rs/helpers": "1.1.2" } }, "sha512-in2kLgFp6nuXie5q7blvRk8m/3UuvNDtc5SEFUh6Eu2yn20PcElj1VMdXhQhn+IN0h119BUWWdJ3AuEuh8PiDg=="], - "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + "@tsparticles/basic": ["@tsparticles/basic@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1", "@tsparticles/move-base": "3.9.1", "@tsparticles/plugin-hex-color": "3.9.1", "@tsparticles/plugin-hsl-color": "3.9.1", "@tsparticles/plugin-rgb-color": "3.9.1", "@tsparticles/shape-circle": "3.9.1", "@tsparticles/updater-color": "3.9.1", "@tsparticles/updater-opacity": "3.9.1", "@tsparticles/updater-out-modes": "3.9.1", "@tsparticles/updater-size": "3.9.1" } }, "sha512-ijr2dHMx0IQHqhKW3qA8tfwrR2XYbbWYdaJMQuBo2CkwBVIhZ76U+H20Y492j/NXpd1FUnt2aC0l4CEVGVGdeQ=="], - "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + "@tsparticles/engine": ["@tsparticles/engine@3.9.1", "", {}, "sha512-DpdgAhWMZ3Eh2gyxik8FXS6BKZ8vyea+Eu5BC4epsahqTGY9V3JGGJcXC6lRJx6cPMAx1A0FaQAojPF3v6rkmQ=="], - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + "@tsparticles/interaction-external-attract": ["@tsparticles/interaction-external-attract@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-5AJGmhzM9o4AVFV24WH5vSqMBzOXEOzIdGLIr+QJf4fRh9ZK62snsusv/ozKgs2KteRYQx+L7c5V3TqcDy2upg=="], - "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + "@tsparticles/interaction-external-bounce": ["@tsparticles/interaction-external-bounce@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-bv05+h70UIHOTWeTsTI1AeAmX6R3s8nnY74Ea6p6AbQjERzPYIa0XY19nq/hA7+Nrg+EissP5zgoYYeSphr85A=="], - "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + "@tsparticles/interaction-external-bubble": ["@tsparticles/interaction-external-bubble@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-tbd8ox/1GPl+zr+KyHQVV1bW88GE7OM6i4zql801YIlCDrl9wgTDdDFGIy9X7/cwTvTrCePhrfvdkUamXIribQ=="], - "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + "@tsparticles/interaction-external-connect": ["@tsparticles/interaction-external-connect@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-sq8YfUNsIORjXHzzW7/AJQtfi/qDqLnYG2qOSE1WOsog39MD30RzmiOloejOkfNeUdcGUcfsDgpUuL3UhzFUOA=="], - "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + "@tsparticles/interaction-external-grab": ["@tsparticles/interaction-external-grab@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-QwXza+sMMWDaMiFxd8y2tJwUK6c+nNw554+/9+tEZeTTk2fCbB0IJ7p/TH6ZGWDL0vo2muK54Njv2fEey191ow=="], - "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "@tsparticles/interaction-external-pause": ["@tsparticles/interaction-external-pause@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-Gzv4/FeNir0U/tVM9zQCqV1k+IAgaFjDU3T30M1AeAsNGh/rCITV2wnT7TOGFkbcla27m4Yxa+Fuab8+8pzm+g=="], - "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + "@tsparticles/interaction-external-push": ["@tsparticles/interaction-external-push@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-GvnWF9Qy4YkZdx+WJL2iy9IcgLvzOIu3K7aLYJFsQPaxT8d9TF8WlpoMlWKnJID6H5q4JqQuMRKRyWH8aAKyQw=="], - "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + "@tsparticles/interaction-external-remove": ["@tsparticles/interaction-external-remove@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-yPThm4UDWejDOWW5Qc8KnnS2EfSo5VFcJUQDWc1+Wcj17xe7vdSoiwwOORM0PmNBzdDpSKQrte/gUnoqaUMwOA=="], - "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + "@tsparticles/interaction-external-repulse": ["@tsparticles/interaction-external-repulse@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-/LBppXkrMdvLHlEKWC7IykFhzrz+9nebT2fwSSFXK4plEBxDlIwnkDxd3FbVOAbnBvx4+L8+fbrEx+RvC8diAw=="], - "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "@tsparticles/interaction-external-slow": ["@tsparticles/interaction-external-slow@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-1ZYIR/udBwA9MdSCfgADsbDXKSFS0FMWuPWz7bm79g3sUxcYkihn+/hDhc6GXvNNR46V1ocJjrj0u6pAynS1KQ=="], - "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + "@tsparticles/interaction-particles-attract": ["@tsparticles/interaction-particles-attract@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-CYYYowJuGwRLUixQcSU/48PTKM8fCUYThe0hXwQ+yRMLAn053VHzL7NNZzKqEIeEyt5oJoy9KcvubjKWbzMBLQ=="], - "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@tsparticles/interaction-particles-collisions": ["@tsparticles/interaction-particles-collisions@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-ggGyjW/3v1yxvYW1IF1EMT15M6w31y5zfNNUPkqd/IXRNPYvm0Z0ayhp+FKmz70M5p0UxxPIQHTvAv9Jqnuj8w=="], - "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + "@tsparticles/interaction-particles-links": ["@tsparticles/interaction-particles-links@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-MsLbMjy1vY5M5/hu/oa5OSRZAUz49H3+9EBMTIOThiX+a+vpl3sxc9AqNd9gMsPbM4WJlub8T6VBZdyvzez1Vg=="], - "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@tsparticles/move-base": ["@tsparticles/move-base@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-X4huBS27d8srpxwOxliWPUt+NtCwY+8q/cx1DvQxyqmTA8VFCGpcHNwtqiN+9JicgzOvSuaORVqUgwlsc7h4pQ=="], - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@tsparticles/move-parallax": ["@tsparticles/move-parallax@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-whlOR0bVeyh6J/hvxf/QM3DqvNnITMiAQ0kro6saqSDItAVqg4pYxBfEsSOKq7EhjxNvfhhqR+pFMhp06zoCVA=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@tsparticles/path-perlin-noise": ["@tsparticles/path-perlin-noise@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1", "@tsparticles/perlin-noise": "3.9.1" } }, "sha512-Essnb64JVpkXeTvKukNXT338Zbc6fdRKIt+IYc0idvJT1wOISnMekigd+Mgf8bIo0L2sJnFjf54keocgTov5FQ=="], - "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], - - "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], - - "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@tsparticles/perlin-noise": ["@tsparticles/perlin-noise@3.9.1", "", {}, "sha512-JFX32N14UZMucfsGg4jpsaMoTqNOhr+ZF8rtxX+L9Rq5eNnEyz0PDgc00lqABF4C1Ah/XTCKWo+yeyDjhntGqQ=="], - "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + "@tsparticles/plugin-easing-quad": ["@tsparticles/plugin-easing-quad@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-C2UJOca5MTDXKUTBXj30Kiqr5UyID+xrY/LxicVWWZPczQW2bBxbIbfq9ULvzGDwBTxE2rdvIB8YFKmDYO45qw=="], - "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], - - "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - - "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], - - "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - - "@types/node": ["@types/node@24.0.7", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw=="], - - "@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="], - - "@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="], - - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - - "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@tsparticles/plugin-hex-color": ["@tsparticles/plugin-hex-color@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-vZgZ12AjUicJvk7AX4K2eAmKEQX/D1VEjEPFhyjbgI7A65eX72M465vVKIgNA6QArLZ1DLs7Z787LOE6GOBWsg=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/type-utils": "8.39.0", "@typescript-eslint/utils": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw=="], + "@tsparticles/plugin-hsl-color": ["@tsparticles/plugin-hsl-color@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-jJd1iGgRwX6eeNjc1zUXiJivaqC5UE+SC2A3/NtHwwoQrkfxGWmRHOsVyLnOBRcCPgBp/FpdDe6DIDjCMO715w=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.39.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg=="], + "@tsparticles/plugin-rgb-color": ["@tsparticles/plugin-rgb-color@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-SBxk7f1KBfXeTnnklbE2Hx4jBgh6I6HOtxb+Os1gTp0oaghZOkWcCD2dP4QbUu7fVNCMOcApPoMNC8RTFcy9wQ=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.39.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.39.0", "@typescript-eslint/types": "^8.39.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew=="], + "@tsparticles/react": ["@tsparticles/react@3.0.0", "", { "peerDependencies": { "@tsparticles/engine": "^3.0.2", "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-hjGEtTT1cwv6BcjL+GcVgH++KYs52bIuQGW3PWv7z3tMa8g0bd6RI/vWSLj7p//NZ3uTjEIeilYIUPBh7Jfq/Q=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0" } }, "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A=="], + "@tsparticles/shape-circle": ["@tsparticles/shape-circle@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-DqZFLjbuhVn99WJ+A9ajz9YON72RtCcvubzq6qfjFmtwAK7frvQeb6iDTp6Ze9FUipluxVZWVRG4vWTxi2B+/g=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.39.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ=="], + "@tsparticles/shape-emoji": ["@tsparticles/shape-emoji@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-ifvY63usuT+hipgVHb8gelBHSeF6ryPnMxAAEC1RGHhhXfpSRWMtE6ybr+pSsYU52M3G9+TF84v91pSwNrb9ZQ=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0", "@typescript-eslint/utils": "8.39.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q=="], + "@tsparticles/shape-image": ["@tsparticles/shape-image@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-fCA5eme8VF3oX8yNVUA0l2SLDKuiZObkijb0z3Ky0qj1HUEVlAuEMhhNDNB9E2iELTrWEix9z7BFMePp2CC7AA=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.39.0", "", {}, "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg=="], + "@tsparticles/shape-line": ["@tsparticles/shape-line@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-wT8NSp0N9HURyV05f371cHKcNTNqr0/cwUu6WhBzbshkYGy1KZUP9CpRIh5FCrBpTev34mEQfOXDycgfG0KiLQ=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.39.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.39.0", "@typescript-eslint/tsconfig-utils": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/visitor-keys": "8.39.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw=="], + "@tsparticles/shape-polygon": ["@tsparticles/shape-polygon@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-dA77PgZdoLwxnliH6XQM/zF0r4jhT01pw5y7XTeTqws++hg4rTLV9255k6R6eUqKq0FPSW1/WBsBIl7q/MmrqQ=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.0", "@typescript-eslint/types": "8.39.0", "@typescript-eslint/typescript-estree": "8.39.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ=="], + "@tsparticles/shape-square": ["@tsparticles/shape-square@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-DKGkDnRyZrAm7T2ipqNezJahSWs6xd9O5LQLe5vjrYm1qGwrFxJiQaAdlb00UNrexz1/SA7bEoIg4XKaFa7qhQ=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.0", "", { "dependencies": { "@typescript-eslint/types": "8.39.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA=="], + "@tsparticles/shape-star": ["@tsparticles/shape-star@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-kdMJpi8cdeb6vGrZVSxTG0JIjCwIenggqk0EYeKAwtOGZFBgL7eHhF2F6uu1oq8cJAbXPujEoabnLsz6mW8XaA=="], - "@typescript/vfs": ["@typescript/vfs@1.6.1", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-JwoxboBh7Oz1v38tPbkrZ62ZXNHAk9bJ7c9x0eI5zBfBnBYGhURdbnh7Z4smN/MV48Y5OCcZb58n972UtbazsA=="], + "@tsparticles/slim": ["@tsparticles/slim@3.9.1", "", { "dependencies": { "@tsparticles/basic": "3.9.1", "@tsparticles/engine": "3.9.1", "@tsparticles/interaction-external-attract": "3.9.1", "@tsparticles/interaction-external-bounce": "3.9.1", "@tsparticles/interaction-external-bubble": "3.9.1", "@tsparticles/interaction-external-connect": "3.9.1", "@tsparticles/interaction-external-grab": "3.9.1", "@tsparticles/interaction-external-pause": "3.9.1", "@tsparticles/interaction-external-push": "3.9.1", "@tsparticles/interaction-external-remove": "3.9.1", "@tsparticles/interaction-external-repulse": "3.9.1", "@tsparticles/interaction-external-slow": "3.9.1", "@tsparticles/interaction-particles-attract": "3.9.1", "@tsparticles/interaction-particles-collisions": "3.9.1", "@tsparticles/interaction-particles-links": "3.9.1", "@tsparticles/move-parallax": "3.9.1", "@tsparticles/plugin-easing-quad": "3.9.1", "@tsparticles/shape-emoji": "3.9.1", "@tsparticles/shape-image": "3.9.1", "@tsparticles/shape-line": "3.9.1", "@tsparticles/shape-polygon": "3.9.1", "@tsparticles/shape-square": "3.9.1", "@tsparticles/shape-star": "3.9.1", "@tsparticles/updater-life": "3.9.1", "@tsparticles/updater-rotate": "3.9.1", "@tsparticles/updater-stroke-color": "3.9.1" } }, "sha512-CL5cDmADU7sDjRli0So+hY61VMbdroqbArmR9Av+c1Fisa5ytr6QD7Jv62iwU2S6rvgicEe9OyRmSy5GIefwZw=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@tsparticles/updater-color": ["@tsparticles/updater-color@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-XGWdscrgEMA8L5E7exsE0f8/2zHKIqnTrZymcyuFBw2DCB6BIV+5z6qaNStpxrhq3DbIxxhqqcybqeOo7+Alpg=="], - "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], + "@tsparticles/updater-life": ["@tsparticles/updater-life@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-Oi8aF2RIwMMsjssUkCB6t3PRpENHjdZf6cX92WNfAuqXtQphr3OMAkYFJFWkvyPFK22AVy3p/cFt6KE5zXxwAA=="], - "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="], + "@tsparticles/updater-opacity": ["@tsparticles/updater-opacity@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-w778LQuRZJ+IoWzeRdrGykPYSSaTeWfBvLZ2XwYEkh/Ss961InOxZKIpcS6i5Kp/Zfw0fS1ZAuqeHwuj///Osw=="], - "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g=="], + "@tsparticles/updater-out-modes": ["@tsparticles/updater-out-modes@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-cKQEkAwbru+hhKF+GTsfbOvuBbx2DSB25CxOdhtW2wRvDBoCnngNdLw91rs+0Cex4tgEeibkebrIKFDDE6kELg=="], - "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ=="], + "@tsparticles/updater-rotate": ["@tsparticles/updater-rotate@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-9BfKaGfp28JN82MF2qs6Ae/lJr9EColMfMTHqSKljblwbpVDHte4umuwKl3VjbRt87WD9MGtla66NTUYl+WxuQ=="], - "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.11.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw=="], + "@tsparticles/updater-size": ["@tsparticles/updater-size@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-3NSVs0O2ApNKZXfd+y/zNhTXSFeG1Pw4peI8e6z/q5+XLbmue9oiEwoPy/tQLaark3oNj3JU7Q903ZijPyXSzw=="], - "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw=="], + "@tsparticles/updater-stroke-color": ["@tsparticles/updater-stroke-color@3.9.1", "", { "dependencies": { "@tsparticles/engine": "3.9.1" } }, "sha512-3x14+C2is9pZYTg9T2TiA/aM1YMq4wLdYaZDcHm3qO30DZu5oeQq0rm/6w+QOGKYY1Z3Htg9rlSUZkhTHn7eDA=="], - "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], - "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], - "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.11.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], - "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.11.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg=="], + "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], - "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA=="], + "@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="], - "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.11.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.11.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], - "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], - - "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], - - "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], - - "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], - - "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], - - "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], - - "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], - - "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], - - "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], - - "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], - "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], - "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], - - "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - - "axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="], - - "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.29", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ=="], - "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], - - "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], - - "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001733", "", {}, "sha512-e4QKw/O2Kavj2VQTKZWrwzkt3IxOmIlU6ajRb6LP64LHpBo1J67k2Hi4Vu/TgJWsNtynurfS0uK3MaUTCPfu5Q=="], + "caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], @@ -623,13 +480,7 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - "chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], - - "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], - - "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - - "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], @@ -639,199 +490,37 @@ "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], - "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], - - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], - "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - - "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], - - "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - - "cytoscape": ["cytoscape@3.33.0", "", {}, "sha512-2d2EwwhaxLWC8ahkH1PpQwCyu6EY3xDRdcEJXrLTb4fOUtVc+YWQalHU67rFS1a6ngj1fgv9dQLtJxP/KAFZEw=="], - - "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], - - "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], - - "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], - - "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], - - "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], - - "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - - "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], - - "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], - - "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], - - "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], - - "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], - - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - - "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], - - "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], - - "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], - - "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], - - "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], - - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], - - "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], - - "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], - - "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], - - "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], - - "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], - - "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], - - "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], - - "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], - - "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], - - "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], - - "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], - - "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - - "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], - - "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - - "dagre-d3-es": ["dagre-d3-es@7.0.11", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw=="], - - "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], - - "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], - - "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], - - "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], - - "dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], - - "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], - - "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], - - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - - "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - - "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - - "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], - "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], - - "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - - "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + "enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-iterator-helpers": ["es-iterator-helpers@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.0.3", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.6", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.4", "safe-array-concat": "^1.1.3" } }, "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - - "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], - - "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], - "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], - "esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], - - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "eslint": ["eslint@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.1", "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="], - - "eslint-config-next": ["eslint-config-next@15.3.7", "", { "dependencies": { "@next/eslint-plugin-next": "15.3.7", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-/4Bj88Hcnwuw4bfPNgw/3LbwiSNKMjMneN3XH+Syf1kdbIGMG0nBmipecvBtcPre8EfEUp3v9WX1mu3tWNbemw=="], - - "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], - - "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], - - "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], - - "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], - - "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], - - "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], - - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], - - "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], - "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], - - "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], @@ -843,131 +532,57 @@ "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], - "estree-util-value-to-estree": ["estree-util-value-to-estree@3.4.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-Zlp+gxis+gCfK12d3Srl2PdX2ybsEA8ZYy6vQGVQTNNYLEGRQQ56XB64bjemN8kxIKXP1nC9ip4Z+ILy9LGzvQ=="], + "estree-util-value-to-estree": ["estree-util-value-to-estree@3.5.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ=="], "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - - "exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - - "fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], - - "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="], - - "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], - - "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], - - "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "fast-xml-parser": ["fast-xml-parser@5.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="], - "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], - - "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - - "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "foreach": ["foreach@2.0.6", "", {}, "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg=="], - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], - "fumadocs-core": ["fumadocs-core@15.6.3", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.6.1", "@orama/orama": "^3.1.9", "@shikijs/rehype": "^3.7.0", "@shikijs/transformers": "^3.7.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "image-size": "^2.0.2", "negotiator": "^1.0.0", "npm-to-yarn": "^3.0.1", "react-remove-scroll": "^2.7.1", "remark": "^15.0.0", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^3.7.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@oramacloud/client": "1.x.x || 2.x.x", "@types/react": "*", "algoliasearch": "5.x.x", "next": "14.x.x || 15.x.x", "react": "18.x.x || 19.x.x", "react-dom": "18.x.x || 19.x.x" }, "optionalPeers": ["@oramacloud/client", "@types/react", "algoliasearch", "next", "react", "react-dom"] }, "sha512-71IPC6Y0ZLPHlavYormnF1r2uX/lNrTFTYCEh6Akll8hWxRNbKG9Hk4xpFJDTkU83c8eLtHk2iow/ccQkcV6Hw=="], + "fumadocs-core": ["fumadocs-core@16.8.5", "", { "dependencies": { "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.0.2", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0 || ^1.0.0", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-4MRqh/KWtR5Q5+LJd2SFv3nLDHtuZw3q8rwApd9nAWkunHVU30U17fUVq6nY+IDoLs7bSLnvDGvoE+Ynelrn3A=="], - "fumadocs-mdx": ["fumadocs-mdx@11.6.10", "", { "dependencies": { "@mdx-js/mdx": "^3.1.0", "@standard-schema/spec": "^1.0.0", "chokidar": "^4.0.3", "esbuild": "^0.25.5", "estree-util-value-to-estree": "^3.4.0", "js-yaml": "^4.1.0", "lru-cache": "^11.1.0", "picocolors": "^1.1.1", "tinyexec": "^1.0.1", "tinyglobby": "^0.2.14", "unist-util-visit": "^5.0.0", "zod": "^3.25.67" }, "peerDependencies": { "@fumadocs/mdx-remote": "^1.2.0", "fumadocs-core": "^14.0.0 || ^15.0.0", "next": "^15.3.0", "vite": "6.x.x" }, "optionalPeers": ["@fumadocs/mdx-remote", "next", "vite"], "bin": { "fumadocs-mdx": "bin.js" } }, "sha512-W13mGPKDviKHq1FdxJqbBmA8vQ0niEISUUREJU8u3q1g5lQgnZ9whZjTnvijnqiGNbBsjb8CmjU20OlmwG6nhA=="], + "fumadocs-mdx": ["fumadocs-mdx@14.3.2", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.28.0", "estree-util-value-to-estree": "^3.5.0", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "picocolors": "^1.1.1", "picomatch": "^4.0.4", "tinyexec": "^1.1.1", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "zod": "^4.3.6" }, "peerDependencies": { "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "^15.0.0 || ^16.0.0", "mdast-util-directive": "*", "next": "^15.3.0 || ^16.0.0", "react": "^19.2.0", "vite": "6.x.x || 7.x.x || 8.x.x" }, "optionalPeers": ["@types/mdast", "@types/mdx", "@types/react", "mdast-util-directive", "next", "react", "vite"], "bin": { "fumadocs-mdx": "dist/bin.js" } }, "sha512-73SoZkbUuqnD91G/0zBcaQdM1TMnYw5JJzKgkGvQTiZbtLQFuWTt8/uRqnzFMuNIUu/WY9Lo9d1iZ8G+jOVieA=="], "fumadocs-openapi": ["fumadocs-openapi@9.1.10", "", { "dependencies": { "@fumari/json-schema-to-typescript": "^1.1.3", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@scalar/openapi-parser": "0.18.3", "ajv": "^8.17.1", "class-variance-authority": "^0.7.1", "fumadocs-core": "15.6.9", "fumadocs-ui": "15.6.9", "github-slugger": "^2.0.0", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.1.0", "next-themes": "^0.4.6", "openapi-sampler": "^1.6.1", "react-hook-form": "^7.62.0", "remark": "^15.0.1", "remark-rehype": "^11.1.2", "shiki": "^3.9.2", "tinyglobby": "^0.2.14", "xml-js": "^1.6.11" }, "peerDependencies": { "@scalar/api-client-react": "*", "@types/react": "*", "react": "18.x.x || 19.x.x", "react-dom": "18.x.x || 19.x.x" }, "optionalPeers": ["@scalar/api-client-react", "@types/react"] }, "sha512-0ybW/yn9XevfpGfSNPzZql0JvtrKvD6wst1iRPpJHglPasWS4C9tCGdR3iyabME21FE/d+UUz7jbvMWG5Ip4kw=="], - "fumadocs-twoslash": ["fumadocs-twoslash@3.1.4", "", { "dependencies": { "@radix-ui/react-popover": "^1.1.14", "@shikijs/twoslash": "^3.6.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-gfm": "^3.1.0", "mdast-util-to-hast": "^13.2.0", "shiki": "^3.6.0", "tailwind-merge": "^3.3.1", "twoslash": "^0.3.1" }, "peerDependencies": { "@types/react": "*", "fumadocs-ui": "^15.0.0", "react": "18.x.x || 19.x.x" }, "optionalPeers": ["@types/react"] }, "sha512-mD3byKodAZ9c7OG6coppMUg/KcaYlM5DznTR4Yh0/adFkaToFYJcK/cJHHc/hHSou9WOXlwdSOrDUdMny8Qugw=="], - - "fumadocs-ui": ["fumadocs-ui@15.6.3", "", { "dependencies": { "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-direction": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-presence": "^1.1.4", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "class-variance-authority": "^0.7.1", "fumadocs-core": "15.6.3", "lodash.merge": "^4.6.2", "next-themes": "^0.4.6", "postcss-selector-parser": "^7.1.0", "react-medium-image-zoom": "^5.2.14", "scroll-into-view-if-needed": "^3.1.0", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@types/react": "*", "next": "14.x.x || 15.x.x", "react": "18.x.x || 19.x.x", "react-dom": "18.x.x || 19.x.x", "tailwindcss": "^3.4.14 || ^4.0.0" }, "optionalPeers": ["@types/react", "next", "tailwindcss"] }, "sha512-FN2wpPacoJ6vHhwVZF+tiAezKKqOOy5hpQxBUer0Bda95I7uFyloGF/ilVMrrCSbzd5bt/bKzXRJQwcJAw9vAQ=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], - - "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "fumadocs-ui": ["fumadocs-ui@16.8.5", "", { "dependencies": { "@fumadocs/tailwind": "0.0.5", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-direction": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-presence": "^1.1.5", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "lucide-react": "^1.11.0", "motion": "^12.38.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.0.2", "tailwind-merge": "^3.5.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "16.8.5", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@takumi-rs/image-response", "@types/mdx", "@types/react", "next"] }, "sha512-caJjSfUhNkwoqumOBKfHxE1UjVHxkTsoaUhA96IvCM3G82bU2OKhf1pYtf/GbZ0XVdIlmY8Z47Cqwsze0HlXjg=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], - - "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], - "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], - - "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], - - "gsap": ["gsap@3.13.0", "", {}, "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw=="], - - "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], - - "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - - "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - - "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], - - "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], - - "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], - "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], - "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], - "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], - "hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="], + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], - "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + "hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], @@ -975,187 +590,71 @@ "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], - - "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - - "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], - "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], - - "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], - - "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], - - "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], - - "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], - - "is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="], - - "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], - - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], - - "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], - - "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], - "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - - "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], - - "is-generator-function": ["is-generator-function@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "get-proto": "^1.0.0", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ=="], - - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], - "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], - - "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], - - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - - "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], - - "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], - "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], - - "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], - - "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], - - "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], - - "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], - - "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], - - "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], - - "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], - - "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], - - "jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], - - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "json-pointer": ["json-pointer@0.6.2", "", { "dependencies": { "foreach": "^2.0.4" } }, "sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - - "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], - - "katex": ["katex@0.16.22", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], - - "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], - - "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], - - "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], - - "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], - - "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], - - "leven": ["leven@4.0.0", "", {}, "sha512-puehA3YKku3osqPlNuzGDUHq8WpwXupUg1V6NXdV38G+gr+gkBwFC8g1b/+YcIvp8gnqVIus+eJCH/eGsRmJNw=="], - - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - - "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], + "leven": ["leven@4.1.0", "", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], - "local-pkg": ["local-pkg@1.1.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.0.1", "quansync": "^0.2.8" } }, "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], - "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], - "lru-cache": ["lru-cache@11.1.0", "", {}, "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A=="], - - "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], @@ -1169,8 +668,6 @@ "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], - "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], - "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], @@ -1181,16 +678,12 @@ "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], - "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - - "mermaid": ["mermaid@11.8.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.0.4", "@iconify/utils": "^2.1.33", "@mermaid-js/parser": "^0.6.1", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.11", "dayjs": "^1.11.13", "dompurify": "^3.2.5", "katex": "^0.16.9", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^15.0.7", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-VSXJLqP1Sqw5sGr273mhvpPRhXwE6NlmMSqBZQw+yZJoAJkOIPPn/uT3teeCBx60Fkt5zEI3FrH2eVT0jXRDzw=="], - "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1209,8 +702,6 @@ "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], - "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], - "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], @@ -1263,137 +754,63 @@ "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - - "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], - "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], - "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], - - "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], - - "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], + "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "napi-postinstall": ["napi-postinstall@0.3.2", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw=="], - - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "next": ["next@15.3.7", "", { "dependencies": { "@next/env": "15.3.7", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.5", "@next/swc-darwin-x64": "15.3.5", "@next/swc-linux-arm64-gnu": "15.3.5", "@next/swc-linux-arm64-musl": "15.3.5", "@next/swc-linux-x64-gnu": "15.3.5", "@next/swc-linux-x64-musl": "15.3.5", "@next/swc-win32-arm64-msvc": "15.3.5", "@next/swc-win32-x64-msvc": "15.3.5", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-7dzkjUftt7PAtspscMY+n7dx6Gg+/si89AwwwEMiupSd5bozpQNEbkYczzcIVaYloohZUTsGImq96FGrh0rFUw=="], + "next": ["next@16.2.4", "", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], "npm-to-yarn": ["npm-to-yarn@3.0.1", "", {}, "sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - - "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], - - "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], - - "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], - - "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], - - "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], - - "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], - - "ogl": ["ogl@1.0.11", "", {}, "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], - - "oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="], - - "openapi-sampler": ["openapi-sampler@1.6.1", "", { "dependencies": { "@types/json-schema": "^7.0.7", "fast-xml-parser": "^4.5.0", "json-pointer": "0.6.2" } }, "sha512-s1cIatOqrrhSj2tmJ4abFYZQK6l5v+V4toO5q1Pa0DyN8mtyqy2I+Qrj5W9vOELEtybIMQs/TBZGVO/DtTFK8w=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], - "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], - - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - - "package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="], - - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "openapi-sampler": ["openapi-sampler@1.7.3", "", { "dependencies": { "@types/json-schema": "^7.0.7", "fast-xml-parser": "^5.5.1", "json-pointer": "0.6.2" } }, "sha512-Qgy2+Z7xR3l7kXurtzi1PCtzAINkFKhBADBe/8cidC2fQrLUQTudLiJjQDnqJXoisWAR6zaHhC0hP6Hn5vja+g=="], "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], - - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - - "pkg-types": ["pkg-types@2.2.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ=="], - - "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], - - "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postcss": ["postcss@8.5.12", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA=="], - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], - "postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="], - - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - - "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], - - "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - - "quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="], - - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - - "react": ["react@19.1.3", "", {}, "sha512-A4YW2fBH0DryMfkcPOfIPd6YO7jP/0KguaRmONj69EcxMbHerGFzxeCqfznKipB8aJIwr/VefHwehTakgfDA1g=="], - - "react-dom": ["react-dom@19.1.3", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.3" } }, "sha512-stZrJtR8ovt0w3Dn8kTWwcx8hGHMhwjYOcv7hyzPPX/Ym+yl+Z39XFa+1meucWq0FgxF5IIE3h93SYP1kWB0tg=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], - "react-hook-form": ["react-hook-form@7.62.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="], + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-hook-form": ["react-hook-form@7.75.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw=="], - "react-medium-image-zoom": ["react-medium-image-zoom@5.3.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-RCIzVlsKqy3BYgGgYbolUfuvx0aSKC7YhX/IJGEp+WJxsqdIVYJHkBdj++FAj6VD7RiWj6VVmdCfa/9vJE9hZg=="], + "react-medium-image-zoom": ["react-medium-image-zoom@5.4.5", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-58QSIRK6X3uw2fSTejJRnH0JuKTZl7ZJYX+sAMaYx4YTEm33gsNdnP5RuQSCnBiAvisQeErqZWAT31bR89WB6g=="], - "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], @@ -1403,17 +820,13 @@ "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], - "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - - "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], - "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - - "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], @@ -1421,9 +834,7 @@ "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], - "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], - - "remark-mdx": ["remark-mdx@3.1.0", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA=="], + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], @@ -1433,63 +844,17 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], - - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], - - "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], - - "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], - - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - - "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], - - "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], - - "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], - - "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], - - "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], - - "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], - "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], - "sharp": ["sharp@0.34.3", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.3", "@img/sharp-darwin-x64": "0.34.3", "@img/sharp-libvips-darwin-arm64": "1.2.0", "@img/sharp-libvips-darwin-x64": "1.2.0", "@img/sharp-libvips-linux-arm": "1.2.0", "@img/sharp-libvips-linux-arm64": "1.2.0", "@img/sharp-libvips-linux-ppc64": "1.2.0", "@img/sharp-libvips-linux-s390x": "1.2.0", "@img/sharp-libvips-linux-x64": "1.2.0", "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", "@img/sharp-libvips-linuxmusl-x64": "1.2.0", "@img/sharp-linux-arm": "0.34.3", "@img/sharp-linux-arm64": "0.34.3", "@img/sharp-linux-ppc64": "0.34.3", "@img/sharp-linux-s390x": "0.34.3", "@img/sharp-linux-x64": "0.34.3", "@img/sharp-linuxmusl-arm64": "0.34.3", "@img/sharp-linuxmusl-x64": "0.34.3", "@img/sharp-wasm32": "0.34.3", "@img/sharp-win32-arm64": "0.34.3", "@img/sharp-win32-ia32": "0.34.3", "@img/sharp-win32-x64": "0.34.3" } }, "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "shiki": ["shiki@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/engine-javascript": "3.9.2", "@shikijs/engine-oniguruma": "3.9.2", "@shikijs/langs": "3.9.2", "@shikijs/themes": "3.9.2", "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-t6NKl5e/zGTvw/IyftLcumolgOczhuroqwXngDeMqJ3h3EQiTY/7wmfgPlsmloD8oYfqkEDqxiaH37Pjm1zUhQ=="], - - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - - "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -1497,103 +862,43 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], - - "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - - "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], - - "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], - - "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], - - "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], - - "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], - - "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], - - "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], - - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], - "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], - "style-to-js": ["style-to-js@1.1.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="], - - "style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="], + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], - - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], - "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], + "tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="], - "tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="], + "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], - "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], + "takumi-js": ["takumi-js@1.1.2", "", { "dependencies": { "@takumi-rs/core": "1.1.2", "@takumi-rs/helpers": "1.1.2", "@takumi-rs/wasm": "1.1.2" } }, "sha512-S8Ff0zBUQJ8VC40r7y1RPBo5QXFXV3U868M+T8B8JGt5Tko8l83DVEr1BYLOxqUenEeev853S708oE0aD50eZA=="], - "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], - "tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], - - "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], - - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], - "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], - - "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], - - "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "twoslash": ["twoslash@0.3.2", "", { "dependencies": { "@typescript/vfs": "^1.6.1", "twoslash-protocol": "0.3.2" }, "peerDependencies": { "typescript": "^5.5.0" } }, "sha512-TB+ja888uMKhbng8HzpTHm+JfxIWbngIHPy4nKEt2N93MFjpqmkqn8ppnPhIKj4kDnrohEsiogMF7T1gMY06rw=="], - - "twoslash-protocol": ["twoslash-protocol@0.3.2", "", {}, "sha512-lWIL1dGcMr7cywSLSn8ufCoeyPab3bIwPE6DmAlQYQSMjJUgzzRvSz/LsQ179eNJafRghYDlIgF2v7pmsjV3Ww=="], - - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - - "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], - - "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], - - "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], - - "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], - - "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], - - "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], - - "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - - "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], - "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], - - "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="], + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], @@ -1603,13 +908,9 @@ "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], - "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], - "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], - - "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], - - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -1617,142 +918,86 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], - - "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], - - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], - - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - - "vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], - "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], - - "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], - - "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], - - "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], - - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], - "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], "yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@eslint/eslintrc/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - - "@iconify/utils/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], - - "@scalar/openapi-types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], - - "@shikijs/twoslash/twoslash": ["twoslash@0.3.3", "", { "dependencies": { "@typescript/vfs": "^1.6.1", "twoslash-protocol": "0.3.3" }, "peerDependencies": { "typescript": "^5.5.0" } }, "sha512-Yen1RBSYh/NUR6tfK6xT7DsXkBYby7lfp078Q7XoJR5dtAeG9jB5PVL1oF2sZwHapLjaqcZNlw7GSB8himZNsQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" }, "bundled": true }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" }, "bundled": true }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="], + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - - "@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "@scalar/openapi-types/zod": ["zod@3.24.1", "", {}, "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A=="], - "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + "@shikijs/rehype/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "@shikijs/rehype/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], - "eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], - "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], - "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "eslint-plugin-import/doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "eslint-plugin-react/doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "fumadocs-openapi/fumadocs-core": ["fumadocs-core@15.6.9", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.6.1", "@orama/orama": "^3.1.11", "@shikijs/rehype": "^3.9.2", "@shikijs/transformers": "^3.9.2", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "image-size": "^2.0.2", "negotiator": "^1.0.0", "npm-to-yarn": "^3.0.1", "react-remove-scroll": "^2.7.1", "remark": "^15.0.0", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^3.9.2", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@mixedbread/sdk": "^0.19.0", "@oramacloud/client": "1.x.x || 2.x.x", "@types/react": "*", "algoliasearch": "5.x.x", "next": "14.x.x || 15.x.x", "react": "18.x.x || 19.x.x", "react-dom": "18.x.x || 19.x.x", "react-router": "7.x.x" }, "optionalPeers": ["@mixedbread/sdk", "@oramacloud/client", "@types/react", "algoliasearch", "next", "react", "react-dom", "react-router"] }, "sha512-GVrJ2C2fch3KfNz3utqvF2CeZ7Uc947lfo7Deq1mKhWlH+HY3RSUM9MMecRl+uTzu9TNWPCUwj9q0XSUBh5VbQ=="], "fumadocs-openapi/fumadocs-ui": ["fumadocs-ui@15.6.9", "", { "dependencies": { "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-direction": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-presence": "^1.1.4", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "class-variance-authority": "^0.7.1", "fumadocs-core": "15.6.9", "lodash.merge": "^4.6.2", "next-themes": "^0.4.6", "postcss-selector-parser": "^7.1.0", "react-medium-image-zoom": "^5.3.0", "scroll-into-view-if-needed": "^3.1.0", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@types/react": "*", "next": "14.x.x || 15.x.x", "react": "18.x.x || 19.x.x", "react-dom": "18.x.x || 19.x.x", "tailwindcss": "^3.4.14 || ^4.0.0" }, "optionalPeers": ["@types/react", "next", "tailwindcss"] }, "sha512-4zQcWqBJx7DuJn3IWAVrRY7o7Xx6DsScq3U8R8Qu2EJOuioJHotyLoVldSaB3wLioxtR7ito6866LZDp3oci6A=="], - "is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - - "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + "fumadocs-openapi/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@shikijs/rehype/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@shikijs/rehype/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], - "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@shikijs/rehype/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], - "@shikijs/twoslash/twoslash/twoslash-protocol": ["twoslash-protocol@0.3.3", "", {}, "sha512-26NXjXEj+2NgytwOjjhFtNpQI7Zgct6PTyLg6JO7fIbO7MIm+kx89IxvBustgBOSKxoWjNBN2LXjNHHMD7+k0g=="], + "@shikijs/rehype/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], + "@shikijs/rehype/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], - "@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "fumadocs-openapi/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "fumadocs-openapi/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], - "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + "fumadocs-openapi/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], - "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "fumadocs-openapi/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], - "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "fumadocs-openapi/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], - "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "fumadocs-openapi/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], } } diff --git a/docs/content/docs/client/cli.mdx b/docs/content/docs/client/cli.mdx new file mode 100644 index 0000000..0d4efec --- /dev/null +++ b/docs/content/docs/client/cli.mdx @@ -0,0 +1,134 @@ +--- +title: CLI Reference +description: All funnel client commands and flags +--- + +## funnel http + +Create an HTTP tunnel to a local service. + +```bash +funnel http
+``` + +`
` is a port number or `host:port`. If only a port is given, `localhost` is assumed. + +| Flag | Description | +| --- | --- | +| `--id`, `-i` | Tunnel ID (subdomain). Generated if omitted | +| `--server`, `-s` | Server URL, overrides context config | +| `--token`, `-t` | Auth token, overrides context config | +| `--quic-port` | QUIC port on the server, overrides auto-discovery | +| `--insecure` | Skip TLS certificate verification | +| `--team` | Associate tunnel with a team | + +```bash +funnel http 3000 +funnel http 3000 --id my-app +funnel http 127.0.0.1:8080 --server https://tunnel.example.com +``` + +## funnel login + +Authenticate via OAuth. Opens a browser for the OAuth flow and stores the received token. + +```bash +funnel login +funnel login --provider gitlab +``` + +| Flag | Default | Description | +| --- | --- | --- | +| `--provider` | `github` | OAuth provider name | + +## funnel logout + +Clear the stored token for the current context. + +```bash +funnel logout +``` + +## funnel whoami + +Show the currently authenticated user. + +```bash +funnel whoami +``` + +## funnel status + +List active tunnels on the server. + +```bash +funnel status +``` + +## funnel keys + +Manage API keys. + +```bash +funnel keys create --name "deploy-key" +funnel keys list +``` + +## funnel sessions + +View tunnel session history. + +```bash +funnel sessions +funnel sessions --all # admin only: show all users +funnel sessions --limit 100 +``` + +## funnel users + +Manage users (admin only). + +```bash +funnel users list +funnel users set-role --user --role admin +``` + +## funnel teams + +Manage teams. + +```bash +funnel teams create --name backend +funnel teams list +funnel teams add-member --team --user +funnel teams remove-member --team --user +funnel teams list-members --team +funnel teams set-member-role --team --user --role owner +``` + +## funnel context + +Manage server contexts. See [contexts](/docs/getting-started/contexts) for details. + +```bash +funnel context create --server +funnel context use +funnel context list +funnel context delete +``` + +## funnel config + +View the current configuration. + +```bash +funnel config show +``` + +## Global flags + +| Flag | Description | +| --- | --- | +| `--context`, `-c` | Use a specific context for this command | +| `--version` | Print version and protocol info | +| `--help` | Print help | diff --git a/docs/content/docs/core-concepts/architecture.mdx b/docs/content/docs/core-concepts/architecture.mdx deleted file mode 100644 index 8718923..0000000 --- a/docs/content/docs/core-concepts/architecture.mdx +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: architecture -icon: GraphIcon -description: a look at how funnel works under the hood. ---- - -import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; -import { Callout } from 'fumadocs-ui/components/callout'; -import { Step, Steps } from 'fumadocs-ui/components/steps'; - -funnel's architecture is designed to be both simple and efficient, relying on a persistent websocket connection to proxy http requests. the system consists of two main components: the **funnel server** and the **funnel client**. - -## system diagram - -this diagram illustrates the flow of a request from an end-user to your local application. - -:3000"] --> B["funnel client"] - B -.websocket.-> C["funnel server
:8080"] - D["browser/curl"] --> E["tunnel-id.localhost:8080"] - E --> C - C -.-> B - B --> A - - style A fill:#b7bdf8,color:#24273a - style B fill:#f5bde6,color:#24273a - style C fill:#8bd5ca,color:#24273a - style D fill:#a6da95,color:#24273a - style E fill:#91d7e3,color:#24273a -`} -/> - -## component breakdown - - - -the client runs on your local machine, alongside the service you want to expose. its primary responsibilities are: -- establishing and maintaining a persistent websocket connection to the funnel server. -- when a request is received from the server, it forwards it to the specified local service (e.g., `localhost:3000`). -- it captures the response from the local service and sends it back to the server over the websocket. -- it automatically reconnects to the server if the connection is lost. - - -the server runs on a publicly accessible machine (e.g., a vps). its responsibilities are: -- listening for websocket connections from funnel clients. -- for each connected client, it registers a unique tunnel id and manages its lifecycle. -- listening for public http traffic on a specific port (e.g., 8080). -- when a request arrives at a tunnel's public url (e.g., `your-id.your-server.com`), it proxies that request to the corresponding client over the correct websocket. -- it then waits for the response from the client and forwards it back to the original requester. - - - -## communication protocol - - - the entire system is built around the websocket protocol. this allows for a persistent, bi-directional communication channel between the client and server, which is more efficient than traditional http polling for this use case. - - - - -**connection**: the client initiates a websocket connection to the server's `/ws` endpoint, providing its desired tunnel id in the query parameters (`/ws?id=my-tunnel`). - - - -**registration**: the server validates the id. if it's available, it creates a new `tunnel` instance and associates it with the websocket connection. - - - -**request proxying**: - - an http request arrives at `my-tunnel.server.com`. - - the server's router extracts the subdomain (`my-tunnel`), finds the active tunnel, and constructs a json message containing the request details (method, path, headers, body). - - this message is sent down the websocket to the client. - - - -**local forwarding**: - - the client receives the message, reconstructs the http request, and forwards it to the local service. - - - -**response proxying**: - - the local service returns a response. - - the client captures this response, serializes it into a json message, and sends it back to the server. - - - -**final response**: - - the server receives the response message and writes its contents (status code, headers, body) back to the original http requester. - - \ No newline at end of file diff --git a/docs/content/docs/core-concepts/meta.json b/docs/content/docs/core-concepts/meta.json deleted file mode 100644 index 0e46b51..0000000 --- a/docs/content/docs/core-concepts/meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "core concepts", - "icon": "LightbulbIcon", - "pages": ["architecture"] -} diff --git a/docs/content/docs/getting-started/basic-usage.mdx b/docs/content/docs/getting-started/basic-usage.mdx deleted file mode 100644 index c206529..0000000 --- a/docs/content/docs/getting-started/basic-usage.mdx +++ /dev/null @@ -1,172 +0,0 @@ ---- -title: basic usage -icon: RocketLaunchIcon -description: a step-by-step guide to get your first tunnel running. ---- - -import { Steps } from 'fumadocs-ui/components/steps'; -import { Callout } from 'fumadocs-ui/components/callout'; -import { Card, Cards } from 'fumadocs-ui/components/card'; - - -this guide walks you through creating your first tunnel, from starting a local service to accessing it from anywhere on the internet. - - -## prerequisites - - - - - - - - -for convenience, you can set up configuration files to save server endpoints. this allows you to use `funnel http 3000` instead of specifying the full server URL each time. - - - -for client authentication, currently there is none. -what i recommend is throwing a uuid at a subdomain and hoping it sticks like spaghetti on the wall. well yea funnel uses letsencrypt for the domain, so crt.sh indexes it faster than i can say "oops i committed my api keys again". - -some buzzkills would call this a "security nightmare". some would say it's like using tissues as a bulletproof vest. but hey, at least it's documented now! that's basically the same as fixing it, right? - -well yea it is a little unoptimal - -but honestly at this point security is just performance art. we're all just improvising until someone gets pwned. - - - - -### start a local service - -first, you need a local web service to expose. if you don't have one, you can start a simple python web server. - - -this python server will serve the current directory on port 3000, perfect for testing your tunnel. - - -```bash -# this will serve the current directory on port 3000 -python3 -m http.server 3000 -``` - -you should see output like: -``` -Serving HTTP on 0.0.0.0 port 3000 (http://0.0.0.0:3000/) ... -``` - -### start the funnel server - -next, start the funnel server using docker. this is the remote component that will listen for public traffic. - - -the server acts as a bridge between public internet traffic and your local client. it runs independently and can serve multiple clients. - - -```bash -# starts the server on the default port 8080 -docker run -d --name funnel-server -p 8080:8080 ghcr.io/karol-broda/funnel-server:latest -``` - -verify the server is running: -```bash -docker logs funnel-server -``` - -you should see logs indicating the server is listening on port 8080. - -### (optional) set up configuration - -for convenience, you can create a configuration file to save your server settings. this allows you to use shorter commands in the future. - - -you must create the configuration file manually. the client will not create a default config automatically. - - -create a config file at `~/.config/funnel/config.toml` with this structure: - -```toml -[inlets.default] -server = "http://localhost:8080" -domain = "localhost:8080" -``` - -you can add more inlets for different environments: - -```toml -[inlets.default] -server = "http://localhost:8080" -domain = "localhost:8080" - -[inlets.production] -server = "https://api.tunneling.dev" -domain = "tunneling.dev" -``` - -### connect the funnel client - -now, connect your local service to the server. this command tells the funnel client to connect to your remote server and forward traffic to your local service on port 3000. - - -we're using the custom subdomain `demo` for this example. you can choose any available subdomain name. - - -```bash -# connect to the server, forwarding to localhost:3000 -funnel http 3000 --server http://localhost:8080 --id demo -``` - - -if you set up configuration, you can use the shorter command: -```bash -funnel http 3000 --id demo -``` - - -you should see output similar to: -``` -✓ Connected to server -✓ Tunnel active: http://demo.localhost:8080 -✓ Forwarding to localhost:3000 -``` - -### access your service - - -your local service is now exposed to the world! you can access it from any device with internet connectivity. - - -test your tunnel: -```bash -curl http://demo.localhost:8080 -``` - -you should see the response from your local python web server. the request flow is: -1. **curl** → **funnel server** → **websocket** → **funnel client** → **localhost:3000** -2. **localhost:3000** → **funnel client** → **websocket** → **funnel server** → **curl** - - - -## what's next? - - - - - - - - -you've successfully created your first tunnel! for production use, consider deploying your own server with TLS enabled. - \ No newline at end of file diff --git a/docs/content/docs/getting-started/contexts.mdx b/docs/content/docs/getting-started/contexts.mdx new file mode 100644 index 0000000..1bd45bd --- /dev/null +++ b/docs/content/docs/getting-started/contexts.mdx @@ -0,0 +1,78 @@ +--- +title: Contexts +description: Manage connections to multiple funnel servers +--- + +Contexts let you configure and switch between multiple funnel servers. Each context stores a server URL and authentication token. + +## Config file + +Contexts are stored in `~/.config/funnel/config.toml`: + +```toml +current_context = "production" + +[contexts.production] +server = "https://tunnel.example.com" +token = "fnl_abc123..." + +[contexts.staging] +server = "https://staging.tunnel.example.com" +token = "fnl_def456..." +``` + +The file is created with `0600` permissions to protect tokens. + +## Create a context + +```bash +funnel context create production --server https://tunnel.example.com +``` + +The first context you create automatically becomes the active one. + +## Switch contexts + +```bash +funnel context use staging +``` + +All subsequent commands use the staging server and token. + +## List contexts + +```bash +funnel context list +``` + +## Delete a context + +```bash +funnel context delete staging +``` + +If you delete the active context, funnel switches to the next available one. + +## Environment overrides + +Environment variables take precedence over the config file: + +| Variable | Overrides | +| --- | --- | +| `FUNNEL_CONTEXT` | Active context name | +| `FUNNEL_SERVER` | Server URL for the resolved context | +| `FUNNEL_TOKEN` | Token for the resolved context | + +This is useful in CI or scripts where you don't want to modify the config file: + +```bash +FUNNEL_SERVER=https://ci.tunnel.example.com FUNNEL_TOKEN=fnl_ci_key funnel http 3000 +``` + +## Per-command override + +You can also override the context for a single command with `--context`: + +```bash +funnel --context staging status +``` diff --git a/docs/content/docs/getting-started/installation.mdx b/docs/content/docs/getting-started/installation.mdx index 21edf5a..537a303 100644 --- a/docs/content/docs/getting-started/installation.mdx +++ b/docs/content/docs/getting-started/installation.mdx @@ -1,99 +1,51 @@ --- -title: installation -icon: DownloadSimpleIcon -description: how to install the funnel client and server. +title: Installation +description: Install the funnel client and server --- -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; -import { Callout } from 'fumadocs-ui/components/callout'; -import { File, Folder, Files } from 'fumadocs-ui/components/files'; +## Nix flake -get started with funnel by installing the client and running the server using docker. +Add funnel as a flake input: -## client installation +```nix +{ + inputs.funnel.url = "github:karol-broda/funnel"; +} +``` - - -the easiest way to install the `funnel` client: +Then use the package directly: ```bash -curl -LsSf https://raw.githubusercontent.com/karol-broda/funnel/master/scripts/install.sh | sh +nix run github:karol-broda/funnel -- http 3000 ``` -supports linux and macos on both amd64 and arm64 architectures. - - - -download from the [releases page](https://github.com/karol-broda/funnel/releases) and install: +Or install it to your profile: ```bash -# example for linux amd64 -sudo mv funnel /usr/local/bin/funnel -sudo chmod +x /usr/local/bin/funnel +nix profile install github:karol-broda/funnel ``` - - -requires [go](https://go.dev/doc/install) 1.21 or later: +See [NixOS module](/docs/nix/nixos-module) and [Home Manager](/docs/nix/home-manager) for declarative setups. + +## Cargo ```bash -git clone https://github.com/karol-broda/funnel.git -cd funnel -make build-client -sudo mv ./bin/funnel /usr/local/bin/funnel +cargo install funnel-client +cargo install funnel-server ``` - - -## server installation +## Binary download - -the server runs as a docker container. you'll need [docker](https://docs.docker.com/get-docker/) installed. - - -### using docker image +Pre-built binaries are available for Linux and macOS (amd64 and arm64) from [GitHub releases](https://github.com/karol-broda/funnel/releases). ```bash -# pull and verify the server image -docker pull ghcr.io/karol-broda/funnel-server:latest -docker run --rm ghcr.io/karol-broda/funnel-server:latest version +curl -fsSL https://github.com/karol-broda/funnel/releases/latest/download/install.sh | sh ``` -### building from source +The install script verifies checksums before placing binaries in your PATH. + +## Verify ```bash -git clone https://github.com/karol-broda/funnel.git -cd funnel -make build-server-docker +funnel --version ``` - -## project structure - - - - - - - - - - - - - - - - - - - - - - - - -## next steps - -**[basic usage](/docs/getting-started/basic-usage)** - create your first tunnel -**[server deployment](/docs/server/deployment)** - deploy for production -**[cli reference](/docs/reference/client-cli)** - explore all commands \ No newline at end of file diff --git a/docs/content/docs/getting-started/meta.json b/docs/content/docs/getting-started/meta.json deleted file mode 100644 index 2c10ad3..0000000 --- a/docs/content/docs/getting-started/meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "getting started", - "icon": "RocketLaunchIcon", - "pages": ["installation", "basic-usage"] -} diff --git a/docs/content/docs/getting-started/quickstart.mdx b/docs/content/docs/getting-started/quickstart.mdx new file mode 100644 index 0000000..efae066 --- /dev/null +++ b/docs/content/docs/getting-started/quickstart.mdx @@ -0,0 +1,64 @@ +--- +title: Quickstart +description: Get a tunnel running in under two minutes +--- + +This guide assumes you have a funnel server running and the client installed. If not, see [installation](/docs/getting-started/installation) and [server deployment](/docs/server/deployment). + +## Connect to a server + +Create a context pointing to your server: + +```bash +funnel context create default --server https://tunnel.example.com +``` + +## Authenticate + +Log in via OAuth (GitHub by default): + +```bash +funnel login +``` + +This opens your browser, completes the OAuth flow, and stores the token in your local config. + +## Start a tunnel + +Expose a local service on port 3000: + +```bash +funnel http 3000 +``` + +``` +funnel + + public url https://abc123.tunnel.example.com + forwarding localhost:3000 + tunnel id abc123 +``` + +The tunnel is live. Requests to the public URL are forwarded to your local service over QUIC. + +## Custom subdomain + +Pick your own tunnel ID: + +```bash +funnel http 3000 --id my-app +``` + +This gives you `https://my-app.tunnel.example.com`. + +## Check active tunnels + +```bash +funnel status +``` + +## Next steps + +- [Contexts](/docs/getting-started/contexts): manage multiple servers +- [CLI reference](/docs/client/cli): all commands and flags +- [Teams](/docs/server/teams): team-scoped tunnels diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 98a14fd..7b97172 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -1,62 +1,23 @@ --- -title: introduction -icon: HouseIcon -description: expose local services to the internet through websocket connections +title: funnel +description: Self-hosted tunneling over QUIC --- -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { Callout } from 'fumadocs-ui/components/callout'; +Funnel exposes local services through secure tunnels. It runs over QUIC with per-request streams, handles TLS automatically via Let's Encrypt, and ships as a single binary for both server and client. -
- -

🕳️ funnel

- -*a tunneling solution built with go* - -**expose local services to the internet through websocket connections** - -
- - -this tunneling solution is intended for development and testing purposes. while functional, it may not include all security features required for production environments. - - -funnel is a lightweight tunneling solution that allows you to expose your local development services to the internet. it works by creating a persistent websocket connection between a client on your machine and a remote server. - -## key features - -- **http tunneling**: expose any local http service with a single command -- **secure connections**: supports tls with let's encrypt for secure tunnels -- **custom subdomains**: use your own memorable tunnel ids -- **automatic reconnection**: client automatically reconnects if connection is lost -- **cross-platform**: single binary for linux, macos, and windows - -## how it works - -the flow is straightforward: - -1. **the funnel client** starts on your local machine and connects to **the funnel server** -2. **the funnel server** registers a unique public url for your tunnel -3. when a request comes in, the server proxies it through the websocket to the client -4. the client forwards the request to your local service -5. the response travels back through the same path - -## getting started +## Quick links - - - + + Install the client and server via Nix, Cargo, or binary download. + + + Get a tunnel running in under two minutes. + + + Run your own funnel server with PostgreSQL or embedded Turso. + + + How QUIC streams, tunnel routing, and request proxying work. + diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 5ff077b..dac3996 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -1,12 +1,26 @@ { - "title": "documentation", - "icon": "BookOpenIcon", + "title": "Documentation", + "root": true, "pages": [ "index", - "getting-started", - "core-concepts", - "reference", - "server", + "---Getting Started---", + "getting-started/installation", + "getting-started/quickstart", + "getting-started/contexts", + "---Server---", + "server/deployment", + "server/tls", + "server/authentication", + "server/teams", + "---Client---", + "client/cli", + "---NixOS---", + "nix/flake", + "nix/nixos-module", + "nix/home-manager", + "---Reference---", + "reference/architecture", + "reference/server-api", "roadmap" ] } diff --git a/docs/content/docs/nix/flake.mdx b/docs/content/docs/nix/flake.mdx new file mode 100644 index 0000000..dfd2d41 --- /dev/null +++ b/docs/content/docs/nix/flake.mdx @@ -0,0 +1,58 @@ +--- +title: Flake +description: Using funnel as a Nix flake input +--- + +Funnel provides a Nix flake with packages, NixOS modules, and Home Manager modules. + +## Add as input + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + funnel.url = "github:karol-broda/funnel"; + }; +} +``` + +## Packages + +The flake exports packages for the client and server: + +```nix +{ + # use directly + environment.systemPackages = [ + inputs.funnel.packages.${system}.default # client + inputs.funnel.packages.${system}.server + ]; +} +``` + +## Overlay + +Apply the overlay to get funnel packages in your pkgs set: + +```nix +{ + nixpkgs.overlays = [ inputs.funnel.overlays.default ]; +} +``` + +Then reference as `pkgs.funnel` and `pkgs.funnel-server`. + +## Run without installing + +```bash +nix run github:karol-broda/funnel -- http 3000 +nix run github:karol-broda/funnel#server -- --seed-api-key +``` + +## OCI containers + +The flake also builds OCI container images: + +```nix +inputs.funnel.packages.${system}.oci-server +``` diff --git a/docs/content/docs/nix/home-manager.mdx b/docs/content/docs/nix/home-manager.mdx new file mode 100644 index 0000000..c8bda27 --- /dev/null +++ b/docs/content/docs/nix/home-manager.mdx @@ -0,0 +1,74 @@ +--- +title: Home Manager +description: Declarative client configuration with Home Manager +--- + +The funnel Home Manager module manages the client config file declaratively. + +## Import + +```nix +{ + imports = [ inputs.funnel.homeManagerModules.default ]; +} +``` + +## Basic configuration + +```nix +{ + programs.funnel = { + enable = true; + settings = { + currentContext = "production"; + contexts.production = { + server = "https://tunnel.example.com"; + }; + }; + }; +} +``` + +This generates `~/.config/funnel/config.toml` with the specified settings. + +## Multiple contexts + +```nix +{ + programs.funnel.settings.contexts = { + production = { + server = "https://tunnel.example.com"; + }; + staging = { + server = "https://staging.tunnel.example.com"; + }; + }; +} +``` + +## Tokens with sops-nix + +For tokens that shouldn't be in the Nix store, use `tokenFile`: + +```nix +{ + programs.funnel.settings.contexts.production = { + server = "https://tunnel.example.com"; + tokenFile = config.sops.secrets."funnel/production-token".path; + }; +} +``` + +When any context uses `tokenFile`, the module generates the config at activation time instead of symlinking to the Nix store, substituting the file contents at the token placeholder. + +## Environment variables + +Set environment variables for the funnel client: + +```nix +{ + programs.funnel.environment = { + FUNNEL_LOG_LEVEL = "debug"; + }; +} +``` diff --git a/docs/content/docs/nix/nixos-module.mdx b/docs/content/docs/nix/nixos-module.mdx new file mode 100644 index 0000000..546e48c --- /dev/null +++ b/docs/content/docs/nix/nixos-module.mdx @@ -0,0 +1,169 @@ +--- +title: NixOS Module +description: Declarative server deployment with NixOS +--- + +The funnel NixOS module runs the server as a systemd service with hardened security settings. + +## Import + +```nix +{ + imports = [ inputs.funnel.nixosModules.default ]; +} +``` + +## Basic configuration + +```nix +{ + services.funnel.server = { + enable = true; + host = "0.0.0.0"; + port = 8080; + quicPort = 4433; + seedApiKey = true; + }; +} +``` + +## Database + +### Embedded Turso + +```nix +{ + services.funnel.server = { + enable = true; + database.tursoPath = "/var/lib/funnel/funnel.db"; + }; +} +``` + +### PostgreSQL + +```nix +{ + services.funnel.server = { + enable = true; + database = { + postgresUrl = "postgres://funnel:password@localhost/funnel"; + maxConnections = 20; + }; + }; +} +``` + +To create a local PostgreSQL database automatically: + +```nix +{ + services.funnel.server = { + enable = true; + database.createLocally = true; + }; +} +``` + +## TLS + +```nix +{ + services.funnel.server = { + enable = true; + tls = { + enable = true; + port = 443; + certDir = "/var/lib/funnel/certs"; + acme = { + email = "admin@example.com"; + staging = false; + dnsProvidersConfigFile = "/run/secrets/dns-providers.json"; + }; + }; + }; +} +``` + +## OAuth + +### GitHub + +```nix +{ + services.funnel.server = { + enable = true; + auth = { + baseUrl = "https://tunnel.example.com"; + initialAdminEmail = "admin@example.com"; + github.clientId = "your-client-id"; + }; + # secret via environment file + environmentFile = "/run/secrets/funnel-env"; + }; +} +``` + +The environment file should contain: + +``` +GITHUB_CLIENT_SECRET=your-client-secret +``` + +### Generic OIDC + +```nix +{ + services.funnel.server.auth.oauth = { + providerName = "gitlab"; + clientId = "your-client-id"; + authorizeUrl = "https://gitlab.com/oauth/authorize"; + tokenUrl = "https://gitlab.com/oauth/token"; + userinfoUrl = "https://gitlab.com/api/v4/user"; + }; +} +``` + +## Secrets + +Use `environmentFile` or `environmentFiles` to inject secrets without putting them in the Nix store: + +```nix +{ + services.funnel.server.environmentFile = "/run/secrets/funnel-env"; +} +``` + +This integrates with sops-nix, agenix, or any secret management tool that writes files. + +## Firewall + +```nix +{ + services.funnel.server = { + enable = true; + openFirewall = true; # opens port and quicPort + }; +} +``` + +## Systemd hardening + +The module applies these security settings by default: + +- `DynamicUser`: runs as a transient system user +- `PrivateTmp`: isolated temp directory +- `ProtectSystem=strict`: read-only filesystem +- `ProtectHome`: no access to home directories +- `NoNewPrivileges`: prevents privilege escalation +- Restricted syscall filters and namespaces + +Override with `serviceConfig`: + +```nix +{ + services.funnel.server.serviceConfig = { + MemoryMax = "512M"; + }; +} +``` diff --git a/docs/content/docs/reference/architecture.mdx b/docs/content/docs/reference/architecture.mdx new file mode 100644 index 0000000..211e55a --- /dev/null +++ b/docs/content/docs/reference/architecture.mdx @@ -0,0 +1,72 @@ +--- +title: Architecture +description: How funnel tunnels work under the hood +--- + +## Overview + +Funnel is a client-server system where the client maintains a persistent QUIC connection to the server. When a public request arrives at the server, it opens a new QUIC stream to the client, which forwards the request to the local service and sends the response back. + +``` + QUIC connection + ┌────────┐ (persistent, muxed) ┌────────┐ + │ Client │ ◄──────────────────────────────────► │ Server │ + └───┬────┘ └───┬────┘ + │ │ + │ HTTP HTTP │ + ▼ ▼ + ┌────────┐ ┌──────────┐ + │ Local │ │ Public │ + │ Service│ │ Internet │ + └────────┘ └──────────┘ +``` + +## Why QUIC + +Funnel uses QUIC instead of WebSocket or plain TCP for tunnel transport: + +- **Per-request streams**: each HTTP request gets its own QUIC stream. No head-of-line blocking. A slow response on one request doesn't delay others. +- **Built-in multiplexing**: multiple concurrent requests share a single connection without framing overhead. +- **Connection migration**: QUIC connections survive network changes (e.g. switching from WiFi to cellular). +- **0-RTT reconnection**: after the initial handshake, reconnections can send data immediately. + +## Connection flow + +1. Client connects to the server's QUIC port (default `4433`) +2. Client opens an initial stream and sends a registration message with the tunnel ID and auth token +3. Server validates the token, checks for ID conflicts, and registers the tunnel +4. Server assigns a subdomain: `.` +5. The QUIC connection stays open. The server opens new streams as requests arrive. + +## Request proxying + +When a request arrives at `.`: + +1. Server matches the subdomain to a registered tunnel +2. Server opens a new QUIC stream to the client +3. Server serializes the HTTP request (method, path, headers, body) and sends it over the stream +4. Client deserializes the request and forwards it to the local service via HTTP +5. Client reads the local response, serializes it, and sends it back over the same stream +6. Server reconstructs the HTTP response and sends it to the original caller + +Each request uses its own stream, so the server can handle many concurrent requests to the same tunnel without coordination. + +## WebSocket forwarding + +WebSocket upgrade requests are detected and handled specially. The tunnel transparently forwards WebSocket frames between the public client and the local service, maintaining the bidirectional connection. + +## Reconnection + +If the QUIC connection drops, the client reconnects with exponential backoff. During reconnection, the tunnel subdomain is unavailable. Once reconnected, the same tunnel ID is re-registered (if still available). + +## Subdomain routing + +The server uses a middleware layer that inspects the `Host` header of incoming requests. If the subdomain matches a registered tunnel, the request is routed to the tunnel proxy. Otherwise, it falls through to the API routes. + +## Proxy headers + +The server adds standard proxy headers to forwarded requests: + +- `X-Forwarded-For`: client IP address +- `X-Forwarded-Host`: original host header +- `X-Forwarded-Proto`: `http` or `https` diff --git a/docs/content/docs/reference/client-cli.mdx b/docs/content/docs/reference/client-cli.mdx deleted file mode 100644 index d3f0d5d..0000000 --- a/docs/content/docs/reference/client-cli.mdx +++ /dev/null @@ -1,333 +0,0 @@ ---- -title: client cli -icon: TerminalIcon -description: a reference for all `funnel` client command-line flags. ---- - -import { Callout } from 'fumadocs-ui/components/callout'; -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; - - -the `funnel` client is the command-line tool you use on your local machine to connect your service to a funnel server. - - -## primary commands - - - - - - - -## `funnel http` - -this is the primary command for creating an http tunnel. - - -```bash -funnel http [address:port | port] [flags] -``` - - -### arguments - - -you can specify either just a port or a full address:port combination. - - -**`[address:port | port]`** (required): the local address of the service you want to expose. -- if you provide just a port (e.g., `3000`), it will default to `localhost:3000`. -- you can provide a full address (e.g., `127.0.0.1:8080`). - -### flags - -| flag | shorthand | default | description | -| :-------- | :-------- | :----------------------- | :----------------------------------------------------------------------- | -| `--server` | `-s` | (from config) | the url of the remote funnel server. use `http://` or `https://`. overrides config. | -| `--id` | `-i` | (randomly generated) | the custom subdomain to request for the tunnel. | -| `--inlet` | | `default` | the inlet configuration to use from your config file. | -| `--token` | `-t` | (from config) | authentication token for the server. overrides config. | -| `--help` | `-h` | | show help for the command. | - -## configuration - -the funnel client supports configuration files to save and reuse server endpoints. this allows you to quickly switch between different tunnel servers without specifying the full URL each time. - -### config file location - -the client looks for configuration in the following order: -1. `$XDG_CONFIG_HOME/funnel/config.toml` (if `XDG_CONFIG_HOME` is set) -2. `~/.config/funnel/config.toml` (default location) - - -use `funnel config set-server` and `funnel config set-token` to create and manage your config file automatically. - - -### config file format - -configuration uses TOML format with the following structure: - -```toml -[inlets.default] -server = "http://localhost:8080" -domain = "localhost:8080" -token = "sk_your_token_here" - -[inlets.production] -server = "https://api.tunneling.dev" -domain = "tunneling.dev" -token = "sk_production_token" - -[inlets.staging] -server = "https://staging.tunneling.dev" -domain = "staging.tunneling.dev" -token = "sk_staging_token" -``` - -### inlet configuration - -each inlet section contains: -- **`server`** (required): the URL of the tunnel server -- **`domain`** (optional): the domain for display purposes -- **`token`** (optional): authentication token for the server - - -the token is stored in plain text in the config file. the file is created with restricted permissions (0600), but ensure your machine is secure. - - -### using inlets - - -use the `--inlet` flag to specify which configuration to use, or omit it to use the default inlet. - - -```bash -# use default inlet -funnel http 3000 - -# use specific inlet -funnel http 3000 --inlet production - -# override config with command line -funnel http 3000 --inlet production --server https://custom.example.com -``` - - -if no configuration file exists or the specified inlet doesn't exist, the client will require you to specify the `--server` flag explicitly. - - -### examples - - - -expose a service on port 3000, using the default inlet: - -```bash -funnel http 3000 -``` - - -this will use the default inlet from your config file, or `localhost:8080` if no config exists. - - - - -use a specific inlet configuration: - -```bash -# use production inlet -funnel http 3000 --inlet production - -# use staging inlet with custom id -funnel http 3000 --inlet staging --id my-app-staging -``` - - -perfect for switching between different environments (development, staging, production) without remembering server URLs. - - - - -connect to a remote server with a custom subdomain: - -```bash -funnel http 3000 --server https://my-server.com --id my-app-demo -``` - - -use `https://` for secure connections when connecting to a server with TLS enabled. - - - - -expose a service on a different host and port: - -```bash -funnel http 192.168.1.100:8000 --server http://tunnel.mycompany.com:8080 --id staging-api -``` - - -you can expose services running on other machines in your network, but ensure they're accessible from your client machine. - - - - -## `funnel config` - -manage client configuration, including server urls and authentication tokens. - -### subcommands - - - -save an authentication token to your config file: - -```bash -funnel config set-token sk_your_token_here - -# for a specific inlet -funnel config set-token sk_prod_token --inlet production -``` - - -the token is stored in plain text in the config file. the file is created with restricted permissions (0600). - - - - -save a server url to your config file: - -```bash -funnel config set-server https://tunnel.example.com - -# for a specific inlet -funnel config set-server https://prod.tunnel.io --inlet production -``` - - - -display the current configuration: - -```bash -funnel config show - -# output: -# Config file: ~/.config/funnel/config.toml -# -# [default] -# server: http://localhost:8080 -# token: sk_7Fj2kL... -# -# [production] -# server: https://tunnel.example.com -# token: sk_prod_t... -``` - - - -show the config file path: - -```bash -funnel config path - -# output: -# /home/user/.config/funnel/config.toml -``` - - - -### config flags - -| flag | default | description | -| :--- | :------ | :---------- | -| `--inlet` | `default` | the inlet to configure | - -## `funnel version` - -displays the version of the funnel client. - -```bash -funnel version -``` - - -useful for verifying your installation and checking for updates. - - -## usage patterns - -### development workflow - - - - - - - -### common scenarios - - - -expose your local development server: - -```bash -# react development server -funnel http 3000 --inlet local --id my-react-app - -# next.js development server -funnel http 3000 --inlet local --id my-nextjs-app - -# vite development server -funnel http 5173 --inlet local --id my-vite-app -``` - - - -expose your local api for testing: - -```bash -# express api server -funnel http 8000 --inlet staging --id my-api-v1 - -# django development server -funnel http 8000 --inlet staging --id django-api - -# flask development server -funnel http 5000 --inlet staging --id flask-api -``` - - - -expose your local server for mobile app testing: - -```bash -# react native metro bundler -funnel http 8081 --inlet local --id rn-bundler - -# local api for mobile app -funnel http 3000 --inlet staging --id mobile-api -``` - - -perfect for testing your mobile app against a local backend without complex network configuration. - - - - -## troubleshooting - - -if you encounter connection issues, check these common problems: - - -- **connection refused**: ensure the server is running and accessible -- **tunnel id taken**: try a different `--id` or let the system generate one -- **local service not found**: verify your local service is running on the specified port -- **firewall blocking**: check that your firewall allows outbound websocket connections -- **inlet not found**: verify the inlet exists in your config file or use `--server` to override -- **config file not found**: create a config file manually at `~/.config/funnel/config.toml` or use `--server` flag - - -for server-specific configuration, see the [server CLI reference](/docs/reference/server-cli). - \ No newline at end of file diff --git a/docs/content/docs/reference/meta.json b/docs/content/docs/reference/meta.json deleted file mode 100644 index 709ce62..0000000 --- a/docs/content/docs/reference/meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "reference", - "icon": "FilesIcon", - "pages": ["client-cli", "server-cli", "server-api"] -} diff --git a/docs/content/docs/reference/server-api/api-keys.mdx b/docs/content/docs/reference/server-api/api-keys.mdx new file mode 100644 index 0000000..8189588 --- /dev/null +++ b/docs/content/docs/reference/server-api/api-keys.mdx @@ -0,0 +1,29 @@ +--- +title: API Keys +description: API key management +full: true +_openapi: + toc: + - depth: 2 + title: List_keys + url: '#list_keys' + - depth: 2 + title: Create_key + url: '#create_key' + - depth: 2 + title: Revoke_key + url: '#revoke_key' + structuredData: + headings: + - content: List_keys + id: list_keys + - content: Create_key + id: create_key + - content: Revoke_key + id: revoke_key + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/index.mdx b/docs/content/docs/reference/server-api/index.mdx index 9e61e41..5958235 100644 --- a/docs/content/docs/reference/server-api/index.mdx +++ b/docs/content/docs/reference/server-api/index.mdx @@ -1,76 +1,17 @@ --- title: Server API -description: Complete REST API reference for the Funnel Server -full: true +description: REST API reference for the funnel tunnel server --- -## Overview +The funnel server exposes a REST API at `/api/v1`. All endpoints except health and info require authentication via `Authorization: Bearer `. -The Funnel Server API provides comprehensive endpoints for tunnel management, monitoring, and statistics. All endpoints return JSON responses and follow RESTful conventions. +Browse the API reference by section: -## Base URL +- [Server](/docs/reference/server-api/server) - Health check and server info +- [Tunnels](/docs/reference/server-api/tunnels) - Tunnel management +- [API Keys](/docs/reference/server-api/api-keys) - API key management +- [Profile](/docs/reference/server-api/profile) - Current user profile, accounts, sessions +- [Users](/docs/reference/server-api/users) - User management (admin) +- [Teams](/docs/reference/server-api/teams) - Team management -``` -http://localhost:8080/api -``` - -## Authentication - -Currently, the API does not require authentication. This will be added in future versions. - -## Response Format - -All API responses follow this structure: - -```json -{ - "success": true, - "data": {...}, - "error": null -} -``` - -## Endpoints - -The API is organized into the following groups: - -### Server Endpoints -- **Health Check** - Basic server health status -- **Server Stats** - Basic server statistics -- **Metrics** - Comprehensive server-wide metrics -- **Historical Metrics** - Historical server data with time ranges - -### Tunnel Endpoints -- **List Tunnels** - Get all active tunnels -- **Tunnel Info** - Get specific tunnel information -- **Tunnel Stats** - Basic tunnel statistics -- **Tunnel Metrics** - Comprehensive tunnel metrics -- **Tunnel Historical** - Historical tunnel data -- **Delete Tunnel** - Remove a tunnel - -## Interactive Documentation - -Each endpoint has detailed interactive documentation with: -- Request/response schemas -- Example requests and responses -- Try-it-out functionality -- Code samples in multiple languages - -Browse the endpoints in the navigation sidebar to explore the complete API. - -## Error Handling - -Error responses include: -- HTTP status codes (400, 404, 500, etc.) -- Descriptive error messages in the `error` field -- `success: false` in the response body - -## Rate Limiting - -Currently, no rate limiting is implemented. - -## OpenAPI Specification - -The complete OpenAPI specification is available at: -- **JSON**: `http://localhost:8080/api/swagger/doc.json` -- **Swagger UI**: `http://localhost:8080/swagger/` +The interactive API reference is also available at `/api/v1/scalar` on a running server. diff --git a/docs/content/docs/reference/server-api/meta.json b/docs/content/docs/reference/server-api/meta.json index 0c6dd0a..3580db0 100644 --- a/docs/content/docs/reference/server-api/meta.json +++ b/docs/content/docs/reference/server-api/meta.json @@ -1,8 +1,12 @@ { "title": "Server API", - "icon": "CodeIcon", "pages": [ + "index", "server", - "tunnels" + "tunnels", + "api-keys", + "profile", + "users", + "teams" ] } diff --git a/docs/content/docs/reference/server-api/profile.mdx b/docs/content/docs/reference/server-api/profile.mdx new file mode 100644 index 0000000..7a198e5 --- /dev/null +++ b/docs/content/docs/reference/server-api/profile.mdx @@ -0,0 +1,29 @@ +--- +title: Profile +description: Current user profile and sessions +full: true +_openapi: + toc: + - depth: 2 + title: List_accounts + url: '#list_accounts' + - depth: 2 + title: Get_current_user + url: '#get_current_user' + - depth: 2 + title: List_sessions + url: '#list_sessions' + structuredData: + headings: + - content: List_accounts + id: list_accounts + - content: Get_current_user + id: get_current_user + - content: List_sessions + id: list_sessions + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server.mdx b/docs/content/docs/reference/server-api/server.mdx new file mode 100644 index 0000000..0c480f8 --- /dev/null +++ b/docs/content/docs/reference/server-api/server.mdx @@ -0,0 +1,24 @@ +--- +title: Server +description: Server health and info +full: true +_openapi: + toc: + - depth: 2 + title: Get_health + url: '#get_health' + - depth: 2 + title: Get_server_info + url: '#get_server_info' + structuredData: + headings: + - content: Get_health + id: get_health + - content: Get_server_info + id: get_server_info + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server/health/get.mdx b/docs/content/docs/reference/server-api/server/health/get.mdx deleted file mode 100644 index 7577b69..0000000 --- a/docs/content/docs/reference/server-api/server/health/get.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Health check -full: true -_openapi: - method: GET - route: /health - toc: [] - structuredData: - headings: [] - contents: - - content: Returns the health status of the server ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns the health status of the server - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server/health/meta.json b/docs/content/docs/reference/server-api/server/health/meta.json deleted file mode 100644 index d0524a2..0000000 --- a/docs/content/docs/reference/server-api/server/health/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Health Check", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/server/meta.json b/docs/content/docs/reference/server-api/server/meta.json deleted file mode 100644 index f20ea87..0000000 --- a/docs/content/docs/reference/server-api/server/meta.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "title": "Server", - "icon": "HardDrivesIcon", - "pages": [ - "health", - "metrics", - "server" - ] -} diff --git a/docs/content/docs/reference/server-api/server/metrics/get.mdx b/docs/content/docs/reference/server-api/server/metrics/get.mdx deleted file mode 100644 index 6e634e9..0000000 --- a/docs/content/docs/reference/server-api/server/metrics/get.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: Get comprehensive server metrics -full: true -_openapi: - method: GET - route: /metrics - toc: [] - structuredData: - headings: [] - contents: - - content: >- - Returns detailed server-wide metrics including performance, resource - usage, and aggregated statistics ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns detailed server-wide metrics including performance, resource usage, and aggregated statistics - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server/metrics/historical/get.mdx b/docs/content/docs/reference/server-api/server/metrics/historical/get.mdx deleted file mode 100644 index 1a819e0..0000000 --- a/docs/content/docs/reference/server-api/server/metrics/historical/get.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: Get historical server metrics -full: true -_openapi: - method: GET - route: /metrics/historical - toc: [] - structuredData: - headings: [] - contents: - - content: >- - Returns time-series data for server-wide metrics over a specified time - range ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns time-series data for server-wide metrics over a specified time range - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server/metrics/historical/meta.json b/docs/content/docs/reference/server-api/server/metrics/historical/meta.json deleted file mode 100644 index fa070ab..0000000 --- a/docs/content/docs/reference/server-api/server/metrics/historical/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Historical", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/server/metrics/meta.json b/docs/content/docs/reference/server-api/server/metrics/meta.json deleted file mode 100644 index f3de3d2..0000000 --- a/docs/content/docs/reference/server-api/server/metrics/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Metrics", - "pages": ["get", "historical"] -} diff --git a/docs/content/docs/reference/server-api/server/server/meta.json b/docs/content/docs/reference/server-api/server/server/meta.json deleted file mode 100644 index 1c3f889..0000000 --- a/docs/content/docs/reference/server-api/server/server/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Server Stats", - "pages": ["stats"] -} diff --git a/docs/content/docs/reference/server-api/server/server/stats/get.mdx b/docs/content/docs/reference/server-api/server/server/stats/get.mdx deleted file mode 100644 index 5febf11..0000000 --- a/docs/content/docs/reference/server-api/server/server/stats/get.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: Get server statistics -full: true -_openapi: - method: GET - route: /server/stats - toc: [] - structuredData: - headings: [] - contents: - - content: >- - Returns comprehensive server-wide statistics including tunnel count, - request metrics, and cache performance ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns comprehensive server-wide statistics including tunnel count, request metrics, and cache performance - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/server/server/stats/meta.json b/docs/content/docs/reference/server-api/server/server/stats/meta.json deleted file mode 100644 index 79e89a2..0000000 --- a/docs/content/docs/reference/server-api/server/server/stats/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Stats", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/teams.mdx b/docs/content/docs/reference/server-api/teams.mdx new file mode 100644 index 0000000..2d9f795 --- /dev/null +++ b/docs/content/docs/reference/server-api/teams.mdx @@ -0,0 +1,49 @@ +--- +title: Teams +description: Team management +full: true +_openapi: + toc: + - depth: 2 + title: List_teams + url: '#list_teams' + - depth: 2 + title: Create_team + url: '#create_team' + - depth: 2 + title: Delete_team + url: '#delete_team' + - depth: 2 + title: List_team_members + url: '#list_team_members' + - depth: 2 + title: Add_team_member + url: '#add_team_member' + - depth: 2 + title: Remove_team_member + url: '#remove_team_member' + - depth: 2 + title: Set_team_member_role + url: '#set_team_member_role' + structuredData: + headings: + - content: List_teams + id: list_teams + - content: Create_team + id: create_team + - content: Delete_team + id: delete_team + - content: List_team_members + id: list_team_members + - content: Add_team_member + id: add_team_member + - content: Remove_team_member + id: remove_team_member + - content: Set_team_member_role + id: set_team_member_role + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels.mdx b/docs/content/docs/reference/server-api/tunnels.mdx new file mode 100644 index 0000000..7cc8e3d --- /dev/null +++ b/docs/content/docs/reference/server-api/tunnels.mdx @@ -0,0 +1,29 @@ +--- +title: Tunnels +description: Tunnel management +full: true +_openapi: + toc: + - depth: 2 + title: List_tunnels + url: '#list_tunnels' + - depth: 2 + title: Get_tunnel + url: '#get_tunnel' + - depth: 2 + title: Delete_tunnel + url: '#delete_tunnel' + structuredData: + headings: + - content: List_tunnels + id: list_tunnels + - content: Get_tunnel + id: get_tunnel + - content: Delete_tunnel + id: delete_tunnel + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/meta.json b/docs/content/docs/reference/server-api/tunnels/meta.json deleted file mode 100644 index 9378ad3..0000000 --- a/docs/content/docs/reference/server-api/tunnels/meta.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "title": "Tunnels", - "icon": "LinkIcon", - "pages": [ - "tunnels" - ] -} diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/get.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/get.mdx deleted file mode 100644 index 7dfba12..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/get.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: List active tunnels -full: true -_openapi: - method: GET - route: /tunnels - toc: [] - structuredData: - headings: [] - contents: - - content: Returns a list of all currently active tunnel connections ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns a list of all currently active tunnel connections - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/meta.json b/docs/content/docs/reference/server-api/tunnels/tunnels/meta.json deleted file mode 100644 index b88a65a..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Tunnel Operations", - "pages": ["get", "tunnelid"] -} diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/delete.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/delete.mdx deleted file mode 100644 index da90271..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/delete.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Delete tunnel -full: true -_openapi: - method: DELETE - route: /tunnels/{tunnelId} - toc: [] - structuredData: - headings: [] - contents: - - content: Forcibly closes a tunnel connection ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Forcibly closes a tunnel connection - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/get.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/get.mdx deleted file mode 100644 index 6b81687..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/get.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Get tunnel details -full: true -_openapi: - method: GET - route: /tunnels/{tunnelId} - toc: [] - structuredData: - headings: [] - contents: - - content: Returns detailed information about a specific tunnel ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns detailed information about a specific tunnel - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/get.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/get.mdx deleted file mode 100644 index c67c749..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/get.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: Get tunnel historical data -full: true -_openapi: - method: GET - route: /tunnels/{tunnelId}/historical - toc: [] - structuredData: - headings: [] - contents: - - content: >- - Returns time-series data for a specific tunnel over a specified time - range ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns time-series data for a specific tunnel over a specified time range - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/meta.json b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/meta.json deleted file mode 100644 index fa070ab..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/historical/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Historical", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/meta.json b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/meta.json deleted file mode 100644 index e127078..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Specific Tunnel", - "pages": ["get", "delete", "stats", "metrics", "historical"] -} diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/get.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/get.mdx deleted file mode 100644 index d8d6923..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/get.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Get comprehensive tunnel metrics -full: true -_openapi: - method: GET - route: /tunnels/{tunnelId}/metrics - toc: [] - structuredData: - headings: [] - contents: - - content: Returns detailed performance and usage metrics for a specific tunnel ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns detailed performance and usage metrics for a specific tunnel - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/meta.json b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/meta.json deleted file mode 100644 index bd62b35..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/metrics/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Metrics", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/get.mdx b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/get.mdx deleted file mode 100644 index 6128a50..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/get.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Get tunnel statistics -full: true -_openapi: - method: GET - route: /tunnels/{tunnelId}/stats - toc: [] - structuredData: - headings: [] - contents: - - content: Returns detailed statistics and metrics for a specific tunnel ---- - -{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - -Returns detailed statistics and metrics for a specific tunnel - - \ No newline at end of file diff --git a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/meta.json b/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/meta.json deleted file mode 100644 index 79e89a2..0000000 --- a/docs/content/docs/reference/server-api/tunnels/tunnels/tunnelid/stats/meta.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "title": "Stats", - "pages": ["get"] -} diff --git a/docs/content/docs/reference/server-api/users.mdx b/docs/content/docs/reference/server-api/users.mdx new file mode 100644 index 0000000..3ac86ea --- /dev/null +++ b/docs/content/docs/reference/server-api/users.mdx @@ -0,0 +1,34 @@ +--- +title: Users +description: User management (admin only) +full: true +_openapi: + toc: + - depth: 2 + title: List_users + url: '#list_users' + - depth: 2 + title: Deactivate_user + url: '#deactivate_user' + - depth: 2 + title: Reactivate_user + url: '#reactivate_user' + - depth: 2 + title: Set_user_role + url: '#set_user_role' + structuredData: + headings: + - content: List_users + id: list_users + - content: Deactivate_user + id: deactivate_user + - content: Reactivate_user + id: reactivate_user + - content: Set_user_role + id: set_user_role + contents: [] +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/docs/content/docs/reference/server-cli.mdx b/docs/content/docs/reference/server-cli.mdx deleted file mode 100644 index ff1c0ce..0000000 --- a/docs/content/docs/reference/server-cli.mdx +++ /dev/null @@ -1,173 +0,0 @@ ---- -title: server cli & tls -icon: TerminalWindowIcon -description: a reference for all `funnel-server` command-line flags and how to configure tls. ---- - -import { Callout } from 'fumadocs-ui/components/callout'; -import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; - - -the funnel server runs exclusively as a docker container for easy deployment and management. - - -## running the server - -basic command to start the server: - -```bash -docker run -d --name funnel-server [options] ghcr.io/karol-broda/funnel-server:latest -``` - -## configuration - - - -| variable | default | description | -| --- | --- | --- | -| `FUNNEL_PORT` | `8080` | http traffic port | -| `FUNNEL_HOST` | `0.0.0.0` | host interface to bind | -| `FUNNEL_REQUIRE_AUTH` | `false` | require token authentication | -| `FUNNEL_TOKEN_STORE` | `/var/lib/funnel/tokens.json` | token storage file path | -| `FUNNEL_ENABLE_TLS` | `false` | enable automatic tls | -| `FUNNEL_TLS_PORT` | `8443` | https traffic port | -| `FUNNEL_LETSENCRYPT_EMAIL` | - | email for let's encrypt (required for tls) | -| `FUNNEL_CERT_DIR` | `/var/lib/funnel/certs` | certificate storage directory | -| `FUNNEL_DNS_PROVIDERS_CONFIG` | - | dns provider config path (required for tls) | - - - -alternatively, pass flags directly to the container: - -| flag | shorthand | description | -| --- | --- | --- | -| `--port` | `-p` | http traffic port | -| `--require-auth` | - | require token authentication | -| `--token-store` | - | token storage file path | -| `--enable-tls` | - | enable automatic tls | -| `--tls-port` | - | https traffic port | -| `--letsencrypt-email` | - | email for let's encrypt | -| `--cert-dir` | - | certificate storage directory | -| `--dns-providers-config` | - | dns provider config path | -| `--help` | `-h` | show help | - - - -## examples - -basic server (no authentication): -```bash -docker run -d --name funnel-server -p 8080:8080 ghcr.io/karol-broda/funnel-server:latest -``` - -server with authentication: -```bash -docker run -d --name funnel-server \ - -p 8080:8080 \ - -v funnel-data:/var/lib/funnel \ - -e FUNNEL_REQUIRE_AUTH=true \ - ghcr.io/karol-broda/funnel-server:latest -``` - -server with custom port: -```bash -docker run -d --name funnel-server -p 9000:9000 -e FUNNEL_PORT=9000 ghcr.io/karol-broda/funnel-server:latest -``` - -## authentication - - -funnel supports api key-based authentication to control who can create tunnels. - - -### managing tokens - - - -```bash -# with docker -docker exec funnel-server funnel-server token create --name my-laptop - -# output: -# Token created for "my-laptop" -# -# sk_7Fj2kL9xMnPqRsTuVwXyZ0AbCdEfGhIj -# -# Save this token now - it will NOT be shown again. -# Token is persisted to disk and survives server restarts. -``` - - -save the token immediately - it cannot be retrieved later. only the hash is stored on the server. - - - - -```bash -docker exec funnel-server funnel-server token list - -# output: -# NAME PREFIX CREATED -# ---- ------ ------- -# my-laptop sk_7Fj2kL... 2 hours ago -# ci-pipeline sk_x8Yz9W... 5 days ago -``` - - - -```bash -docker exec funnel-server funnel-server token revoke --name my-laptop - -# output: -# Token "my-laptop" revoked. -``` - - - -### token storage - -tokens are hashed with sha-256 before storage. the token file is saved at `/var/lib/funnel/tokens.json` by default. - - -mount a volume at `/var/lib/funnel` to persist tokens across container restarts. - - -## tls configuration - - -funnel automatically provisions and renews tls certificates from let's encrypt using the dns-01 challenge. - - -### setup steps - -1. **create dns provider config** - see [lego documentation](https://go-acme.github.io/lego/dns/) - -```json title="dns-providers.json" -{ - "providers": [ - { - "name": "cloudflare", - "env": { - "CF_API_TOKEN": "your-cloudflare-api-token" - } - } - ] -} -``` - -2. **start with tls enabled** - -```bash -docker run -d --name funnel-server \ - -p 8080:8080 \ - -p 8443:8443 \ - -v $(pwd)/dns-providers.json:/etc/funnel/dns-providers.json \ - -v funnel-certs:/var/lib/funnel/certs \ - -e FUNNEL_ENABLE_TLS=true \ - -e FUNNEL_LETSENCRYPT_EMAIL=your-email@example.com \ - -e FUNNEL_DNS_PROVIDERS_CONFIG=/etc/funnel/dns-providers.json \ - ghcr.io/karol-broda/funnel-server:latest -``` - -certificates are generated automatically on first request and renewed before expiration. \ No newline at end of file diff --git a/docs/content/docs/roadmap.mdx b/docs/content/docs/roadmap.mdx index 8fc9a8a..be3c27a 100644 --- a/docs/content/docs/roadmap.mdx +++ b/docs/content/docs/roadmap.mdx @@ -1,172 +1,89 @@ --- -title: roadmap -icon: MapTrifoldIcon -description: current features and future plans for funnel development. +title: Roadmap +description: Planned features and future direction --- -import { Callout } from 'fumadocs-ui/components/callout'; -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; - - -funnel aims to be a simple, secure, and powerful tunneling solution for developers. this roadmap outlines our progress and future plans. - - -## current status - - - - - - - - - - - - - - - - - - - - - - -our current focus is on building a web dashboard for visual tunnel management. - - - - - - - - - - - - - - -## feature breakdown - -### ✅ **what works now** - -**http tunneling** - the core functionality is complete and stable: -- websocket-based communication between client and server -- http request/response proxying through the tunnel -- subdomain-based routing for tunnel identification - -**custom tunnel ids** - flexible tunnel naming: -- use your own memorable subdomain names -- auto-generated ids using domain-safe alphabet -- 3-63 character length with validation - -**auto-reconnection** - reliable connection management: -- clients automatically reconnect when connection is lost -- exponential backoff prevents connection storms -- graceful handling of network interruptions - -**cross-platform support** - works everywhere: -- builds for linux, macos, windows -- supports both amd64 and arm64 architectures -- single binary distribution - -**custom domains** - flexible deployment: -- works with any domain name, not just localhost -- supports production deployments with real domains -- integrates with existing dns infrastructure - -**server api** - comprehensive rest api: -- complete tunnel lifecycle management (create, read, delete) -- detailed monitoring and statistics endpoints -- server health and metrics tracking -- historical data with configurable time ranges - -**tunnel statistics** - comprehensive monitoring: -- real-time request counts and bandwidth usage -- connection health and uptime monitoring -- historical data and performance analytics -- detailed metrics for individual tunnels and server-wide stats - -**openapi documentation** - interactive api docs: -- auto-generated swagger documentation from go annotations -- interactive api playground with try-it-out functionality -- comprehensive request/response schemas and examples -- integrated with fumadocs for seamless documentation experience - -**client authentication** - secure access control: -- api key-based authentication for clients -- token creation, listing, and revocation via cli -- tokens hashed with sha-256 on server (secure storage) -- client config file support for token storage -- tokens persist across server restarts - -### 🔄 **working on next** - -**web dashboard** - visual tunnel management: -- browser-based tunnel monitoring interface -- real-time connection status and metrics -- graphical configuration and control -- integrated with server api for complete management - -### 📋 **planned improvements** - -**client api** - programmatic client control: -- sdk for embedding client functionality -- configuration management and control -- integration with existing applications - -**https by default** - automatic security: -- automatic tls certificate generation -- secure tunnel endpoints by default -- let's encrypt integration - -**oauth integration** - enterprise security: -- secure tunnel access with oauth tokens -- integration with popular oauth providers -- token-based access control - -**tcp forwarding** - broader protocol support: -- tunnel any tcp service, not just http -- support for databases, ssh, and other tcp services -- port forwarding capabilities - -**multiple tunnels per client** - improved efficiency: -- run multiple services through a single client connection -- shared connection overhead -- simplified client management - -## contributing - - -we welcome contributions! check out our github repository to see how you can help with these features. - - -**ways to contribute:** -- **feature development** - help implement planned features -- **bug fixes** - improve stability and reliability -- **documentation** - enhance guides and examples -- **testing** - help test new features and find issues -- **feedback** - share your use cases and feature requests - -**development workflow:** -1. fork the repository -2. create feature branch: `git checkout -b feature/your-awesome-feature` -3. set up development: `make dev-setup` -4. make changes and test: `make build && make test` -5. format and lint: `make fmt && make lint` -6. commit changes: `git commit -m "description"` -7. push and create pull request - -## legend - -- **✅ completed** - ready to use in current release -- **🔄 in progress** - actively being developed -- **📋 planned** - scheduled for future development - - -have ideas for features not on this roadmap? open an issue on github to discuss your suggestions! - \ No newline at end of file +## Project config file + +A `funnel.toml` in the project root that declares tunnels. `funnel up` reads it and starts everything. + +```toml +[tunnels.api] +port = 3000 +id = "my-api" +team = "backend" + +[tunnels.frontend] +port = 5173 +id = "my-frontend" +``` + +Committable to the repo so anyone on the team gets the same tunnel setup with one command. + +## Multiple tunnels per client + +Run multiple tunnels from a single process, sharing a single QUIC connection: + +```bash +funnel http 3000 --id api -- http 5173 --id frontend +``` + +## Shell completions + +```bash +funnel completion bash > /etc/bash_completion.d/funnel +funnel completion zsh > ~/.zfunc/_funnel +``` + +## Request inspection + +A local web UI at `http://127.0.0.1:4040` for inspecting requests flowing through tunnels. Live feed with headers, body, timing. Filter, replay, export as curl. + +## File serving + +```bash +funnel file ./dist +``` + +Embedded static file server with directory listing and content-type detection. + +## Tunnel access control + +```bash +funnel http 3000 --auth user:pass +funnel http 3000 --allow-ip 10.0.0.0/8 +funnel http 3000 --expires 2h +``` + +## TCP tunneling + +```bash +funnel tcp 5432 --id my-db +``` + +Raw TCP forwarding. The server allocates a port and forwards bytes bidirectionally. + +## TLS passthrough + +```bash +funnel tls 8443 --id secure-app +``` + +Forward TLS connections without terminating them. Routes via SNI. + +## UDP tunneling + +```bash +funnel udp 51820 --id wireguard +``` + +QUIC already runs over UDP, so the transport is a natural fit. + +## Other planned work + +- Self-update mechanism (`funnel update`) +- Rate limiting (per-user, per-team, per-tunnel) +- Audit logging (structured JSON events) +- OpenTelemetry integration +- HTTP/2 proxying (enables gRPC tunneling) +- Web dashboard (Leptos) diff --git a/docs/content/docs/server/authentication.mdx b/docs/content/docs/server/authentication.mdx new file mode 100644 index 0000000..ba14509 --- /dev/null +++ b/docs/content/docs/server/authentication.mdx @@ -0,0 +1,122 @@ +--- +title: Authentication +description: API keys, OAuth, and access control +--- + +Funnel uses API keys for all authenticated requests. OAuth provides a way to obtain keys through a browser flow. + +## API keys + +Every API request requires an `Authorization: Bearer ` header. Keys are created with scoped permissions: + +- **`tunnels`**: can create and use tunnels +- **`management`**: can manage keys, users, teams, and view sessions + +Keys are stored as hashes. The plaintext is only returned once at creation. + +### Seed key + +On first startup, use `--seed-api-key` to create an initial admin key: + +```bash +funnel-server --seed-api-key +``` + +The key is printed to the server logs. Use it to bootstrap your setup, then create dedicated keys for users. + +### Create keys via CLI + +```bash +funnel keys create --name "ci-deploy" +``` + +### Create keys via API + +```bash +curl -X POST https://tunnel.example.com/api/v1/keys \ + -H "Authorization: Bearer $ADMIN_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name": "ci-deploy", "scopes": ["tunnels"]}' +``` + +### Key expiry + +Keys can be created with an expiry time. After expiry, the key is rejected. + +### Revoke keys + +```bash +funnel keys list +# find the key ID, then: +curl -X DELETE https://tunnel.example.com/api/v1/keys/ \ + -H "Authorization: Bearer $ADMIN_KEY" +``` + +## OAuth + +OAuth lets users log in through a browser and receive an API key automatically. Two providers are supported. + +### GitHub + +```bash +funnel-server \ + --base-url https://tunnel.example.com \ + --github-client-id \ + --github-client-secret +``` + +| Flag | Env | +| --- | --- | +| `--github-client-id` | `GITHUB_CLIENT_ID` | +| `--github-client-secret` | `GITHUB_CLIENT_SECRET` | +| `--base-url` | `BASE_URL` | + +Set the GitHub OAuth callback URL to `https://tunnel.example.com/auth/v1/github/callback`. + +### Generic OIDC + +Any OpenID Connect provider (GitLab, Google, Keycloak, etc.): + +```bash +funnel-server \ + --base-url https://tunnel.example.com \ + --oauth-provider-name gitlab \ + --oauth-client-id \ + --oauth-client-secret \ + --oauth-authorize-url https://gitlab.com/oauth/authorize \ + --oauth-token-url https://gitlab.com/oauth/token \ + --oauth-userinfo-url https://gitlab.com/api/v4/user +``` + +| Flag | Env | Default | +| --- | --- | --- | +| `--oauth-provider-name` | `OAUTH_PROVIDER_NAME` | - | +| `--oauth-client-id` | `OAUTH_CLIENT_ID` | - | +| `--oauth-client-secret` | `OAUTH_CLIENT_SECRET` | - | +| `--oauth-authorize-url` | `OAUTH_AUTHORIZE_URL` | - | +| `--oauth-token-url` | `OAUTH_TOKEN_URL` | - | +| `--oauth-userinfo-url` | `OAUTH_USERINFO_URL` | - | +| `--oauth-scopes` | `OAUTH_SCOPES` | `openid email profile` | +| `--oauth-id-field` | `OAUTH_ID_FIELD` | `sub` | +| `--oauth-email-field` | `OAUTH_EMAIL_FIELD` | `email` | +| `--oauth-name-field` | `OAUTH_NAME_FIELD` | `name` | +| `--oauth-avatar-field` | `OAUTH_AVATAR_FIELD` | `picture` | + +The callback URL is `https://tunnel.example.com/auth/v1//callback`. + +### Client login + +```bash +funnel login # uses github by default +funnel login --provider gitlab # use a specific provider +``` + +The CLI starts a local HTTP server, opens the browser for the OAuth flow, and stores the received API key in the context config. + +## Initial admin + +Set `--initial-admin-email` to auto-promote a user to admin on their first OAuth login: + +```bash +funnel-server --initial-admin-email admin@example.com +``` diff --git a/docs/content/docs/server/deployment.mdx b/docs/content/docs/server/deployment.mdx index 54de7eb..053037e 100644 --- a/docs/content/docs/server/deployment.mdx +++ b/docs/content/docs/server/deployment.mdx @@ -1,143 +1,63 @@ --- -title: deployment -icon: PackageIcon +title: Deployment +description: Run the funnel server --- -import { Step, Steps } from 'fumadocs-ui/components/steps'; -this guide will walk you through deploying the funnel server using docker. +The server is a single binary that handles HTTP API, QUIC tunnel connections, and optionally TLS termination. -## prerequisites +## Minimal startup -- docker installed on your machine. -- a domain name for your server (for tls/https tunnels). -- dns provider credentials for automatic tls certificate generation (optional but recommended). - -## docker deployment - -the recommended way to deploy the server is using the official docker image. - - - - -### create a volume for certificates - -the server needs to persist tls certificates. create a docker volume to store them. +With the embedded Turso database (no external dependencies): ```bash -docker volume create funnel-certs +funnel-server --seed-api-key ``` - - -### basic deployment +This starts the server on port `8080` (HTTP) and `4433` (QUIC), creates an embedded database at `./funnel.db`, and prints a seed API key to stdout. -for a basic deployment without tls, you can run: +## With PostgreSQL ```bash -docker run -d --name funnel-server \ - -p 8080:8080 \ - --restart unless-stopped \ - ghcr.io/karol-broda/funnel-server:latest +funnel-server \ + --database-url postgres://user:pass@localhost/funnel \ + --seed-api-key ``` -this will start the server on port 8080 and make it accessible at `http://your-server-ip:8080`. - +Migrations run automatically on startup. The server verifies schema version compatibility before accepting connections. - -### deployment with tls (recommended) +## Configuration -for a production deployment with automatic tls certificates, you'll need to provide your domain name and dns provider configuration. +All options can be set via CLI flags or environment variables. -first, create a dns provider configuration file (see the [server cli reference](/docs/reference/server-cli) for details): +| Flag | Env | Default | Description | +| --- | --- | --- | --- | +| `--host` | `FUNNEL_HOST` | `0.0.0.0` | Bind address | +| `--port` | `FUNNEL_PORT` | `8080` | HTTP port | +| `--quic-port` | `FUNNEL_QUIC_PORT` | `4433` | QUIC port for tunnel connections | +| `--database-url` | `DATABASE_URL` | - | PostgreSQL connection URL | +| `--db-max-connections` | `FUNNEL_DB_MAX_CONNECTIONS` | `10` | Connection pool size | +| `--turso-db-path` | `FUNNEL_TURSO_DB_PATH` | `./funnel.db` | Embedded database path | +| `--seed-api-key` | `FUNNEL_SEED_API_KEY` | `false` | Create a seed API key on startup | +| `--initial-admin-email` | `FUNNEL_INITIAL_ADMIN_EMAIL` | - | Auto-promote this email to admin on first login | -```json title="dns-providers.json" -{ - "providers": [ - { - "name": "cloudflare", - "env": { - "CF_API_TOKEN": "your-cloudflare-api-token" - } - } - ] -} -``` +For TLS and OAuth options, see [TLS](/docs/server/tls) and [authentication](/docs/server/authentication). -then run the server with tls enabled: +## Database choice -```bash -docker run -d --name funnel-server \ - -p 80:8080 \ - -p 443:8443 \ - -v $(pwd)/dns-providers.json:/etc/funnel/dns-providers.json \ - -v funnel-certs:/var/lib/funnel/certs \ - -e FUNNEL_ENABLE_TLS=true \ - -e FUNNEL_LETSENCRYPT_EMAIL=your-email@example.com \ - -e FUNNEL_DNS_PROVIDERS_CONFIG=/etc/funnel/dns-providers.json \ - --restart unless-stopped \ - ghcr.io/karol-broda/funnel-server:latest -``` - -replace `your-email@example.com` with your actual email address. - +**PostgreSQL** is the production choice. It supports concurrent access, has proper ACID guarantees, and scales with connection pooling. - -### verify the deployment +**Embedded Turso** (libsql) is useful for development, single-user setups, or environments where running PostgreSQL is impractical. Data is stored in a single file. No external process needed. -check the logs to ensure the server started correctly: +If `--database-url` is set, PostgreSQL is used. Otherwise the server falls back to the embedded database at `--turso-db-path`. -```bash -docker logs -f funnel-server -``` +## Docker -you should see logs indicating that the server is listening on the configured ports. if tls is enabled, you'll also see logs about certificate generation. - - - - -## configuration - -the server can be configured using environment variables: - -| Variable | Description | Default | -| --- | --- | --- | -| `FUNNEL_HOST` | the host to listen on. | `0.0.0.0` | -| `FUNNEL_PORT` | the port for http traffic. | `8080` | -| `FUNNEL_TLS_PORT` | the port for https/tls traffic. | `8443` | -| `FUNNEL_ENABLE_TLS` | enable automatic tls certificate generation. | `false` | -| `FUNNEL_LETSENCRYPT_EMAIL` | email address for let's encrypt account (required for tls). | `""` | -| `FUNNEL_DNS_PROVIDERS_CONFIG` | path to the dns provider config file (required for tls). | `""` | -| `FUNNEL_CERT_DIR` | directory to store tls certificates. | `/var/lib/funnel/certs` | - -## docker compose (alternative) - -you can also use docker compose for easier management: - -```yaml title="docker-compose.yml" -version: '3.8' -services: - funnel-server: - image: ghcr.io/karol-broda/funnel-server:latest - container_name: funnel-server - ports: - - "80:8080" - - "443:8443" - volumes: - - ./dns-providers.json:/etc/funnel/dns-providers.json - - funnel-certs:/var/lib/funnel/certs - environment: - - FUNNEL_ENABLE_TLS=true - - FUNNEL_LETSENCRYPT_EMAIL=your-email@example.com - - FUNNEL_DNS_PROVIDERS_CONFIG=/etc/funnel/dns-providers.json - restart: unless-stopped - -volumes: - funnel-certs: -``` +OCI images are published for `linux/amd64` and `linux/arm64`: -then run: ```bash -docker-compose up -d +docker run -p 8080:8080 -p 4433:4433/udp ghcr.io/karol-broda/funnel-server ``` -for a complete list of configuration options, please refer to the [server cli reference](/docs/reference/server-cli). \ No newline at end of file +## NixOS + +For declarative NixOS deployments with systemd hardening, see the [NixOS module](/docs/nix/nixos-module). diff --git a/docs/content/docs/server/meta.json b/docs/content/docs/server/meta.json deleted file mode 100644 index 295ed72..0000000 --- a/docs/content/docs/server/meta.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "server", - "icon": "HardDrivesIcon", - "pages": ["deployment", "security"] -} diff --git a/docs/content/docs/server/security.mdx b/docs/content/docs/server/security.mdx deleted file mode 100644 index 4adb52f..0000000 --- a/docs/content/docs/server/security.mdx +++ /dev/null @@ -1,338 +0,0 @@ ---- -title: security -icon: ShieldCheckIcon -description: authentication, tls encryption, and deployment security considerations. ---- - -import { Step, Steps } from 'fumadocs-ui/components/steps'; -import { Callout } from 'fumadocs-ui/components/callout'; -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; - - -funnel supports api key-based authentication. enable it with `--require-auth` to secure your server. - - -## current security features - - - - - - - - -## authentication - -funnel supports api key-based authentication to control who can create tunnels on your server. - -### enabling authentication - -start the server with the `--require-auth` flag: - -```bash -funnel-server --require-auth -``` - -or with docker: - -```bash -docker run -d --name funnel-server \ - -p 8080:8080 \ - -v funnel-tokens:/var/lib/funnel \ - -e FUNNEL_REQUIRE_AUTH=true \ - ghcr.io/karol-broda/funnel-server:latest -``` - -### managing tokens - - - -```bash -funnel-server token create --name my-laptop - -# output: -# Token created for "my-laptop" -# -# sk_7Fj2kL9xMnPqRsTuVwXyZ0AbCdEfGhIj -# -# Save this token now - it will NOT be shown again. -# Token is persisted to disk and survives server restarts. -``` - - -the plain token is only shown once. save it immediately - you cannot retrieve it later. - - - - -```bash -funnel-server token list - -# output: -# NAME PREFIX CREATED -# ---- ------ ------- -# my-laptop sk_7Fj2kL... 2 hours ago -# ci-pipeline sk_x8Yz9W... 5 days ago -``` - - - -```bash -funnel-server token revoke --name my-laptop - -# output: -# Token "my-laptop" revoked. -``` - - - -### token storage security - -| location | storage method | security | -| --- | --- | --- | -| server (`tokens.json`) | sha-256 hashed | cannot recover plain token if file leaks | -| client (`config.toml`) | plain text | file saved with 0600 permissions | - - -tokens are hashed before storage on the server. even if the token file is compromised, attackers cannot use the hashes to authenticate. - - -### client configuration - -clients can save their token to avoid passing it on every command: - -```bash -# save token to config -funnel config set-token sk_7Fj2kL9xMnPqRsTuVwXyZ0AbCdEfGhIj - -# save server url -funnel config set-server https://tunnel.example.com - -# now just run without flags -funnel http 3000 -``` - - -the client stores the token in plain text in `~/.config/funnel/config.toml`. the file is created with restricted permissions (0600), but ensure your machine is secure. - - -## current limitations - -- **no rate limiting** - no built-in protection against abuse -- **no ip restrictions** - accepts connections from any ip address -- **no user management** - tokens are not tied to user accounts - -## tls encryption - - -the server has built-in support for automatic tls certificate generation using let's encrypt. - - - - -enable automatic tls with let's encrypt: - -```bash -docker run -d --name funnel-server \ - -p 80:8080 \ - -p 443:8443 \ - -v $(pwd)/dns-providers.json:/etc/funnel/dns-providers.json \ - -v funnel-certs:/var/lib/funnel/certs \ - -e FUNNEL_ENABLE_TLS=true \ - -e FUNNEL_LETSENCRYPT_EMAIL=your-email@example.com \ - -e FUNNEL_DNS_PROVIDERS_CONFIG=/etc/funnel/dns-providers.json \ - ghcr.io/karol-broda/funnel-server:latest -``` - -requires dns provider configuration for certificate validation. - - - -use your own certificates: - -```bash -docker run -d --name funnel-server \ - -p 443:8443 \ - -v /path/to/certs:/var/lib/funnel/certs \ - -e FUNNEL_ENABLE_TLS=true \ - ghcr.io/karol-broda/funnel-server:latest -``` - - - -automatic certificate features: -- **on-demand generation** - certificates created on first request -- **automatic renewal** - renewed before expiration -- **wildcard support** - can generate `*.domain.com` certificates -- **secure storage** - stored in docker volume - - - -## deployment security - -for additional security beyond token authentication, consider these deployment strategies: - - - -### network-level protection - -use firewall rules to restrict access: - -```bash -# allow public access to tunnel traffic -sudo ufw allow 80/tcp -sudo ufw allow 443/tcp - -# restrict websocket connections to trusted networks -sudo ufw allow from 192.168.1.0/24 to any port 8080 -sudo ufw deny 8080/tcp - -sudo ufw enable -``` - - -allow public access to ports 80/443 for tunnel traffic, but restrict the websocket port (8080) to trusted networks only. - - - - -### reverse proxy with authentication - -use nginx or similar to add authentication: - -```nginx -server { - listen 443 ssl; - server_name tunnel.yourdomain.com; - - ssl_certificate /path/to/cert.pem; - ssl_certificate_key /path/to/key.pem; - - # require basic auth for websocket connections - location ~ ^/\?id= { - auth_basic "Tunnel Access"; - auth_basic_user_file /etc/nginx/.htpasswd; - - proxy_pass http://localhost:8080; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - - # public access to tunnels - location / { - proxy_pass http://localhost:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } -} -``` - - -this adds basic authentication to websocket connections while keeping tunnel traffic public. - - - - -### vpn or private network - -deploy the server in a private network: -- use a vpn to access the server -- deploy on a private subnet -- use ssh tunneling for connections - - -the most secure approach is to deploy in a private network and use vpn/ssh for access. - - - - -## monitoring - - -the server provides comprehensive logging for monitoring connections and tunnel activity. - - -monitor these events: - - - -```bash -# view real-time connection logs -docker logs -f funnel-server - -# search for specific events -docker logs funnel-server | grep -i "websocket\|tunnel" -``` - -key events to monitor: -- websocket connection attempts -- tunnel creation and removal -- failed connection attempts - - - -```bash -# monitor certificate activities -docker logs funnel-server | grep -i "certificate\|tls\|acme" -``` - -certificate-related events: -- certificate generation -- renewal activities -- tls handshake errors - - - -```bash -# monitor errors and failures -docker logs funnel-server | grep -i "error\|failed\|rejected" -``` - -important errors: -- connection failures -- invalid tunnel ids -- certificate issues - - - -## future security features - - -we plan to add additional security features in future releases. - - -planned features: -- **rate limiting** - protection against abuse -- **ip allowlists** - built-in ip-based access control -- **user management** - user accounts and permissions -- **oauth integration** - enterprise sso support - -## security updates - - -regularly update to get the latest security patches and features. - - - - -### check for updates - -monitor [github releases](https://github.com/karol-broda/funnel/releases) for new versions. - - - -### update procedure - -```bash -# pull latest image -docker pull ghcr.io/karol-broda/funnel-server:latest - -# restart with new image -docker stop funnel-server -docker rm funnel-server -docker run -d --name funnel-server [your-config] ghcr.io/karol-broda/funnel-server:latest -``` - - \ No newline at end of file diff --git a/docs/content/docs/server/teams.mdx b/docs/content/docs/server/teams.mdx new file mode 100644 index 0000000..3cc29e3 --- /dev/null +++ b/docs/content/docs/server/teams.mdx @@ -0,0 +1,59 @@ +--- +title: Teams +description: Team management and team-scoped tunnels +--- + +Teams group users together and scope tunnel visibility. + +## Roles + +**User roles** (server-wide): +- **admin**: full access to all resources +- **member**: access to own resources and team resources + +**Team roles**: +- **owner**: can manage team members +- **member**: can create tunnels under the team + +## Create a team + +Admins can create teams: + +```bash +funnel teams create --name backend +``` + +## Manage members + +```bash +funnel teams add-member --team --user +funnel teams remove-member --team --user +funnel teams set-member-role --team --user --role owner +``` + +List members: + +```bash +funnel teams list-members --team +``` + +## Team-scoped tunnels + +Associate a tunnel with a team using `--team`: + +```bash +funnel http 3000 --team +``` + +Team-scoped tunnels are visible to all team members in the status and sessions views. Non-members cannot see or interact with them. + +## User management + +Admins can list users, change roles, and deactivate accounts: + +```bash +funnel users list +funnel users set-role --user --role admin +``` + +Deactivated users cannot authenticate and their active tunnels are rejected. diff --git a/docs/content/docs/server/tls.mdx b/docs/content/docs/server/tls.mdx new file mode 100644 index 0000000..5d8eac6 --- /dev/null +++ b/docs/content/docs/server/tls.mdx @@ -0,0 +1,89 @@ +--- +title: TLS +description: Automatic TLS with Let's Encrypt +--- + +Funnel can automatically obtain and renew TLS certificates from Let's Encrypt using DNS-01 challenges. This is required for wildcard certificates that cover all tunnel subdomains. + +## Enable TLS + +```bash +funnel-server \ + --enable-tls \ + --tls-port 443 \ + --letsencrypt-email admin@example.com \ + --dns-providers-config ./dns-providers.json +``` + +| Flag | Env | Default | Description | +| --- | --- | --- | --- | +| `--enable-tls` | `FUNNEL_ENABLE_TLS` | `false` | Enable TLS | +| `--tls-port` | `FUNNEL_TLS_PORT` | `8443` | HTTPS port | +| `--cert-dir` | `FUNNEL_CERT_DIR` | `./certs` | Certificate storage directory | +| `--letsencrypt-email` | `LETSENCRYPT_EMAIL` | - | ACME registration email | +| `--dns-providers-config` | `DNS_PROVIDERS_CONFIG` | - | Path to DNS provider config | +| `--acme-staging` | `FUNNEL_ACME_STAGING` | `false` | Use Let's Encrypt staging | + +When TLS is enabled, the HTTP port serves redirects to HTTPS. + +## DNS provider config + +The `--dns-providers-config` flag points to a JSON file that maps domain patterns to DNS providers. This tells funnel which provider to use for DNS-01 challenges. + +### Cloudflare + +```json +[ + { + "pattern": "*.tunnel.example.com", + "provider": "cloudflare", + "api_token": "your-cloudflare-api-token", + "zone_id": "your-zone-id" + } +] +``` + +The API token needs `Zone:DNS:Edit` permissions for the relevant zone. + +### Route53 + +```json +[ + { + "pattern": "*.tunnel.example.com", + "provider": "route53", + "access_key_id": "AKIA...", + "secret_access_key": "...", + "hosted_zone_id": "Z1234..." + } +] +``` + +### Exec provider + +For DNS providers not natively supported, use the `exec` provider which calls a script: + +```json +[ + { + "pattern": "*.tunnel.example.com", + "provider": "exec", + "create_command": "/usr/local/bin/dns-create.sh", + "delete_command": "/usr/local/bin/dns-delete.sh" + } +] +``` + +The scripts receive the domain and TXT record value as arguments. + +## Certificate storage + +Certificates and private keys are stored in `--cert-dir`. On restart, the server loads existing certificates before requesting new ones. Renewal happens automatically before expiry. + +## Development + +Use `--acme-staging` during development to avoid Let's Encrypt rate limits: + +```bash +funnel-server --enable-tls --acme-staging --letsencrypt-email dev@example.com --dns-providers-config ./dns.json +``` diff --git a/docs/mdx-components.tsx b/docs/mdx-components.tsx new file mode 100644 index 0000000..093f47d --- /dev/null +++ b/docs/mdx-components.tsx @@ -0,0 +1,27 @@ +import type { MDXComponents } from 'mdx/types' +import defaultMdxComponents from 'fumadocs-ui/mdx' +import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock' +import { ImageZoom, type ImageZoomProps } from 'fumadocs-ui/components/image-zoom' +import * as TabsComponents from 'fumadocs-ui/components/tabs' +import { APIPage } from 'fumadocs-openapi/ui' + +export function getMDXComponents(components?: MDXComponents): MDXComponents { + return { + ...defaultMdxComponents, + ...TabsComponents, + APIPage, + img: (props: React.ComponentProps<'img'>) => ( + + ), + pre: ({ ref: _ref, ...props }: React.ComponentProps<'pre'>) => ( + +
{props.children}
+
+ ), + ...components, + } +} + +export function useMDXComponents(components: MDXComponents): MDXComponents { + return getMDXComponents(components) +} diff --git a/docs/next.config.mjs b/docs/next.config.mjs index 340f69b..9333e30 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -1,68 +1,17 @@ -import { createMDX } from "fumadocs-mdx/next"; - -const withMDX = createMDX(); +import { createMDX } from 'fumadocs-mdx/next' /** @type {import('next').NextConfig} */ const config = { reactStrictMode: true, - serverExternalPackages: ["typescript", "twoslash"], - compress: true, - poweredByHeader: false, - generateEtags: true, - - trailingSlash: false, - skipTrailingSlashRedirect: true, - - images: { - formats: ["image/avif", "image/webp"], - minimumCacheTTL: 31536000, + serverExternalPackages: [ + '@takumi-rs/core', + '@takumi-rs/image-response', + ], + typescript: { + ignoreBuildErrors: true, }, +} +const withMDX = createMDX() - - headers: async () => { - return [ - { - source: "/(.*)", - headers: [ - { - key: "X-Content-Type-Options", - value: "nosniff", - }, - { - key: "X-Frame-Options", - value: "DENY", - }, - { - key: "X-XSS-Protection", - value: "1; mode=block", - }, - { - key: "Referrer-Policy", - value: "strict-origin-when-cross-origin", - }, - ], - }, - { - source: "/sitemap.xml", - headers: [ - { - key: "Cache-Control", - value: "public, max-age=86400, s-maxage=86400", - }, - ], - }, - { - source: "/robots.txt", - headers: [ - { - key: "Cache-Control", - value: "public, max-age=86400, s-maxage=86400", - }, - ], - }, - ]; - }, -}; - -export default withMDX(config); \ No newline at end of file +export default withMDX(config) diff --git a/docs/openapi.json b/docs/openapi.json index 3dcdb47..a242cb8 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1,949 +1,1773 @@ { - "schemes": ["http"], - "swagger": "2.0", - "info": { - "description": "REST API for tunnel management and monitoring in the funnel tunneling solution\n\nThis API provides endpoints for:\n- Health checking\n- Server statistics and monitoring\n- Tunnel management (list, view, delete)\n- Individual tunnel statistics", - "title": "Funnel Server API", - "contact": { - "name": "Funnel Support", - "url": "https://github.com/karol-broda/funnel" - }, - "license": { - "name": "MIT", - "url": "https://github.com/karol-broda/funnel/blob/master/LICENSE.md" - }, - "version": "1.0" + "openapi": "3.1.0", + "info": { + "title": "Funnel API", + "description": "Funnel tunnel server REST API", + "license": { + "name": "MIT" }, - "host": "localhost:8080", - "basePath": "/api", - "paths": { - "/health": { - "get": { - "description": "Returns the health status of the server", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Health check", - "responses": { - "200": { - "description": "Server is healthy", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HealthData" - } - } - } - ] - } - } + "version": "0.1.0" + }, + "servers": [ + { + "url": "/api/v1", + "description": "API v1" + } + ], + "paths": { + "/accounts": { + "get": { + "tags": [ + "Profile" + ], + "operationId": "list_accounts", + "responses": { + "200": { + "description": "Linked OAuth accounts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AccountView" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + } }, - "/metrics": { - "get": { - "description": "Returns detailed server-wide metrics including performance, resource usage, and aggregated statistics", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get comprehensive server metrics", - "responses": { - "200": { - "description": "Comprehensive server metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.ServerMetricsResponse" - } - } - } - ] - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/health": { + "get": { + "tags": [ + "Server" + ], + "operationId": "get_health", + "responses": { + "200": { + "description": "Server is healthy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/info": { + "get": { + "tags": [ + "Server" + ], + "operationId": "get_server_info", + "responses": { + "200": { + "description": "Server version and QUIC port", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerInfo" } + } } + } + } + } + }, + "/keys": { + "get": { + "tags": [ + "API Keys" + ], + "operationId": "list_keys", + "responses": { + "200": { + "description": "List of API keys for the current user", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ApiKeyView" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "/metrics/historical": { - "get": { - "description": "Returns time-series data for server-wide metrics over a specified time range", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get historical server metrics", - "parameters": [ - { - "type": "string", - "default": "24h", - "description": "Time range (1h, 24h, 7d)", - "name": "range", - "in": "query" - }, - { - "type": "string", - "default": "5m", - "description": "Data interval (1m, 5m, 1h)", - "name": "interval", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Historical server metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HistoricalMetricsResponse" - } - } - } - ] - } - } + "security": [ + { + "bearer": [] + } + ] + }, + "post": { + "tags": [ + "API Keys" + ], + "operationId": "create_key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKeyRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "API key created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateKeyResponse" } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "/server/stats": { - "get": { - "description": "Returns comprehensive server-wide statistics including tunnel count, request metrics, and cache performance", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Server" - ], - "summary": "Get server statistics", - "responses": { - "200": { - "description": "Server statistics retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.ServerStats" - } - } - } - ] - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/keys/{id}": { + "delete": { + "tags": [ + "API Keys" + ], + "operationId": "revoke_key", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "API key ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "API key revoked", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + }, + "404": { + "description": "Key not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "/tunnels": { - "get": { - "description": "Returns a list of all currently active tunnel connections", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "List active tunnels", - "responses": { - "200": { - "description": "List of active tunnels", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/server.TunnelInfo" - } - } - } - } - ] - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/me": { + "get": { + "tags": [ + "Profile" + ], + "operationId": "get_current_user", + "responses": { + "200": { + "description": "Current user profile", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "/tunnels/{tunnelId}": { - "get": { - "description": "Returns detailed information about a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel details", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID (3-63 characters, lowercase letters, numbers, and hyphens only)", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel details retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelInfo" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } - } - }, - "delete": { - "description": "Forcibly closes a tunnel connection", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Delete tunnel", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID to delete", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel deleted successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelDeleteData" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/sessions": { + "get": { + "tags": [ + "Profile" + ], + "operationId": "list_sessions", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "all", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Tunnel session history", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunnelSession" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + } }, - "/tunnels/{tunnelId}/historical": { - "get": { - "description": "Returns time-series data for a specific tunnel over a specified time range", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel historical data", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - }, - { - "type": "string", - "default": "24h", - "description": "Time range (1h, 24h, 7d)", - "name": "range", - "in": "query" - }, - { - "type": "string", - "description": "Start time (RFC3339 format)", - "name": "start", - "in": "query" - }, - { - "type": "string", - "description": "End time (RFC3339 format)", - "name": "end", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Tunnel historical data", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.HistoricalMetricsResponse" - } - } - } - ] - } - }, - "400": { - "description": "Invalid parameters", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/teams": { + "get": { + "tags": [ + "Teams" + ], + "operationId": "list_teams", + "responses": { + "200": { + "description": "List of teams", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Team" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + }, + "post": { + "tags": [ + "Teams" + ], + "operationId": "create_team", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTeamRequest" + } } + }, + "required": true }, - "/tunnels/{tunnelId}/metrics": { - "get": { - "description": "Returns detailed performance and usage metrics for a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get comprehensive tunnel metrics", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Comprehensive tunnel metrics", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.DetailedTunnelMetrics" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } + "responses": { + "200": { + "description": "Team created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "409": { + "description": "Team name already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + } }, - "/tunnels/{tunnelId}/stats": { - "get": { - "description": "Returns detailed statistics and metrics for a specific tunnel", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "Tunnels" - ], - "summary": "Get tunnel statistics", - "parameters": [ - { - "maxLength": 63, - "minLength": 3, - "type": "string", - "description": "Tunnel ID", - "name": "tunnelId", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Tunnel statistics retrieved successfully", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.APIResponse" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/server.TunnelStats" - } - } - } - ] - } - }, - "400": { - "description": "Invalid tunnel ID format", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - }, - "404": { - "description": "Tunnel not found", - "schema": { - "$ref": "#/definitions/server.APIResponse" - } - } + "security": [ + { + "bearer": [] + } + ] + } + }, + "/teams/{id}": { + "delete": { + "tags": [ + "Teams" + ], + "operationId": "delete_team", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Team deleted", + "content": { + "application/json": { + "schema": { + "type": "object" } + } } - } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } }, - "definitions": { - "server.APIResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data (present on success)" - }, - "error": { - "description": "Error message (present on failure)", - "type": "string", - "example": "error message" - }, - "success": { - "description": "Indicates if the request was successful", - "type": "boolean", - "example": true + "/teams/{id}/members": { + "get": { + "tags": [ + "Teams" + ], + "operationId": "list_team_members", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List of team members", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamMembership" + } } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Not a team member", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.DetailedTunnelMetrics": { - "type": "object", - "properties": { - "average_response_time_ms": { - "description": "Average response time in ms", - "type": "number", - "example": 25.5 - }, - "client_ip": { - "description": "Client IP address", - "type": "string", - "example": "192.168.1.100:52341" - }, - "connection_errors": { - "description": "Connection errors", - "type": "integer", - "example": 2 - }, - "connections_active": { - "description": "Active connections", - "type": "integer", - "example": 1 - }, - "connections_total": { - "description": "Total connections made", - "type": "integer", - "example": 3 - }, - "created_at": { - "description": "Creation time", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "data_errors": { - "description": "Data errors", - "type": "integer", - "example": 1 - }, - "last_activity": { - "description": "Last activity time", - "type": "string", - "example": "2025-08-08T20:30:00Z" - }, - "max_response_time_ms": { - "description": "Maximum response time in ms", - "type": "number", - "example": 150.8 - }, - "min_response_time_ms": { - "description": "Minimum response time in ms", - "type": "number", - "example": 5.2 - }, - "reconnect_count": { - "description": "Number of reconnections", - "type": "integer", - "example": 2 - }, - "requests_per_second": { - "description": "Requests per second", - "type": "number", - "example": 12.4 - }, - "timeout_count": { - "description": "Number of timeouts", - "type": "integer", - "example": 5 - }, - "total_bytes_in": { - "description": "Total bytes received", - "type": "integer", - "example": 125430 - }, - "total_bytes_out": { - "description": "Total bytes sent", - "type": "integer", - "example": 987650 - }, - "total_requests": { - "description": "Total requests processed", - "type": "integer", - "example": 1542 - }, - "total_requests_error": { - "description": "Failed requests", - "type": "integer", - "example": 44 - }, - "total_requests_success": { - "description": "Successful requests", - "type": "integer", - "example": 1498 - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "user_agent": { - "description": "Client user agent", - "type": "string", - "example": "FunnelClient/1.0" + "security": [ + { + "bearer": [] + } + ] + }, + "post": { + "tags": [ + "Teams" + ], + "operationId": "add_team_member", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddMemberRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Member added", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamMembership" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + }, + "403": { + "description": "Not a team owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "409": { + "description": "Already a member", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.HealthData": { - "type": "object", - "properties": { - "status": { - "description": "Service status", - "type": "string", - "example": "healthy" - }, - "timestamp": { - "description": "Current timestamp", - "type": "string", - "example": "2025-08-08T20:11:00Z" - }, - "uptime": { - "description": "Server uptime duration", - "type": "string", - "example": "6.4281425s" + "security": [ + { + "bearer": [] + } + ] + } + }, + "/teams/{id}/members/{user_id}": { + "delete": { + "tags": [ + "Teams" + ], + "operationId": "remove_team_member", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "user_id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member removed", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Cannot remove last owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Not a team owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Membership not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/teams/{id}/members/{user_id}/role": { + "put": { + "tags": [ + "Teams" + ], + "operationId": "set_team_member_role", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Team ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "user_id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetTeamMemberRoleRequest" + } } + }, + "required": true }, - "server.HistoricalDataPoint": { - "type": "object", - "properties": { - "active_connections": { - "type": "integer" - }, - "bytes_in": { - "type": "integer" - }, - "bytes_out": { - "type": "integer" - }, - "error_count": { - "type": "integer" - }, - "request_count": { - "type": "integer" - }, - "response_time_ms": { - "type": "number" - }, - "timestamp": { - "type": "string" + "responses": { + "200": { + "description": "Member role updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamMembership" } + } } + }, + "400": { + "description": "Invalid role or last owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Not a team owner", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Membership not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.HistoricalMetricsResponse": { - "type": "object", - "properties": { - "data_points": { - "description": "Historical data points", - "type": "array", - "items": { - "$ref": "#/definitions/server.HistoricalDataPoint" - } - }, - "interval": { - "description": "Data point interval", - "type": "string", - "example": "1m" - }, - "time_range": { - "description": "Time range of data", - "type": "string", - "example": "24h" - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" + "security": [ + { + "bearer": [] + } + ] + } + }, + "/tunnels": { + "get": { + "tags": [ + "Tunnels" + ], + "operationId": "list_tunnels", + "responses": { + "200": { + "description": "List of active tunnels", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunnelInfo" + } } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.ServerMetricsResponse": { - "type": "object", - "properties": { - "active_tunnels": { - "description": "Currently active tunnels", - "type": "integer", - "example": 8 - }, - "average_response_time_ms": { - "description": "Server average response time", - "type": "number", - "example": 28.7 - }, - "cpu_usage_percent": { - "description": "CPU usage percentage", - "type": "number", - "example": 15.5 - }, - "error_rate_percent": { - "description": "Error rate percentage", - "type": "number", - "example": 0.18 - }, - "memory_usage_bytes": { - "description": "Memory usage in bytes", - "type": "integer", - "example": 67108864 - }, - "network_connections": { - "description": "Active network connections", - "type": "integer", - "example": 12 - }, - "server_requests_per_second": { - "description": "Server-wide RPS", - "type": "number", - "example": 45.2 - }, - "server_uptime": { - "description": "Server uptime", - "type": "string", - "example": "5h23m15s" - }, - "total_bytes_transferred": { - "description": "Total bytes transferred", - "type": "integer", - "example": 5670000 - }, - "total_errors": { - "description": "Total errors", - "type": "integer", - "example": 234 - }, - "total_requests": { - "description": "Total requests processed", - "type": "integer", - "example": 125430 - }, - "total_tunnels": { - "description": "Total tunnels created", - "type": "integer", - "example": 15 + "security": [ + { + "bearer": [] + } + ] + } + }, + "/tunnels/{id}": { + "get": { + "tags": [ + "Tunnels" + ], + "operationId": "get_tunnel", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tunnel ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tunnel details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TunnelInfo" } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Tunnel not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.ServerStats": { - "type": "object", - "properties": { - "active_tunnels": { - "description": "Number of currently active tunnel connections", - "type": "integer", - "example": 2 - }, - "cache_hit_rate": { - "description": "Cache hit rate percentage", - "type": "string", - "example": "90.9%" - }, - "cache_hits": { - "description": "Number of cache hits for hostname resolution", - "type": "integer", - "example": 1401 - }, - "cache_misses": { - "description": "Number of cache misses for hostname resolution", - "type": "integer", - "example": 141 - }, - "router_stats": { - "description": "Detailed router statistics", - "type": "object", - "additionalProperties": true - }, - "total_requests": { - "description": "Total number of HTTP requests processed", - "type": "integer", - "example": 1542 - }, - "uptime": { - "description": "Server uptime duration", - "type": "string", - "example": "2h15m30s" + "security": [ + { + "bearer": [] + } + ] + }, + "delete": { + "tags": [ + "Tunnels" + ], + "operationId": "delete_tunnel", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tunnel ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tunnel disconnected", + "content": { + "application/json": { + "schema": { + "type": "object" } + } } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "Tunnel not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.TunnelDeleteData": { - "type": "object", - "properties": { - "message": { - "description": "Success message", - "type": "string", - "example": "tunnel deleted successfully" - }, - "tunnel_id": { - "description": "Deleted tunnel ID", - "type": "string", - "example": "my-tunnel" + "security": [ + { + "bearer": [] + } + ] + } + }, + "/users": { + "get": { + "tags": [ + "Users" + ], + "operationId": "list_users", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "List of all users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + } }, - "server.TunnelInfo": { - "type": "object", - "properties": { - "bytes_received": { - "description": "Total bytes received from client", - "type": "integer", - "example": 12543 - }, - "bytes_sent": { - "description": "Total bytes sent to client", - "type": "integer", - "example": 98765 - }, - "created_at": { - "description": "Tunnel creation timestamp", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "id": { - "description": "Unique tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "messages_received": { - "description": "Number of messages received from client", - "type": "integer", - "example": 45 - }, - "messages_sent": { - "description": "Number of messages sent to client", - "type": "integer", - "example": 45 - }, - "status": { - "description": "Current tunnel status", - "type": "string", - "enum": [ - "connected", - "disconnected" - ], - "example": "connected" - }, - "uptime": { - "description": "Tunnel connection uptime", - "type": "string", - "example": "1h15m30s" + "security": [ + { + "bearer": [] + } + ] + } + }, + "/users/{id}/deactivate": { + "post": { + "tags": [ + "Users" + ], + "operationId": "deactivate_user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "User deactivated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Cannot deactivate last admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" } + } } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - "server.TunnelStats": { - "type": "object", - "properties": { - "bytes_received": { - "description": "Bytes received total", - "type": "integer", - "example": 12543 - }, - "bytes_sent": { - "description": "Bytes sent total", - "type": "integer", - "example": 98765 - }, - "connection_info": { - "description": "Connection details", - "type": "object", - "additionalProperties": true - }, - "created_at": { - "description": "Creation timestamp", - "type": "string", - "example": "2025-08-08T18:30:00Z" - }, - "messages_received": { - "description": "Messages received count", - "type": "integer", - "example": 45 - }, - "messages_sent": { - "description": "Messages sent count", - "type": "integer", - "example": 45 - }, - "status": { - "description": "Connection status", - "type": "string", - "example": "connected" - }, - "tunnel_id": { - "description": "Tunnel identifier", - "type": "string", - "example": "my-tunnel" - }, - "uptime": { - "description": "Connection uptime", - "type": "string", - "example": "1h15m30s" + "security": [ + { + "bearer": [] + } + ] + } + }, + "/users/{id}/reactivate": { + "post": { + "tags": [ + "Users" + ], + "operationId": "reactivate_user", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "User reactivated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" } + } } - } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } }, - "tags": [ - { - "description": "Server health and statistics endpoints", - "name": "Server" + "/users/{id}/role": { + "put": { + "tags": [ + "Users" + ], + "operationId": "set_user_role", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetUserRoleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User role updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "400": { + "description": "Invalid role or last admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorBody" + } + } + } + } }, - { - "description": "Tunnel management and monitoring endpoints", - "name": "Tunnels" + "security": [ + { + "bearer": [] + } + ] + } + } + }, + "components": { + "schemas": { + "AccountView": { + "type": "object", + "required": [ + "id", + "provider", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "provider": { + "type": "string" + } + } + }, + "AddMemberRequest": { + "type": "object", + "required": [ + "user_id" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "ApiErrorBody": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } } - ] + }, + "ApiKeyView": { + "type": "object", + "required": [ + "id", + "name", + "key_prefix", + "scopes", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "key_prefix": { + "type": "string" + }, + "name": { + "type": "string" + }, + "revoked_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CreateKeyRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "name": { + "type": "string" + }, + "scopes": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + } + }, + "CreateKeyResponse": { + "type": "object", + "required": [ + "key", + "info" + ], + "properties": { + "info": { + "$ref": "#/components/schemas/ApiKeyView" + }, + "key": { + "type": "string" + } + } + }, + "CreateTeamRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "owner_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, + "HealthResponse": { + "type": "object", + "required": [ + "status", + "uptime_secs" + ], + "properties": { + "status": { + "type": "string" + }, + "uptime_secs": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "ServerInfo": { + "type": "object", + "required": [ + "version", + "quic_port" + ], + "properties": { + "quic_port": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "version": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "SetTeamMemberRoleRequest": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "type": "string" + } + } + }, + "SetUserRoleRequest": { + "type": "object", + "required": [ + "role" + ], + "properties": { + "role": { + "type": "string" + } + } + }, + "Team": { + "type": "object", + "required": [ + "id", + "name", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "TeamMembership": { + "type": "object", + "required": [ + "id", + "team_id", + "user_id", + "role", + "created_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "role": { + "type": "string" + }, + "team_id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "TunnelInfo": { + "type": "object", + "required": [ + "id", + "uptime_secs", + "stats", + "owner_id" + ], + "properties": { + "id": { + "type": "string" + }, + "owner_id": { + "type": "string", + "format": "uuid" + }, + "stats": { + "$ref": "#/components/schemas/TunnelStatsSnapshot" + }, + "team_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "uptime_secs": { + "type": "number", + "format": "double" + } + } + }, + "TunnelSession": { + "type": "object", + "required": [ + "id", + "user_id", + "tunnel_id", + "connected_at", + "bytes_in", + "bytes_out", + "requests" + ], + "properties": { + "bytes_in": { + "type": "integer", + "format": "int64" + }, + "bytes_out": { + "type": "integer", + "format": "int64" + }, + "client_ip": { + "type": [ + "string", + "null" + ] + }, + "connected_at": { + "type": "string", + "format": "date-time" + }, + "disconnected_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "requests": { + "type": "integer", + "format": "int64" + }, + "tunnel_id": { + "type": "string" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "TunnelStatsSnapshot": { + "type": "object", + "required": [ + "bytes_in", + "bytes_out", + "requests" + ], + "properties": { + "bytes_in": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "bytes_out": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "requests": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "User": { + "type": "object", + "required": [ + "id", + "email", + "role", + "metadata", + "created_at", + "updated_at" + ], + "properties": { + "avatar_url": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deactivated_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "metadata": { + "type": "object" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + }, + "securitySchemes": { + "bearer": { + "type": "http", + "scheme": "bearer" + } + } + }, + "tags": [ + { + "name": "Server", + "description": "Server health and info", + "x-displayName": "Server" + }, + { + "name": "Tunnels", + "description": "Tunnel management", + "x-displayName": "Tunnels" + }, + { + "name": "API Keys", + "description": "API key management", + "x-displayName": "API Keys" + }, + { + "name": "Profile", + "description": "Current user profile and sessions", + "x-displayName": "Profile" + }, + { + "name": "Users", + "description": "User management (admin only)", + "x-displayName": "Users" + }, + { + "name": "Teams", + "description": "Team management", + "x-displayName": "Teams" + } + ] } \ No newline at end of file diff --git a/docs/package.json b/docs/package.json index 014ea5b..9bf0438 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,56 +1,41 @@ { - "name": "docs", - "version": "0.0.0", + "name": "funnel-docs", + "version": "0.0.1", "private": true, "scripts": { + "dev": "next dev", + "generate:api": "bun run scripts/generate-api-docs.mts", + "prebuild": "bun run generate:api && fumadocs-mdx", "build": "next build", - "dev": "NODE_OPTIONS='--inspect' next dev --turbo", "start": "next start", - "postinstall": "fumadocs-mdx", - "types": "tsc --noEmit", - "lint": "next lint" + "typecheck": "tsc --noEmit" }, - "browserslist": [ - "chrome 64", - "edge 79", - "firefox 67", - "opera 51", - "safari 12" - ], "dependencies": { - "@catppuccin/palette": "1.7.1", - "@phosphor-icons/react": "2.1.10", - "ajv": "^8.17.1", - "fumadocs-core": "15.6.3", - "fumadocs-mdx": "11.6.10", + "@tailwindcss/postcss": "4.2.4", + "@takumi-rs/core": "1.1.2", + "@takumi-rs/core-linux-x64-gnu": "1.1.2", + "@takumi-rs/image-response": "1.1.2", + "@tsparticles/path-perlin-noise": "^3.9.1", + "@tsparticles/react": "^3.0.0", + "@tsparticles/slim": "^3.9.1", + "fumadocs-core": "16.8.5", + "fumadocs-mdx": "14.3.2", "fumadocs-openapi": "9.1.10", - "fumadocs-twoslash": "3.1.4", - "fumadocs-ui": "15.6.3", - "gsap": "3.13.0", - "katex": "0.16.22", - "mermaid": "11.8.1", - "next": "15.3.7", - "ogl": "1.0.11", - - "react": "19.1.3", - "react-dom": "19.1.3", - "rehype-katex": "7.0.1", - "remark-math": "6.0.0", - "shiki": "3.9.2", - "twoslash": "0.3.2" + "fumadocs-ui": "16.8.5", + "motion": "^12.38.0", + "next": "16.2.4", + "postcss": "8.5.12", + "react": "19.2.5", + "react-dom": "19.2.5", + "tailwind-variants": "^3.2.2", + "tailwindcss": "4.2.4" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.11", - "@types/bun": "1.2.19", - "@types/mdx": "^2.0.13", - "@types/node": "24.0.7", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "eslint": "^8", - "eslint-config-next": "15.3.7", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.11", - "typescript": "^5.8.3" + "@types/mdx": "2.0.13", + "@types/node": "20.19.19", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3" }, - "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" + "packageManager": "bun@1.3.3" } diff --git a/docs/postcss.config.mjs b/docs/postcss.config.mjs index a34a3d5..a7f73a2 100644 --- a/docs/postcss.config.mjs +++ b/docs/postcss.config.mjs @@ -2,4 +2,4 @@ export default { plugins: { '@tailwindcss/postcss': {}, }, -}; +} diff --git a/docs/public/.well-known/security.txt b/docs/public/.well-known/security.txt deleted file mode 100644 index 7d868aa..0000000 --- a/docs/public/.well-known/security.txt +++ /dev/null @@ -1,6 +0,0 @@ -Contact: https://github.com/karol-broda/funnel/issues -Contact: mailto:me@karolbroda.com -Expires: 2025-12-31T23:59:59.000Z -Acknowledgments: https://github.com/karol-broda/funnel -Preferred-Languages: en -Canonical: https://funnel.karolbroda.com/.well-known/security.txt diff --git a/docs/public/dm-serif-display-latin-400-normal.woff2 b/docs/public/dm-serif-display-latin-400-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..75c0a6906deb39c5d2543bbc8d3aff6eaef71fc7 GIT binary patch literal 24744 zcmY)VLy#~`kTs09ZQHhO+qP}nwr$(CZQD=VcHehqzJGCZQ;4X@N}W@=D0g`=CICQy z|HPmI0P%mu^^*VQLjSk-KkNU0!3u=J4h+=72_^zF00^;zkb=yCL4<$`ydnvl@q-RF z0RUtJA^{HP03iSkR`hmEc&5f<#g#$Qi zCi(MM3rb>S$#;7LL{-VXAp<#bBJ5Di6=U7eu)49^X47){WP95z#dlDv;jzMgSAB`8+%na$X73QYJ<-EHC;{i#3 z!aKoPYKng~ym614yXPEev$6pE?Xlt#ap>%3u8_w9DRII3&! z>~p)i5*@j5`~9od+WQ)U2S9;)9$j%_58Jt z+3&P>v@DV=^(BqcvS2}qT&NI1gw7-sJWtd3#rGd@yZtf|A|#O_2QEhJvU5Y8Rm&Yc z?`r(}uzIRKr-SOszK0}{!}-e}NsQpWU1WF8dQpzxaHJt?F6X_Y#FTcZ@q@_ocK5$s zTM{@r3XuYq?m$Nf+l}X{_D{OuV4N0UQW^rT*`+ad#jmKSqkMIrJzOz=Y3FYYA-0sF z94OIv^-I3(t$th1vA6o71(2uR)QHz*=lzGotKTJ$5CjiM^*Eiot0-qXMm4t;o)#l1 zb&5ivG4veg-WrJ-GJY0UTK^1-fSQo|>A!3mK1{m4dwko9`=(|kPcK8w0Gj;pG$M0v z+<^1FArX*g1YKkC*ncPvgXV2MKQLs}=%s|}Mb|H{6g9aEB>^-#(+BYTBd^_cMj{GY zGNA0|Va_f9wvveW9GqnDD|%C;rp{a2$&pnU?!L`yK%ZJ;f5t$>bQnS?N1U~5S4ERd zo)rLS#B20io2ppw_h@#FwBpSqIgQc6iq&MqfIrq`JlH<(6gkx3X zGWT_ERt!tOJFSDHlc1NNi=>So@}cVI3bMcb-R{zrr%IanpK}B)Iq*ms(|-J%Rs#zJh6Y3% zf~eJC>sq}WZe9Yeit&eV-yHC~LY`)rLl6YwBz7FvdB@sw0Hg+Y5Qd9?jsaY6TJ|9o zJOD%z{kp64c<)?;thw9(m)_iN5C8%L3Bg!*k!Zp@JQCmpG%aTklrNelp^flkrulPVBzQSX`9T%NxKbVSTu`0b{SQ0OXXA~ z=MfF*od6^-~vYwn_vM{_Qfp-SH*Ojq6NN)OpYf;jzOfG1CWy% zlrsu|bIiW0nMQ^YqCd*Ru>b}t%04|+#K;KxDpD&>h}pIyvwlQ!AdF{Hl}pEAyVKwt zJ0QfH?1IezJD1^g1LhY~KWCp0!76-PrE)*N0@efTK-KC&t;MpDbOG|WE8|lC^W87^ zRRDl7+F=N^g6-Bw3|0iIiKd`x4K+sW>d7gemAPP3z@lJ(P>ZHT2FSHr71$Vozcjs0{hrb&KX8b=p;P`7lNtc#`NC8< z65T`%sA%Mt3I-o}2?eOM--ZzA+rOXkz&tRlrL0z;{Gg&PNh&7sukA4>>!)d_sw_^v zJw(U_ot|{4N2k&a{PP@y62Fytsd1z*B?#V{cbQ~`wi#nLOnv9{#coVoMa&L80N)X@3NRdWqNKGR|QRs8WhlfibxJV8T#)7lUw1yzsqEQl03JWtTEd z4iXznpBciRtJ_WkO`Rav8U1vFD*-S-y)0G~Y#l&}C}6QvNCY5)XrOZf3Ef#FdejO> zD?k?5pu7e>ga$G<^n=-H3#j1%lNAmycqv!yURvGYT7foS`V1GxEi{)(nhhuX0Y`(4qSmf-=cXJ-@z zG<|mKTkY@7a9&h1Y&C=elq7K76N1w%1rtUmKt6d!NDi0&uw=D*# zN=|hA2tCv#h1O+3eoxM2!CVK5(vA1S5+ublH)ft|iHJ(ayc*{WzgJf7*c0l3ZINwP zO+bvMJXfK3peh5Jb2K9KEZlNY1lYAaS7!XsJ-GI|NY65NbG331Ks7xK=>KqdiFm_mr7H_pcAL3keM5k%qqryLkztc2@`lVZ@O^*J_y*4$GpvLrvvQ3Lp3+ zsAQ2QV-;auC6*gk2{>A|>#)kZL~lKo`zv0+LPami6=^-A-^0XhK!zUDT`t|KxMs%T zE&SLS=|+$rV7KeYZtsWXI@7x+ZN5!u&;WsWM74BX4!L;)PVL05!WzS zhy%O7f7C(iW;)TWHLdRe(_P>ml<}gqY9$<&7*f|KuGc?SJEtxxpvwDyt1eovC)eDN zldPdCea{cF!)U8yF==9>OYeJ}+}7S|iscqD$hc&hw66`9Ep9M5 zPv>5CNVIwHK6&$zfJ$~_g(8geD5aRQ+nHiQ zlwzdm7OFb|R!#yuJVYxA&`IObGvnY#XX7XcAS>!5Ld2>FTGgQoV!05utHRa`1Y+K? z3|TFZ%c|fta=Qv%SAt8``Z2#{LRn^9gHX(%+Lma8T2G*w_Js)9wEv8vO=H@$=0@45 zF>KqDqH)z2w=8i}W!WGb*3EujH;D%Pv_H5Fu=SbpQ+1*^TX3Q-_h zv7bol{H!iSkKP?2p7qc19c602bzBIX7skYU_Lx$PSnbhcB87eeKWVZsaj{&f2NB7c zZp0r={BZDph)*R+BrzUZi2+YRf==Y4AN&<#gA^_pelxj{j#p}RS)~zw%%f~7(%zn8 z6)Qw04ud)tq%6T&L`n{#0+sFX34&R!TIZ)od3R z*DIyl@p?a>u`85S&?ZU$&-NPdD~Tr_JKD{4PO%&Z$ET1t+CB&$B8nC1%@OsF2&)#0 zG_1UD!v;zinbXq6_WVL6jkLZg_EH84B^**zKte=DNJ>mkO6Fo%)&G(7UC=hIBD#w8 z0>xxATCLWLh5t_}0|%~9CQ=KBGvqW&i_0_2OpQ(TE9ZDna&$I%Pqz=^05VF{s=@5% z>eZqbKydIm0vH()3W4d0z2!oMWHVarR*`X)a=IO_*UJTa!^3sshJ~e08l+Jji#;J) z`Tq-K4QwkwI_30Q)>jYLY0lBn>EV3p$-v*&s0Cxj0RdM?;AVG^5{HI_x{!c`h>Vbw zSf5r{k%p384ItEjr4_hm>7%58f<-{WVI22w8b)n6!~Y)g#M;Q5qw=q0pj7&9G{|;R zo3@T(G_7E=YF)&G$!0TlTWvv*2ojRyKU;LPIA}#dy4zkzVi*>5fdF<+wLr%#Y$!4K z3L{2Em(Oy+WHOyhBX;M-cHw<0Jz%gotqe|x$OuW9ap#}VMhG;-2&?2Iz-<-rtraXL zo7HNu(zdmCS>bZMSSwb~_XF-8^fb7^R$eLdo|~|tS^IxxKM$2_));0tmm5o)JN_3! z_Qwb9$kk5PD^b{qDfK#^O4L%RN~Kz}at(BVib_EU!Vm;MUvYX<2M;;cabI6?R!eFB zl6iU9ok7QXcLLGFIw6vT_pCd*5v8K1172jj^QIqB76dbn2slFnu++h%zNz_C7LX8; z5t0%UQ8|C0MhzOYZPoTgq?d9m7OSNi(dxE%+XkE^dQMlPGFOV}_AK0d0Dy%7#SGXE z*^c0wueetn`kWb%YV=s@?#T1HjE<3KK2MIf4c^tWO7$b&_lbFRzYG2!=hJ##l%gw? zY!%-!{aaDbg5Q;&3E)Y8eAsa|F&yuha?r2siVP+WLSB~tI71?DxhQr7=6|YK=4AZv z5I;S%o)~`O80N~3jSn9Xj;M9vhPp>mA4O}cfMBkF%M zF_IpLJV>Zhs?Ly?%__Sw_xk!Z2o}YFQiM${VOm?X$t53t*_iL;GpUDi&l&&Y%=fPz z6YXf5+i-XjcJAy7`Hs|7KKEHi-N^)Hy~VTHP-w#ZiHZF9=dZx@T0#aBU8K@vl%F~a zAOI7<0pOs_C=8o<5%~4Fj#i%MWyYEQ0NoZ_fH(v}2%<1qGj1+FZwc@sXWMxXjYg|U zMp#;WeqaV^jhPKjj?ONx`v)wCm@8yzD#dKg=x0@%d?*2O{i?AwKN&;@t6PBe1Lyjh ztVmKuhbTGSOW*q(??3mRt3K#`ZVW`^TDjbagfZHVT|c1!S+0Y6#Ayzg-`gD8tl8PB z%K71kDLmMRf?*KjlGbrVA9cp?M-PL{mVwG~^5Va$e-f2l+5648*wkNW_T;ulcEsme z+uxLgJ`;-HQ8y zZW%hC(+RmFb=`bB6lDwRA|fFsCnV)gq6;Vm5kLr$pjqd(lnGmi&BYonsbIY^(0YD= zFhwZX$c7qoZ%poPQme`-u#RkmG`NZgGCD@;KAMHho$C#qXP!*w`|eycOEAWy(piWa zB!@7KT6Ro9)njQmw@wXE=lG}vr;TC%VRAHCWHZBl#h7Vm)ZZ@VudbO&jhO2kd8uAu z7e)IcFS$rxMLjSl`>wM7Vjpt*Cc5WJng{=#tP|%~_uXcQk~Yxrewawg2SP+fNJ>mj zs87qKh@(>dM>kK?Yk|wWf(Rgj2qMxkB*tS`AX6r*m8GNZbdyZ5w79&$#810xmQBj8 zCjSFekax0u5YM2YM#P&jH*<2lU1uEg$(+2xob|d>t|H<_M@Y+Ok?9Am-rk429|vuZ z;xXvutJlKT|Ka5UnFPymPiu|Z zVx>x{mMjH$;AtN1%3AbR1#bP7$p6FF`*d*fust>=)-JP{i}y}Q zp5$h^f2Vq!`e;TEuF`3i78mE3RS$H`mXqK8uo(0xZK|rx%h;OY&*8GosC(!1>OLbG*;bC}VEGtLETWu_VRIcN2Wm5K$ zU&&QqVwNk(hjbz#@2$OqE{^d+tB5vN7PZ#e|66Z&bkWMGiUmqF-};N^Fd>sh<;j~z z51>LW$Tl5mMHERSi4r78ks?HtP?El>ha&U{_a4FHvHt^-La~{`x&I&Z(w;-lP#MFy z{$Cj`$RAqX>YHfKtknR?ax}9#!vc9vtE%B}wm788{P>hg3G1K2oYDNZV zqEw>dffSFSdAmfAEM%ufH{&8pSQ?=Vhdct@8@fSe@k14+X|}dwCdq9wCHM-w^Mi>Y%W;P+2QH+U*bU#P^MC~>!z|^;2i^^ z>3|N3p6eyaF^JN04mt8`@+IlIsn_k8vdm_Vkh)Lcd03ARVj9z>``qmDAJgQXOb{8; zC^ghv!;HOymhqC8>`;RQlKqs*kY%8%o|v`r{?BMmQY&I=g+CKSO4Nr`V&(=sGoz=kTFjE!=i5l&2A&uWUi{~JdQR}Sr zCQ`E_ibh6AxAcvRao=XQ$ShFKts?x(7Wox^NgU}2Oq)!NG`LSKTK$14X!9pD-&NN5zOxhhjzM`*#Wx6qtcstHAvRd_N);boT;Zm4H)>r zmhy)1;#PncRu^Lq(w#4Uoh~zbmp>am&QWUf&Cm?|prvV0mbO);n(SkekLT`P<~)Dr zy6F&hNJJ4JfT3Kx=#cC#035)!DF?x`5x`VPVOfcsnCeD#0Xc6W3Skffq5))vq~?Ya zEuCbY&hK?FaTWk@V9O`U4-^zt6o(e3SMB7Y6sSuY1u_b|GjfPKkO~0||2>sQ(c1}z z7OhsM7ny)*2URgJ@C=}}=bU^k_NduIG;Q0V1!zJrJ9CED?*sLSt|f1ou=nQco{~l( zv~eBQP21^QcR3+1O8rN?dCv8avP_!(n7jIQ13150CZH|p&_U|?9N5LWuk1`WSMA_0 zMg2A9t0$UY8~2k>(o#z~TAavy^;;Lntx)n^s22U;Vzq+!fw|?G_=Zb z=Fo&YH*w;dY`+v-vl+0sA?=RV`6?zoG2|Gl`J+o^A@rWaHwl(iKC92QK+V5ySxWqKQ z;2Un(l6O|QFL1FhpNZ>P)$eED`}$&9hpkciLa$9S8#-hXMppFaa2Li1TV> z@q4cwf12-}Fv~J4$T#Qb|Pay7dOf3qpz6BdB zo(-)&i%}0GDn7;{!3{zX^iXm{!;V`>$`VZp%g)irW)Oy@Zj(MN@z_H=HQTBx@uonz zL53eB8S*l6L=OS_WO{fWrXkuTa4_x{i`EAuwxt-@o(PGXkHp0DJg5?{hN#JeNd)ZH zbs12^rk5d`FuKrlVrT^xj<`!7ZY!WKMC&o7$AA6-yi=-lLJZsi3GiEU2`JDX)U84b z9xhb9C6nUmQuJCVvXp(~M;Rq)lV8d0T8}*B=wuLgKe6GQ>P z28OJS32h>St=Hw!W`M+Rr0^sD!BqeOi(K*<{GorZ`(=FY$1fA}0v#;M<6(O}{O;hH zQ3wTSFsO7u0PG&_qj#k){^Eyw&$LYwf$O?#+DX$kFEB3GvdtPX&$x_Hme0CEDk?2H z6oiKcizCKI#RZCzBx)c*Qlhq+U~z5JHco+F^(M@X%uppKM{C3N2l#+Op;oI^%k_%M zZn0{^n>Ib%3FSyk^zTQtiH5k01jY;I|N7C-i04Lj#jE`vzxeqCN(Gu7W zs)`t-jiWM-MAc3J&SJ9n=y%Z35`m;YG+a69@*+9^p{S%}>~19V^V&Ls;s+c1`B*W( z)UsXj_(U1!cC1CM`Op&fJdtm8A=;z7bIn~Wc&~!htzBJNF9i7CoGjP3MPD}ZUplwF zq~%=P+V?BiFR>qXZ=dDt>g17s&F`aNb{h}Ju?Ve#u&sr``mY+qJQL#y9tpNI`AAHy zSpt=R6nuVyjfjO34}r^jV0NU+2nUuhc1Em=VKlnwLJL|~S{5O3uFC}Y;a(`^sG$V# zO=#N)ByQ&wam{%TYIL(@P|tCQRF)dqNLcP(fz_x(D8(xbAck zV7KsaDwBF;b7H#U!ch|s)p!>q)@t3!+yL@5-P9shtE;~wDbiN4HJLmm2o{+HcA&)jcgL?d@RG2 zpt(bz=^zjIZHbU>*Czhaqtmw*MZ?HxHl`B71Bd}ax(Wy)6JUU|$N+(r0fuP~4DlSe zMKmKafmJ)Dt-tmbkbQneDK`6GE}nwChyKxOnU40Z7>%%4v&GtqjS(5Wx8^#%?-8`*`;C({NHinS0qD)n{F z=bC1M4aD83H?4W_T&x|rC5N6r|B$m%y$Z9y?;QZX*Y)`X0g-R4(IR%{X*y#EdBX)9 zdS=dqpKtu|aDtjKwj_|_EZY}HnOES?sqx(9lVy#?>t%Wb#t4sDuQW&T0O?^#HbN;V~#>8 z8dOL!MI@J@kir$Ii5(^0vKtsn4w_npB2q%2eZ|CxK2{O~9^))exNJSh>?W_D9%Pt= zs+^d*J@(v?2}h3O1hmM|WcKjNc#p)?Mrr##vYL%z{mcbfvCW^%$Y?Yq^*1Eupgv@c z(uDdx-iB%kc(({8F&p>4IL{8If75s(N$*(@78Cn+JJKYi(KqGd%~)Bgb8J(|UAzx|ikH z-Ofc%(kBAWc1|}h`jj4{$KQYY9K3?Ef>qLhIqw6k!0F>(?G07P3kZ%>0SLCb0ja?w ztUv&VYA_2ZRObe?0;}kge`5;R4Rj#|mIepBkOX~Z0}g>92@b;nu_aAd#gx*Wf@d8- zXi-ASk_MR>7DTX@0|-J32BaZHs%XG&PY5vW&rypF2ww_ z=q>1zVI&Bbk-)*29Tgam!NHjwA;2=IkYSJ^u}P#H7%&G7guR|ErV_B4?wcCg-aNW0GVPyd)MPZaITGS7msj_?&Q( zx#O>guVlyKcJHHxk6zw2yHoeWkV~}{fp%|&`zOqkK6+GWdG2CyE$Q1ln&`uwepcAH z&MxIh`8qyTbLAn=J$z5<-;^^yL>6;=_#NaX*q?pf&+D~7?b*7=++12C2cyD0GgYXE zgPPX(qI7}MsKvdbn(v=|ztZYw{1nf5U`6|@_Lhdfhl^5WAXbHo#rIdtyI~FYbJ@!T zoqY7+W{%(uaw% z68VNkgqT1Ri%(%(fDwDN9`t<35(~7Vxiuf~`cgh=vhI}8Oor)ReD}Ep5Oakz(RkF97(zYtMFeGv%ks#l7Y4bfwV#4&|G#d2m$GGJy?e{T= zqC$GiF2Henfv0VrCDkJuaumplaIj;oHJ<$`L8D&c7ehH2aK%R5wJ%Gl{6(g{yvw2vB zfcSuRuAEl=LHd)fxOC`Zf~IFV&x+GbbBi!2?(2sj+7#G+TrKI*Wi);1FOzlhRK7px zZo6JR*$ZQKG-Gw;I*27-W*<0Xs|eF#xky>wYrYa4bs*I}b=oc>1DC$f%qk|Do_sC2 z_S_lC?q7pxszt6OzKEw}BZh;jeg$K)*$*^2D@613R*1^OVwWwNXidqI& z$m>AHR}Q>F7Z1d)a#`pr6sH5A%H%#KAUmXq?*Bt&RKFmc0NJw|%dA7(vBP z9+_u9e{u(JnrgyZS7gDGWMy^5P3xWTn89XGJ|#cry9K{k&Q2O+`G%?T%d zigs%m>j%hPU(SIbXS}qKwGh}jw;~v|UA6oM6Ajhe{5q7mrVNdamzEHTwwf4KKnVW?(Xd0W{TiiZlr~=3A#ppIF=^OhsXUZ>f^=5~Xa@U$de_*W z)r~XM_Fn>nn5aO3b&^z+e?3?lvHO6f7FXqrx=3{-W+%PkKvpEexy>PD?Qc%clK(VE zir;8=UEP-4Ayf?E({TmWlC)iui`c8s`Ps1GU=B)M7Bu-}#O|WbC%xH=`Q^ppc16vX z=`HIf9OV=VpC{EmGzjiUnCcqd@7S)VUc{)3gJ`8(P~|T@s;m<6w2~<$D<>DfWV5@k9)y3>sxNU?alY1b;TM! zj-`8Hf^QpY4p27*faEGLyAt<4g3WUApvJCml!` z0ninbM(p9R^STX#*F3uu8jLCH{&`fs8DEO0C8e0Gh=$MGvg-`6=Hf_gn|t_qZNuIe zcx5a`?C23%#*(Zmx95L=zY$TtDY|sC=U{C6QxSa$hz+PV7bi?8PO7n94(M`eewlk(ur_G(qmF+uCJ2RkeS#U?F7C;NSwf2dhmi zjP+M$-yK&`9ckek00 zIcAx*%y3GM{7viRUJPffh3v@;_Va^u7TnvT34I*GY0N<;kTaf9BgIqESig%w^zcfB zS;hN3NWrqlkTm&>Hld;Y=xoM*iGz=v6y;?q_+SFGkomvl-45^_kNlFRJQcgsrxBvg zGz*f3QM9IRzq-lHo0gV8NS^aCv{NGih53}v5$lQt##LO!VPLyGst438pwd#V(|2#G zqQbn+N2Xw9t#E8!1?US(GIHAT{ z8Cr-{niFhOiKO@2!^&1fRxAk%hK&-})374k#Qv`;_sJ7Zu5lbn=qFkqu?^!EvNru; z1&elSPtfHm;YG>is={(A2f3~gZ!uNY?=ku6B}iPNKaY}8-o6!k>sCFuaPS-Jw-&9B zi|L#r9XQp*{9e*J608?3y}bzcm)o7$iCQxARV;CU^k0XI8@R7MkZ+ZT*#UCvsV}D| zb>lsXB2`LST7~TtmsJ@@yT6xsK*{&TK@ac*p`__$wFmaE;#>Mx^IQs1sBB_}>x}d- zhd&rT{O`d^u8^FL`pURKLj1tXe-scy?b)Qi+V#u_#Q;@YgaSlY=aoLNVY%TfItt`3 z4kiJj{etkt$03FA#}60!tap%4Tsk%h=_|`RGO((QmtJy74X&IevnMT&ATZNXsWagr zN_>q^d{^Hs-hmkBHhw)8a{yjF9#b;iLnn3Ibx+Q}G>od!DO9=arJPxU$~wt_%K=^% zaa<@2F>b?p(#<(L2d)4_{#!2S2x|U8sPo4qy&8t8(+8g-_OJDn(k3Sf*P+#^7QLJw(o^I5c%eRvq!m;Nd{^c%UzQB-mc)*2R-wz0v+>E(9GunWPsdFl zs9NmsWqAw@JX1ot;_~JM3+Zk}8S49~y)xg7p7VKNQ5MujDc%tnrLhT;`nIHKJ>~cR zo^0}5fBR^6>CXs-gWO)|IVZlM-T048RwGoHCpXHA>&a0co3X;kzB)`2L_K!)x3g{mL-V()lC9-@)- zzkUJ|HzwFHN*ef4PNjZ+Zu4bXpMH`&SG&nM0!v90cm;w;ezyL-$J+I1CChlcya+DQ zMIx$VQmhW||Etgi(k6wfEEZ_1c8w3Xst`NsQJ`p7g85*siwmCxC2Y*1br=k^MK0SsQRbmgRNK*urn zBd!29B$gMH4S85dnp7XGztPtVltck8Mb@e_|2mO9mLr~>|3apn#8Xp`9CJ74Kl4kE`3d(JCx%l2%MS@?!QGDbjmZWSBFdb) zfKd#{)LJ=+u`r_|0DIZ2pvTz{5fUqN0HEdy8FelFul!j|h`Ynjf!GIIq7GLta36#k z!hG$>IegXt{+^EY*qU~^*aI|uFc8e;<&tNAD@^MyYqoE4ionlqf~cU5^9I(t5j9=S zCc%G&Q?rR>ze1R3DXwuO$vLtb><_r+lZ55sfke&)10w200D#M;@sK|?`7$59ORQx@rko{FLN`%G z8it>JrA`i>I}ih@w;J$pAf7vtS2HdzPmG~W>zl(s6?aTTC|p(T9t~s&&X~9mSqJYN za6I*-cz>)f{Ex07B+#&7sU4=x^^A9aR{l&DV0|0y$_>Kj>l^-lXF9=+HT)$d8=6SW z7ZE`rafHDF#U7T!>*VwN1^J-#a$J+7B`6(c4+z^H^qHOK_Y7t;6=fv*t=XKDFJTf$ z;+*&SElye957;AtcMtlfBmw|GFn6Oef!EV&#eE??hkD>zq`WN(i0?zje?R@sT2>-a zAp`&3dk{E-p>A$LDP1NPEVbKeb^g^-dH%RTB-Ps1_oy;&>r8FEDXnyKdN~)mboq(I zXS>Yml4Bc%F>o|Qpk^|m>`_BZt#_ieW=ppxFazIsngPYFz;-#rly5a)e_ypOu6lb$ z%*As90zv&}63?}T+NyG%Q@Zx%)Q7z4yUT5tjhK2UjD-IWIap(TOnokBJMxQE*s#o} z-a^xnvGTcJ{udrxWlC8A!l-n^~`jDzC!M5chyh4}_de{l&a%+B&a;M>0bI z*SqvUY*zL8XG0?YhU5dluG~>!kz%(QQ}u14=}^_g3y2K$;KCkg)VyTjoStfKvVDTs z86-un#s6mQJLA^I-B$K|GsahEPYZb!K@~|IXq89*cR5Y0EWfcqYTeDsYsv+up%UJZ zIQObAx>8pOs2((EKS)0^Q*jbY3{SJ1rDqK1>=iIY*;u-L1E-s&s`(30Ye(!UdegCC%G~a&K%z#*{G=eGT?q6C&!Z1DuxK+VoNAMI@4-or9a9 zyVIFggF(OG#YwMF(+r_&6QK#SVdh>Pr(TW0AMpIG;3hfsbF%bf2aJ9oNSRGgJCQiS z5+3D4s{kL>dBCV4_l0#Or_!=!aceRHx)XJE%1!{>y$VNl8jhBK2UYilQ3J-UVP7TZ zv1DKT`bDK*0uNZipw?G=x1+Eb!Mu{kqY>a6`nI}>s{H19PlLF=F+kKn|X7DZ24x#VxHd#*{bM`K@j^A(1PS`Rxtfd6Wob^;wy>1$1j>xB&B=H*sDV8Qf zT}16H{d3A+9_8rtVGaW6-PWJ)9mdG*T8E0?t5%H&g2U7&1&%tr&z+F+1DtbG%1M-) z0!Uba5Ymd+(TA{SO*Jj^dHGYdy-L{@lXc1;LGIu1yDlQ=j2fq520{m2=t@rYq8J>N ziKg^lMdc@%l{t!r7WII!`~>*`-(h0HsOljcj{dRwMApym$*WOpSaK*Q7EED3S-My6 zNYGVG$sT4AWo06TaYa>Mm*u~q ztml=8Z3^8)q19{nk~VeH@`hh*oB_OfgTUaTT*ByEq4Z5)1N4m6$q-+{6L>Apq?ps& z+vg>@fTgu+*;>!Jn6R)D6-zX>`wy_MjjWncu*s=4sV-%96TIy5_N%8iRy7Rswu|-& zq;7I{DeVza_OA{&-pj^90bzO|>vP(i~{wa~kHnb;}+X3!~(Vp>xJ<}$7hD>#JCV`OuY$bww3I9-W zmf{G&AQx=s7#xnx(0f1{4Q_oPX>$;{=8&K|pQ8nOVn%zW4E{TH$AtM`2SxxJCuT{U z-2ZMR%9}1UlzrkKOy81E4CV-S@Mi@12&wqogniL9*okrJ31H^0u45R%P`mpD)}L< z`5x`8!^5Zx8JZJ7Zolc*m`~26Rio*m$5;CbfP?O|{PGE+yLYoZ5h=QAa zUA4NEG_>y6skD{7+{9Ru>ZmF@`XfiJS%d4SIx0$iE%AJ)qYbLk4DW%h43DsukG@31{MT(UJYAKS-XH zNJ?l|GojA8u)f?=F!c}npY;a8Z>Nm*@^&?ys*@Zo_2kGNh^_0PVuS3$47^>Ns4w>p z?1Qgr^+HpSwrIg%livX|`I>3x5N}P#>YoTZsAsvHN#e*}+J&#@W!xi*a8RsA0j(nl zqkED7F8~oggmxJKtYW6t?rI@*tKdP`Bb!)oH(@^<-)fAA2XWochREsbQf8{jq4smv z;i77}^u5m@_Q5wCsq2A3!<}T)K2gH85wn1vMH!}EiUF$Yka4!|c>WSD%2Yckq1ktR zjbTGJa}Y+%Ag&tzUvY>I#I?=5^T=Lqe*&2R#K6jK?P#hXZtoPQ0VT)eTm{Bn+Aq41 z$yZ(l!#$FYJ%^seqt3DVE><>(9}v3TsTztwG$Z<)hamhpRBz(bqMK)P>2eY(teQ$3 zzO@GL-O}gZHpn}GP{?PFWnf?I;dQ40hKtuWxfS?Xet!4ofBy_5Cca^RKSrVZa=&x{ znY8hq?3XEQMTlH_$|^i0FLI0dQ~QUG2eeDJ!Y`{L>Edsss+j^s2h?S+w>%=WuPP*m z#>f0xlU!~{rXhyVVG0M>ug?4P4b6fgP_Z2jFvjJJ=UMxhc5_0!L`e9H@~qu8y+l0> zkKnGUiG|5OhQklS72={nHV^$bGp`$rWRtSs4^6xrPxlr6&g85c4)XZ~Bcea;olctT z5RD$hL0K*3K=WgY6imypu~@^ebw-Lvc4TRWd3$ zU1s|q7|7|)O@rfu{xaTkj(1OTNm_#h?x{V1;>aB~w&JZXad%Sgoh?%QLj&Np)t>GU zVI;j>^!S6NPnQujH|Y4-47rG{l;JGNvs8^H(@aV}Ve;J(+KY>ukgeb}wyJ6L|B^Fi zrDs$)qMvLtM+L))7?WL5fR$yh@0wXu1Eh|pm9(Z6efBP!4~t`qW1Dn(`*hw(SaK;((ecJ zf!|HsHHC_Vt@4-`4rxBTMBTp=KtL z<=W6Yl4Q0}Br1Kbok%V_br$_0JB23>|8g9cy2c75v#=-PYojPA7${mgV@7tD;3S znF@WvKV8zL$kwUPzhouNVaIxUDA&%EtD@TCT44@K0z1H}t1Odt zADHPm6A0eQu$H2&|6SbpO4nQhRcaG|V@ngF6rI3dFB?wc9y&TYCuorKBq$)ZXo3qQCm(Lnwxb*P*A(kr85@Er+}v)ok+GU@ zzGPZ~M#Y*mFxjz&jSD3EpF|QH2tUKXJXi2=Z(%aI2h0}62@^Ni99NwzTLLx6Rigr} zjIR!yFt=W>c5qp*dQQAf}6jw;SU&VA-Q4&ImMYJ(v3F=s5YOg?e^xo~WKH z#Szi(Cyird758qkHNtjWbrVe1uDR45Ma#b#L^_|?`|;7c9l!HqLaH>M5P4=%%ftsS z@lyAV)(j6OuU`~}ks+GM(i`1m?<*k-#uo3&jXcFY5A(3YiyeN(%$Dxt&Bf3BOs(*?17LXP z8T`K^hVq(pp)M0qFAm%`!mG&WHoTmFBy2$#8>v4exNT5o+yqt$J>w^DCRQn9hmnyz zf3oWyVa#+=Y$@qu2(d_Yn!Ugei<*m98Fdj#x}zyKX}7g;-z&a zqu+6pR>K$%F(HpM8s5rY*~X9Gpvl7DB7c22dvsW&Nb;ziyN?$)-u`n(KcDvIhFydu zMvlr1osQ#n(puW~fsu2t!GHh83eSoi4Jn>qT3R5%s9>V?LdQD)gu)-sUFW#mm%#C=tdF1nT=Z{f9K1}j~MPs8>s?XHx&<8QJlF(h1Jy*YC$F)6Ddvgud+1saPP ze*r%Mnry8q!gPnpZy*ysP?VJ!o|?sdn7-AboC-;fI3`)>#X08GKWAyQu2O;4S837E z&?K_FkLBA>^Y#n>$GC~j9Mh8Ar5rLVMi3BYEDmCeS@F5}L`cg_Nz|JVVpgYwkB zTR-%K{dTnj)Ph-2kGsRC;|D525CgaY96TZ{8w+|7wx>7sHy;unx)q-$#cQB{AgaCn z(NX6;oEVP!g0_-M{g@V2Dy&4!(7I1vldzJwlA)p_>zg z2^229?=buqXJf;A>ii%7$-)!Aoct8-*14q2%s-z7v7|FqPhn5g{!~&WbnGVd3%lt2 z^n=mdgV7)D`I3~jmqfwV%_Lq8DGi?$QksDJ1W$gJfXtpjycb@LoDt%p#W;x2+vX?9 z9I@EQW%t?%XYP~kPbKT(23Iw#_xsk?1=F^6&Dfq*)3n0tSXyA@)f?_k{IgBW z6`Ra9rLflQ%Tox&*ju=$2yGDOd=ulWI^E__9MJsh>W>VQpe50JTt%9Nh zomu6e4Ge(c+oFoo^{qcFeRENH)@WtF&LE;Tl`}0OUMq|7Ee8)T@hc~_uqLPXY3uhY z4C_};XPMlA=`*|F*|61>N3%n7V6!spT>+D%BG*ty)BFp3*nN;qtfV({ z%UB+}Ch==UhLJ8vwE0EioTfj@G#t8BmPK|_W)kUQTVhuy+R`s64ytCj-Ky!u!TuzR zr9Vl+={RFOi5c%3bmc9rsK^@{bo$0FXWmDZ=2H3ls-{O%AXXL_jLC&XNorQndU!D? zIyV(7&L}D@%qSF7bdL%FqIk+r9d}gf0*N}9mafh(OoG}~o=#7bRC%3NxhFy5(P$+( z9+6x|HPRfdM(V-e#N2#PegZWYb+8>-%Bjb*qiAOBNe|M*48$d)#w!4(0*QNv7IBdW z9Swk2OM-tx$X)>&ok#3P+Q6&^!ZAYjHn0X9%qHv!{u5XB4{X$}77qddDI)%XC5#OL zKh6(^#t67S2q|yD(3Ul)D- z0({n;V}xU-jtt5;ojy*XZl%+=hR2W5>0|NHx2>EjbAPt(=0;51X$%zg{}R#P#cNWo zu=Gn}-eWOG;xI!uN7t6(=h4p%9!2G4&dsYM@g*jmLQbZHcWRW1iLc`?Cna5ur`(|* zJ9jkOZIkp0E1v{=@c*8UT=C6~>GDiIQS#fvcM#e0wQ^N0PKM(W{ZT%wd;2;j=c$3f zeIYqbp!^k2xGU-XiM*Lc+f0rhxO5F%Bo8Ns({D?W@w7f8zT_GmU5$zPdtr}iBqsa( zh^lAd-!YhKH2s>(6t9n2c5uzdTTuJY>NU%9;?4+eITd3lGg~aSus@6`oEN#r1jiE- z#|468N#bJ*=*d*7hEB_*Qf<;tew-?GiikPyqo_Cvcl5?$iA#hp`}f07EX6vSh~UED z1ks2N20MxUVmp?x75mE*B7xC29k38PrY1r{N%(g-tc>kgVkrPnfx|ahVG7s` zb&|zq>G8XKY{xRTp;-{?u|OpV=6B;1vQ!w!2o@IWi7k#TmWw@3PP<9SPeQE6LRP!8)GvgDlJzhS z10xvJgdj`u55I8>L2MYvFs}XotvR$rCS)C13}Sr_bfJKaP_V2*ha>gV37FoFKn~d{ z93UHBT`-Ow184#{U}>Ez{5{fhmsdT!vf4zcSj~sE&j0Te-3tF+1U63+Xdgfp#noD3 zKY?3`OwSfKA6CR?(!t6OH#q4K?j+1>ca)P~(h-&QJ_~FdCiY%o-0Y;hwK{rcp0$wJ zce{y8!wl`|oZ16Ag<#(p$UL?_h|^xa6S?cI{fM$3z#UHl}S8t{rc zHU;!;f??-A@c9C={sE8!yV=@9w#Y59riUYsSg8T%`jw$pGG1^xgWM8p8o4wrL8$*S zH<8kBhUfr9UqSQNU>j;V@B6RnqzfNvEHN*)#F`#3F7{wfZYkGfn)wdgI{w-#jN`9l z9~ZIZuHY%J8eLG2`}yc-+4>(FXSGLlfF6vk zsb5Q#Lb8}ih6?G;#kHtI7V40NI%Mg1rK~$vWK1L`wIbGf$_Q+6OKpE!e`*P?#vuIG z+kEZb^^FTD z%A~E`JDag}mk^v3vj1Z4*!`2s67l;l7iP%&x(Bx`L+Wm?@7u)H5?+2C=&1sEeJK?U zLM`&^KV)$+G#+)X5LOr693w^bk*e}CqHM}Fi28XRwYpeqklY0)wM~NG($ZE}`Vh{Z zdMtB_9#uF21d2jBD5?8U<;+0kXGVawV!;9wIxuECF?9yQ(2X>TO!j^m-hE>ZI1rk2 z0FX&gLe{v9uooW7QwnH)>9kA}h;zuuLf4(~ZeoT6!9zjro{`E?CwspvKL3K8H&vO(6jG9n$lHlYk)mfnZn+I>u+&w20XKE~IQq&Q z)^>{u=G}nLq9yO4gbKCa0sW)WG7E{*eG<90mK6!-bYR{}zk)yL54T4qSt>}__}%^~ zBAaZJZB!Z8hi8rj0sczp6V+V5TCrX>10!KX2Y1;zM7Gjdf@-a(X_3>|4JK%;wgVg1 zeOcsb;6cb@AkG4!DCI$pwt(|44(M}1)4d8{a+{~~l|Z3*cMskdqb@@rm@Uw=n&l<= zaH)*F^T;J6u1ZE9mfU$HE{t7D&k_1-9!gt3zgk(=^3UFFjO5X6SYh<48;+~i7U?7k zyKKuO8MYK9{8iP<$A*Kkd(I%OMU6S)s&0zk6Emof~WFK3(&^9 z7$BA`v=SN$<23AAKBR8Qi#&2iE(V#W5!?txb4lzCt|DY5#S1`sGVq8pjL3n~pIUFb!MF+raP!cQ(h#1=^hcG@B1q06z6Sj{e-Qg+x-*Icdd>JVI9rwKJw z$*hw0t^#B95h-H<@FpI|Ozwow16Uz_l{-RbUwpT%m(8-9dR)NsHsaDtqi$GiKg(i~7u)-0PbL0A6D!7* z9s*Ob?g*N+zKu(vly6D9T_f@Q>H6vR=`;>~TjyyU(a?7-@;_KNWL(j8srfHj_B8BB z3Sz-7iIOTIOCnflq#3R8A*+Stq}-r^xtMk zpqU;-Vq=qyVB_UW2Co2P)y;nBz+i?p730)b!wj)BFAG&-H^!1$C>7KSC<{1_nL~BE z6+w_B0$W={N)@a^1EsHjl<8%+%v(-K+`dd#%d0XsG#hbI$v&(i!BLC+*wE!|EmC22 zm~}T|YL+5`KMn+g_jht2y)=nas{$GtpN_>BB~3xD6ixs)ahz`Kp*Y33KMh2R8F{r{ z>W@Y_;p~tmG*E-0f*ZOo-1|{In^Ceh?Z?COmA zA@^lkUAyO!N2=y`z|B1LOw~K{z^<(sa_UH-?j>EbASlBy9Ac{WB}VbpfU18O?L^J) z*doNs$$Z?=X1%-_f$iC~7XUpLvsob(kMWWq$!jm(|aRR88Hu`CQ@oEO50-}H%T z?W0LsCu2I&z#>8eX%pq{6H82l?zsN2dOTGjU?;Ho{ZJe&0?*J~#3^meOrymw@`zKm zOSR{plrZjXn8e^Q4!ix1HChs>1Fq5ZVk4GqG?&*hCrgny-KBlj({RB!qFIXQ-bfLk zakAsNj%7xb9R2&ugvd!#O-5Q(E@nA6-M zir*r;sxKr5!Y)AuNYIbk6so3mccUH1>ii65o2`ftYJ=rheZ`MWjuotro)Dw-JA!1? ze^D&i`Lqqei%idOMc24~a&u^+KtrH#QiUR0inPgu(3oy!tbm58yvE3;FKB2Q zf=0jSdF^|yY6Qfa1&ovAsl{8N2bhZyB@)%KxL}QvrA&8Gqi_HjY^UBM0w({_H^;%4 zSOi)>0C<8oPj5~RLzkt#YZ_<=vYOyb8+xkGOf+*`M;(=>7BhP5IFcNEbsIxv zVD#FJjFhC2HLh)Nr9yCiNkdNylA8S@s5+^D@zFfF_tcX`F_vRHbPp^3T|$nB#+dXt zu+9V-YFWF=N{nkDMyPYVgyJM41YhC)(r@jn5{Y|{{pI0u8p>U@%aXuzY;Cq$I-%9m zH|L2A!`V^w+0}LcJp?JV3CwO%GZjg%Nm{q@(FCZwq)R&}DEhTHIj;G7IbmfZKu;i< zwASgAB+MkJ&sCywy1K}?s#f(1I=a-i%`myfV;d2VOih6Tbe%va8|Y1=TqX^0HQ=I$ zC{Wb2i5f^rhgLOL&*RDx#F-YURFIRarj#-Hd-od)KMDY%Sb)zx406i3B$IuHKv$(W zR?^UOBN$%fRO;gGZpr!@vkRlYPVufIsBZnogwVrK-_$t+oDTg7H(Omd`%4Am5^ z0-2!UbVS3z4j##ZfCP!N)E2hh)BSRz%LaAosm?UOXiw?t``9gIwiw&kXB5}@#F_IO zykEqDO_UJrD%RGXyv40MmLZKCvK~Sb%~`KwZT3%Zaov_vMw_Q9k33tCRz&f{rV-E} zYN0sUx{jz}g?fh66lJ-ZDczJdPiFQoj~eshoojY_AvF;Cvrk_lN?AuH7xFvTFln_e zSO9JofTL#FOG>qc{ydqnaagKwo!2Mqxk$&*-smUH_P_WfsN23C0%cwE$<3<7N(w?> zT%8aZ7NU#98IFc=OU$7&HK{ibMr=kH1wpadIJ5-lMUQxSDC(-l|HJaaz~_jUpsXbP z>9#;5umxdWh+Qu0OihFj>2&3xFy9PEnR`Pys2z6?|BkH2DJj1hrZTImIEckHv zMjQ)7dhN!QwFe7C&A)-B;SjPkQ12APNm%EnJ z$8W4w7qvB5;YvtbYi4~1cb~@pLEl!45~Cy~OQa(UMt2IC zV6$Vyto2ntHVyaHICZ=UE+jcZIC0>8VtN3;_!Y>xg3s^O5P;kEreaLn`?hLEY2OLH z;!VXdCV+xJ02)>#Z6($wNKyyj)xp}9!-?AS#4mX}RB!b(iLf%$c&G*y4e5r3WgPD- zO*?3P7eBWMR%?rMrpN-%B8KJ@pwRYNdyym^hZ9~nfUW*6!5g!K{6GF*71RSr<$qST zSE`T&MT|ix#JB*MA&mqap>WCv=Jp{E!kb7@kS4^eD4=UAM7#&!=L?9pj}&5SeA8AZ zi9G;xwa(|wwZ!NIugzb^IaRqlNGM1#^qX$Y^`;ExkyWl*lX(Hwf;aWY1zct*=4~Iu z=1gg_=9Arzk<2N0Y8wlK*aCNA``l${9s;9O%%uU^-#-$yHcVomYU3{HASd568S}XN zfrUw1Dnwv=)GyKk#pGA6CSBrdES#r8k1|!nTA0baR@1a)jv6ho-&PLUVL%wEX)#gS zcDWpvg+K4|cRP`$@Gz9fByPcmMGpeA!}d|o$b*zA*S1@VEc8u7@#{;j%=5j7e83 zPxc-H6M18uAhwVjFl?g2lA%jhP*6>iQ&@DWnkDY%rA`zqX3_NfH7S6IeE`s`0pDlc z{utw+?I{1dO}};gcTZ3bXST#aF(%$dVW^aS3)$cLe^s{0xf z&&*;@+l7UG4wtJhW-jI*6C&aPJm8wdBWEBbNJt~>9eI9QijbKK~gRpug>#UfB531OFj)fLGlayP?{e={pUQB-qO z)hNbA&l}J$)Xd({^!neBmW2RZ@_wG~He=s5HZkkEAU4sL1xHIKFvt{bT1BlN6kWq%L2h1Zk1mpfrzEohh@T0TIXHAJz7wzC?SpO#2Ji*+i14D_#_QIfq zDn+%nx)KF2(`b*qWX%wNLkYzCtFwlTXlvKB^O!~gJS^PHg=;(=ipu&C-;M&w`B?A;wip@a#ODC)CsMTdaB?k#XX0Yh( z=6I$MNL7Fjsq$%En=|Kf z2{Nh4C|mZ2HC*V)&KXtfR>aAuRKe;J!I<|owjmdzmE*&WUwnRicX_^AW~mx*L-%$` z0M#b(`H9|6)jyu8xPziNe;g{ z6HQiHPOiK7Y|H_upqQRwbFXZFp+I4`<*9xce%boJH8SxzZO-OR2CtdtzrOr|n)E>5#LB4>_lU zOs*o!y}e)5-@X+TmgmLbU$>9e8-|=Lf36Tbg>JKB#7^oyRI=DUTP-WIj2K9~dU1D? zU1!&4rzeN8KRK^={1TtJnia><92zB# zN6uZEf|y=#dI!U7Xuu?^6f-S6H(OQg>Dfc#d<-?8PMCvYK#0q|l*Id6%pb{|!osF< zs;&wGHEPa1=QKE*Y88U&7pv5OGcHG+Q;h|8KX#x$x-^w@JUjhg(&?G?3`b@zFI~q zMDS>Qyu#rL<)B|^X1E?Pg_iaq=VSCQ(_}yFFi@3eSwKLjf5r3le?sgtszBycK;Lan z4QR%9nt@Q1Vn8`w%Ih(~P5DOM=k$&7+-z>u39lp=_hfOxng+^B7F{NjW-H_}m+fJg z6=f=;?TRxy;nR2p?O9d3uXbl^2Hc`3nBwl?WJvU z1T`7E1bQfE_?F7K!@s2nAwQgl)js=UMS$`l<%X|SmWzt>;76(XR9=J0j;{>be)MsZ zON4d--J^m_+@rz>$Wc{B)z85m4hdWa1Sql)mWD!4RQS_Uw8%o# z3o`v3StR|`(3~cT`-N#0@B;|jeO^uQ+6TVbFp5MU*9|7-BDy-MopCH$#kx#3L>+Nd z%0;OGeEq;gvpwD3%kAXc?gI|6Ya&c@lQ6!r4FX0!s-I6ixt2>~6aY=30*?rBG1{1L za$LlVww*+QQc~d*Rirjn7+Ra$qLo(CeuoO}>ZF`ZORR$+clE7SlF33w4 zwUBjH>-U%GmB^z?jn0#8J0E(f<1L!eDT&EQTIkSC&T5yPd~8u$m(3>b1iB#N~FDQzj+2R1bnE(9b&?UBKS;lIs?DQh|K^kqOamE^Nn8xm82rl>#LJTS7 zPz-wDmWgs*b>HngLk%tTFv1LLoZ*bu5H!HDb1pw@|F9cxdD7_7vW6X5SNnRneW=sH z5sV%;Eal}nDRVhC5Lc}%Pl{K(Z;I*cY<3>1(OAYF_+D2j%9Sw*#ms%ms_10J2fDb> z@ZUuoA*^Qu{-EXUSa*hOMfb~g{c$k99Gj#PIuO~+>P``(1QXPtas9E!eG^4?5v!!( zaX|K8IQ`KhqKSt$qvvv6?Z+d?1|NajV4=yuPf|Wc&Xi$G$?)S=QVhPDg9k>HK(z(T z^c%ha2YJ6Mn!mg50~`G - - 🕳️ - \ No newline at end of file diff --git a/docs/public/humans.txt b/docs/public/humans.txt deleted file mode 100644 index dc497ad..0000000 --- a/docs/public/humans.txt +++ /dev/null @@ -1,36 +0,0 @@ -/* TEAM */ -Creator: Karol Broda -Contact: https://github.com/karol-broda -Location: 🇩🇪 - -/* SITE */ -Standards: HTML5, CSS3, JavaScript -Components: Next.js, React, TypeScript, Go -Software: Git - -/* DESCRIPTION */ -A tunneling solution built with Go. -Expose local services to the internet through websocket connections. -Perfect for development, testing, and demonstration purposes. - - -o/- - +ooo/ - +oooo+ - +ooooo+ - +oooooo+ - +ooooooo+ - +oooooooo+ - +ooooooooo+ - +oooooooooo+ - +ooooooooooo+ - +oooooooooooo+ - +ooooooooooooo+ - +oooooooooooooo+ - +ooooooooooooooo+ - +oooooooooooooooo+ - +ooooooooooooooooo+ - +oooooooooooooooooo+ - oooooooooooooooooooo - oooooooooooooooooooo - oooooooooooooooooooo - oooooooooooooooooooo diff --git a/docs/public/icon.svg b/docs/public/icon.svg new file mode 100644 index 0000000..7116253 --- /dev/null +++ b/docs/public/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/public/robots.txt b/docs/public/robots.txt deleted file mode 100644 index eb89fb4..0000000 --- a/docs/public/robots.txt +++ /dev/null @@ -1,15 +0,0 @@ -User-agent: * -Allow: / - -# Sitemap -Sitemap: https://funnel.karolbroda.com/sitemap.xml - -# Crawl delay -Crawl-delay: 1 - -# Specific rules for common bots -User-agent: Googlebot -Allow: / - -User-agent: Bingbot -Allow: / \ No newline at end of file diff --git a/docs/public/site.webmanifest b/docs/public/site.webmanifest deleted file mode 100644 index 15af569..0000000 --- a/docs/public/site.webmanifest +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "funnel docs", - "short_name": "funnel", - "description": "A tunneling solution built with Go. Expose local services to the internet through websocket connections.", - "start_url": "/", - "display": "standalone", - "background_color": "#000000", - "theme_color": "#000000", - "icons": [ - { - "src": "/favicon.svg", - "sizes": "any", - "type": "image/svg+xml" - } - ] -} \ No newline at end of file diff --git a/docs/scripts/generate-api-docs.mts b/docs/scripts/generate-api-docs.mts new file mode 100644 index 0000000..1f72500 --- /dev/null +++ b/docs/scripts/generate-api-docs.mts @@ -0,0 +1,9 @@ +import { generateFiles } from 'fumadocs-openapi' + +void generateFiles({ + input: ['./openapi.json'], + output: './content/docs/reference/server-api', + per: 'tag', + groupBy: 'tag', + addGeneratedComment: true, +}) diff --git a/docs/scripts/generate-openapi-docs.mjs b/docs/scripts/generate-openapi-docs.mjs deleted file mode 100644 index 354a43e..0000000 --- a/docs/scripts/generate-openapi-docs.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import { generateFiles } from 'fumadocs-openapi'; - -void generateFiles({ - // OpenAPI schema file - use the one we downloaded from server - input: ['./openapi.json'], - // Output directory for generated MDX files - output: './content/docs/reference/server-api', - // Include endpoint descriptions in MDX - includeDescription: true, - // Generate per operation (each endpoint gets its own file) - per: 'operation', - // Group operations by tag for better organization - groupBy: 'tag', -}); - -console.log('✅ OpenAPI documentation generated successfully!'); -console.log('📁 Files created in: ./content/docs/reference/server-api/'); -console.log('🔗 Make sure your funnel server is running on http://localhost:8080'); diff --git a/docs/source.config.ts b/docs/source.config.ts index 5ff6f1b..4132d31 100644 --- a/docs/source.config.ts +++ b/docs/source.config.ts @@ -1,26 +1,16 @@ -import { defineConfig, defineDocs } from 'fumadocs-mdx/config'; -import { rehypeCodeDefaultOptions } from 'fumadocs-core/mdx-plugins'; -import { transformerTwoslash } from 'fumadocs-twoslash'; -import rehypeKatex from 'rehype-katex'; -import remarkMath from 'remark-math'; +import { defineDocs, defineConfig } from 'fumadocs-mdx/config' export const docs = defineDocs({ dir: 'content/docs', -}); +}) export default defineConfig({ mdxOptions: { - remarkPlugins: [remarkMath], - rehypePlugins: (v) => [rehypeKatex, ...v], rehypeCodeOptions: { themes: { - light: 'github-light', - dark: 'github-dark', + light: 'github-light-default', + dark: 'github-dark-default', }, - transformers: [ - ...(rehypeCodeDefaultOptions.transformers ?? []), - transformerTwoslash(), - ], }, }, -}); +}) diff --git a/docs/src/app/(home)/_components/feature-grid.tsx b/docs/src/app/(home)/_components/feature-grid.tsx new file mode 100644 index 0000000..a5ab5a4 --- /dev/null +++ b/docs/src/app/(home)/_components/feature-grid.tsx @@ -0,0 +1,30 @@ +'use client' + +import { motion } from 'motion/react' +import { fadeUp } from '@/lib/animation' + +const features = [ + ['QUIC streams', 'Each HTTP request gets its own QUIC stream. No head-of-line blocking, no framing overhead.'], + ['Automatic TLS', "Wildcard certificates from Let's Encrypt via DNS-01. Cloudflare, Route53, or your own provider."], + ['Teams & OAuth', 'GitHub and generic OIDC login. API keys with scoped permissions. Team-scoped tunnels.'], + ['Self-hosted', 'Single binary, embedded database option. Your infrastructure, your data.'], + ['NixOS native', 'NixOS module with systemd hardening. Home Manager with sops-nix. OCI containers.'], + ['Observable', 'Prometheus metrics for tunnels, bandwidth, and latency. Per-tunnel request stats.'], +] as const + +export function FeatureGrid() { + return ( +
+ {features.map(([title, detail], i) => ( + +

{title}

+

{detail}

+
+ ))} +
+ ) +} diff --git a/docs/src/app/(home)/_components/flow-field.tsx b/docs/src/app/(home)/_components/flow-field.tsx new file mode 100644 index 0000000..35a8c6d --- /dev/null +++ b/docs/src/app/(home)/_components/flow-field.tsx @@ -0,0 +1,299 @@ +'use client' + +import { useEffect, useRef } from 'react' + +function simplex2d(x: number, y: number): number { + const F2 = 0.5 * (Math.sqrt(3) - 1) + const G2 = (3 - Math.sqrt(3)) / 6 + const s = (x + y) * F2 + const i = Math.floor(x + s) + const j = Math.floor(y + s) + const t = (i + j) * G2 + const x0 = x - (i - t) + const y0 = y - (j - t) + const i1 = x0 > y0 ? 1 : 0 + const j1 = x0 > y0 ? 0 : 1 + const x1 = x0 - i1 + G2 + const y1 = y0 - j1 + G2 + const x2 = x0 - 1 + 2 * G2 + const y2 = y0 - 1 + 2 * G2 + const hash = (n: number) => { const h = Math.sin(n) * 43758.5453; return h - Math.floor(h) } + const grad = (h: number, gx: number, gy: number) => { const a = h * Math.PI * 2; return Math.cos(a) * gx + Math.sin(a) * gy } + let n0 = 0, n1 = 0, n2 = 0 + let t0 = 0.5 - x0 * x0 - y0 * y0 + if (t0 >= 0) { t0 *= t0; n0 = t0 * t0 * grad(hash(i * 127.1 + j * 311.7), x0, y0) } + let t1 = 0.5 - x1 * x1 - y1 * y1 + if (t1 >= 0) { t1 *= t1; n1 = t1 * t1 * grad(hash((i + i1) * 127.1 + (j + j1) * 311.7), x1, y1) } + let t2 = 0.5 - x2 * x2 - y2 * y2 + if (t2 >= 0) { t2 *= t2; n2 = t2 * t2 * grad(hash((i + 1) * 127.1 + (j + 1) * 311.7), x2, y2) } + return 70 * (n0 + n1 + n2) +} + +const VERT = /* glsl */ ` + attribute vec2 aPos; + attribute float aAlpha; + attribute float aHue; + attribute float aSize; + uniform vec2 uRes; + varying float vAlpha; + varying float vHue; + void main() { + vec2 ndc = (aPos / uRes) * 2.0 - 1.0; + ndc.y = -ndc.y; + gl_Position = vec4(ndc, 0.0, 1.0); + gl_PointSize = aSize; + vAlpha = aAlpha; + vHue = aHue; + } +` + +const FRAG = /* glsl */ ` + precision highp float; + varying float vAlpha; + varying float vHue; + uniform float uDark; + void main() { + float d = length(gl_PointCoord - 0.5) * 2.0; + if (d > 1.0) discard; + float soft = 1.0 - d * d; + vec3 warmD = vec3(0.55, 0.45, 0.27); + vec3 goldD = vec3(0.83, 0.66, 0.27); + vec3 coolL = vec3(0.23, 0.31, 0.47); + vec3 blueL = vec3(0.33, 0.42, 0.56); + vec3 color = mix( + mix(coolL, blueL, vHue), + mix(warmD, goldD, vHue), + uDark + ); + float a = vAlpha * soft; + gl_FragColor = vec4(color * a, a); + } +` + +const TRAIL = 12 +const COUNT = 1200 +const MAX_VERTS = COUNT * TRAIL +const FLOATS_PER = 5 + +interface Particle { + trail: Float64Array + head: number + vx: number + vy: number + life: number + maxLife: number + hue: number +} + +export function FlowField() { + const canvasRef = useRef(null) + const rafRef = useRef(0) + const mouseRef = useRef({ x: -1, y: -1 }) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const gl = canvas.getContext('webgl', { alpha: true, antialias: true }) + if (!gl) return + + const compile = (type: number, src: string) => { + const s = gl.createShader(type)! + gl.shaderSource(s, src) + gl.compileShader(s) + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { + console.error('shader compile:', gl.getShaderInfoLog(s)) + } + return s + } + const prog = gl.createProgram()! + gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT)) + gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG)) + gl.linkProgram(prog) + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + console.error('program link:', gl.getProgramInfoLog(prog)) + } + gl.useProgram(prog) + + const aPos = gl.getAttribLocation(prog, 'aPos') + const aAlpha = gl.getAttribLocation(prog, 'aAlpha') + const aHue = gl.getAttribLocation(prog, 'aHue') + const aSize = gl.getAttribLocation(prog, 'aSize') + const uRes = gl.getUniformLocation(prog, 'uRes') + const uDark = gl.getUniformLocation(prog, 'uDark') + + const buf = gl.createBuffer()! + gl.bindBuffer(gl.ARRAY_BUFFER, buf) + gl.bufferData(gl.ARRAY_BUFFER, MAX_VERTS * FLOATS_PER * 4, gl.DYNAMIC_DRAW) + + const stride = FLOATS_PER * 4 + gl.enableVertexAttribArray(aPos) + gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, stride, 0) + gl.enableVertexAttribArray(aAlpha) + gl.vertexAttribPointer(aAlpha, 1, gl.FLOAT, false, stride, 8) + gl.enableVertexAttribArray(aHue) + gl.vertexAttribPointer(aHue, 1, gl.FLOAT, false, stride, 12) + gl.enableVertexAttribArray(aSize) + gl.vertexAttribPointer(aSize, 1, gl.FLOAT, false, stride, 16) + + gl.enable(gl.BLEND) + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) + + const vertexData = new Float32Array(MAX_VERTS * FLOATS_PER) + + const dpr = devicePixelRatio + let w = 0, h = 0 + + const resize = () => { + w = canvas.clientWidth + h = canvas.clientHeight + canvas.width = w * dpr + canvas.height = h * dpr + gl.viewport(0, 0, canvas.width, canvas.height) + gl.uniform2f(uRes, w, h) + } + resize() + addEventListener('resize', resize) + + const onMouse = (e: MouseEvent) => { + const r = canvas.getBoundingClientRect() + mouseRef.current = { x: e.clientX - r.left, y: e.clientY - r.top } + } + const onLeave = () => { mouseRef.current = { x: -1, y: -1 } } + canvas.addEventListener('mousemove', onMouse) + canvas.addEventListener('mouseleave', onLeave) + + const spawn = (): Particle => { + const x = Math.random() * w + const y = Math.random() * h + const trail = new Float64Array(TRAIL * 2) + for (let k = 0; k < TRAIL; k++) { trail[k * 2] = x; trail[k * 2 + 1] = y } + return { trail, head: 0, vx: 0, vy: 0, life: 0, maxLife: 250 + Math.random() * 350, hue: Math.random() } + } + + const particles: Particle[] = [] + for (let i = 0; i < COUNT; i++) { + const p = spawn() + p.life = Math.random() * p.maxLife + particles.push(p) + } + + let time = Math.random() * 1000 + let paused = false + let last = performance.now() + let acc = 0 + const dt = 1 / 60 + + const onVis = () => { + paused = document.hidden + if (!paused) { last = performance.now(); rafRef.current = requestAnimationFrame(loop) } + } + document.addEventListener('visibilitychange', onVis) + + let darkVal = document.documentElement.classList.contains('dark') ? 1.0 : 0.0 + let darkTarget = darkVal + const obs = new MutationObserver(() => { + darkTarget = document.documentElement.classList.contains('dark') ? 1.0 : 0.0 + }) + obs.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) + + const tick = () => { + time += 0.002 + const mx = mouseRef.current.x + const my = mouseRef.current.y + const hasM = mx >= 0 && my >= 0 + + for (const p of particles) { + const tx = p.trail[p.head * 2] + const ty = p.trail[p.head * 2 + 1] + const angle = simplex2d(tx * 0.0015 + time, ty * 0.0015 + time * 0.5) * Math.PI * 1.8 + let fx = Math.cos(angle) * 0.6 + let fy = Math.sin(angle) * 0.6 + + if (hasM) { + const dx = mx - tx, dy = my - ty + const d = Math.sqrt(dx * dx + dy * dy) + if (d < 200 && d > 1) { const f = (1 - d / 200) * 1.5; fx += (dx / d) * f; fy += (dy / d) * f } + } + + p.vx = p.vx * 0.95 + fx * 0.05 + p.vy = p.vy * 0.95 + fy * 0.05 + p.head = (p.head + 1) % TRAIL + p.trail[p.head * 2] = tx + p.vx + p.trail[p.head * 2 + 1] = ty + p.vy + p.life++ + + const nx = p.trail[p.head * 2], ny = p.trail[p.head * 2 + 1] + if (p.life >= p.maxLife || nx < -20 || nx > w + 20 || ny < -20 || ny > h + 20) { + const s = spawn(); p.trail.set(s.trail); p.head = 0; p.vx = 0; p.vy = 0 + p.life = 0; p.maxLife = s.maxLife; p.hue = s.hue + } + } + } + + const draw = () => { + darkVal += (darkTarget - darkVal) * 0.05 + + gl.clearColor(0, 0, 0, 0) + gl.clear(gl.COLOR_BUFFER_BIT) + gl.uniform1f(uDark, darkVal) + + let vi = 0 + for (const p of particles) { + const lr = p.life / p.maxLife + const fi = Math.min(lr * 4, 1) + const fo = lr > 0.75 ? (1 - lr) / 0.25 : 1 + const base = fi * fo + if (base <= 0.01) continue + + for (let i = 0; i < TRAIL; i++) { + const idx = (p.head - i + TRAIL) % TRAIL + const tf = 1 - i / TRAIL + const a = base * tf * tf * (darkVal > 0.5 ? 0.5 : 0.35) + if (a < 0.01) continue + + const off = vi * FLOATS_PER + vertexData[off] = p.trail[idx * 2] + vertexData[off + 1] = p.trail[idx * 2 + 1] + vertexData[off + 2] = a + vertexData[off + 3] = p.hue + vertexData[off + 4] = (1 + tf * 2) * dpr + vi++ + } + } + + if (vi > 0) { + gl.bufferSubData(gl.ARRAY_BUFFER, 0, vertexData.subarray(0, vi * FLOATS_PER)) + gl.drawArrays(gl.POINTS, 0, vi) + } + } + + const loop = () => { + if (paused) return + const now = performance.now() + acc += Math.min((now - last) / 1000, 0.05) + last = now + + let n = 0 + while (acc >= dt && n < 3) { tick(); acc -= dt; n++ } + acc = Math.min(acc, dt) + + draw() + rafRef.current = requestAnimationFrame(loop) + } + + for (let i = 0; i < 60; i++) tick() + draw() + rafRef.current = requestAnimationFrame(loop) + + return () => { + cancelAnimationFrame(rafRef.current) + removeEventListener('resize', resize) + canvas.removeEventListener('mousemove', onMouse) + canvas.removeEventListener('mouseleave', onLeave) + document.removeEventListener('visibilitychange', onVis) + obs.disconnect() + } + }, []) + + return +} diff --git a/docs/src/app/(home)/_components/flow.tsx b/docs/src/app/(home)/_components/flow.tsx new file mode 100644 index 0000000..c249419 --- /dev/null +++ b/docs/src/app/(home)/_components/flow.tsx @@ -0,0 +1,89 @@ +'use client' + +import { motion } from 'motion/react' +import { fadeUp } from '@/lib/animation' + +const steps = [ + { + icon: ( + + + + + ), + title: 'Your service', + detail: 'localhost:3000', + accent: false, + }, + { + icon: ( + + + + ), + title: 'QUIC tunnel', + detail: 'Per-request streams', + accent: true, + }, + { + icon: ( + + + + + ), + title: 'Public URL', + detail: '*.tunnel.example.com', + accent: false, + }, +] as const + +function StepNode({ + icon, + title, + detail, + accent, + index, +}: { + icon: React.ReactNode + title: string + detail: string + accent: boolean + index: number +}) { + return ( + +
+ {icon} +
+
+
{title}
+
{detail}
+
+
+ ) +} + +export function FlowDiagram() { + return ( +
+
+
+
+
+
+
+
+ {steps.map((step, i) => ( + + ))} +
+
+ ) +} diff --git a/docs/src/app/(home)/_components/github-icon.tsx b/docs/src/app/(home)/_components/github-icon.tsx new file mode 100644 index 0000000..fea6720 --- /dev/null +++ b/docs/src/app/(home)/_components/github-icon.tsx @@ -0,0 +1,7 @@ +export function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ) +} diff --git a/docs/src/app/(home)/_components/hero-button.ts b/docs/src/app/(home)/_components/hero-button.ts new file mode 100644 index 0000000..8d1e25a --- /dev/null +++ b/docs/src/app/(home)/_components/hero-button.ts @@ -0,0 +1,13 @@ +import { tv } from 'tailwind-variants' + +export const heroButton = tv({ + base: 'flex items-center justify-center gap-2 rounded-full px-6 py-2.5 text-sm font-medium ring-1 ring-inset backdrop-blur transition-all', + variants: { + variant: { + primary: + 'bg-fd-primary/90 text-fd-primary-foreground ring-fd-primary/20 hover:bg-fd-primary hover:ring-fd-primary/40', + secondary: + 'bg-fd-foreground/[0.06] text-fd-foreground/70 ring-fd-foreground/[0.08] hover:bg-fd-foreground/[0.12] hover:text-fd-foreground/90', + }, + }, +}) diff --git a/docs/src/app/(home)/_components/mesh-gradient.tsx b/docs/src/app/(home)/_components/mesh-gradient.tsx new file mode 100644 index 0000000..4849a0f --- /dev/null +++ b/docs/src/app/(home)/_components/mesh-gradient.tsx @@ -0,0 +1,35 @@ +'use client' + +export function MeshGradient() { + return ( +
+
+
+
+
+ ) +} diff --git a/docs/src/app/(home)/_components/terminal.tsx b/docs/src/app/(home)/_components/terminal.tsx new file mode 100644 index 0000000..5789c53 --- /dev/null +++ b/docs/src/app/(home)/_components/terminal.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { motion } from "motion/react"; +import { ease } from "@/lib/animation"; + +function Dot() { + return
; +} + +function OutputLine({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {" "} + + {label} + + {children} +
+ ); +} + +export function Terminal() { + return ( + +
+
+
+ + + + + terminal + +
+
+
+ $ funnel http 3000 --id + my-app +
+
+
funnel
+
+ + + https://my-app.tunnel.example.com + + + localhost:3000 + my-app +
+
+
+
+ + ); +} diff --git a/docs/src/app/(home)/layout.tsx b/docs/src/app/(home)/layout.tsx index 38bef26..a1e6605 100644 --- a/docs/src/app/(home)/layout.tsx +++ b/docs/src/app/(home)/layout.tsx @@ -1,13 +1,7 @@ -import type { ReactNode } from "react"; -import { HomeLayout } from "fumadocs-ui/layouts/home"; -import { baseOptions } from "@/app/layout.config"; -import { Footer } from "@/components/layout/footer"; +import { HomeLayout } from 'fumadocs-ui/layouts/home' +import { baseOptions } from '@/lib/layout.shared' +import type { ReactNode } from 'react' export default function Layout({ children }: { children: ReactNode }) { - return ( - <> - {children} -